├── app
├── .gitignore
├── src
│ ├── main
│ │ ├── java
│ │ │ └── otus
│ │ │ │ └── demo
│ │ │ │ └── totalcoverage
│ │ │ │ ├── Open.kt
│ │ │ │ ├── utils
│ │ │ │ └── NeedsTesting.kt
│ │ │ │ ├── di
│ │ │ │ ├── FeatureScope.kt
│ │ │ │ └── NetworkModule.kt
│ │ │ │ ├── baseexpenses
│ │ │ │ ├── ExpenseResponse.kt
│ │ │ │ ├── Expense.kt
│ │ │ │ ├── ExpensesService.kt
│ │ │ │ └── FakeExpensesServiceImpl.kt
│ │ │ │ ├── addexpense
│ │ │ │ ├── AddExpenseRequest.kt
│ │ │ │ ├── CategoryItem.kt
│ │ │ │ ├── AddExpensesComponent.kt
│ │ │ │ ├── CategoriesAdapter.kt
│ │ │ │ ├── AddExpensesInteractor.kt
│ │ │ │ ├── AddExpenseViewModel.kt
│ │ │ │ └── AddExpenseFragment.kt
│ │ │ │ ├── expensesfilter
│ │ │ │ ├── Filter.kt
│ │ │ │ └── FiltersFragment.kt
│ │ │ │ ├── ContainerActivity.kt
│ │ │ │ ├── ExpensesApp.kt
│ │ │ │ └── expenseslist
│ │ │ │ ├── ExpensesRepositoryImpl.kt
│ │ │ │ ├── ExpensesMapper.kt
│ │ │ │ ├── FiltersInteractor.kt
│ │ │ │ ├── ExpensesComponent.kt
│ │ │ │ ├── ExpensesAdapter.kt
│ │ │ │ ├── ExpensesItemView.kt
│ │ │ │ ├── ExpensesViewModel.kt
│ │ │ │ └── ExpensesFragment.kt
│ │ ├── res
│ │ │ ├── drawable
│ │ │ │ ├── beer.png
│ │ │ │ ├── foods.png
│ │ │ │ ├── health.png
│ │ │ │ ├── travel.png
│ │ │ │ ├── transport.png
│ │ │ │ ├── ic_baseline_filter_list_24.xml
│ │ │ │ ├── ic_baseline_add_24.xml
│ │ │ │ └── ic_launcher_background.xml
│ │ │ ├── mipmap-hdpi
│ │ │ │ ├── ic_launcher.png
│ │ │ │ └── ic_launcher_round.png
│ │ │ ├── mipmap-mdpi
│ │ │ │ ├── ic_launcher.png
│ │ │ │ └── ic_launcher_round.png
│ │ │ ├── mipmap-xhdpi
│ │ │ │ ├── ic_launcher.png
│ │ │ │ └── ic_launcher_round.png
│ │ │ ├── mipmap-xxhdpi
│ │ │ │ ├── ic_launcher.png
│ │ │ │ └── ic_launcher_round.png
│ │ │ ├── mipmap-xxxhdpi
│ │ │ │ ├── ic_launcher.png
│ │ │ │ └── ic_launcher_round.png
│ │ │ ├── mipmap-anydpi-v26
│ │ │ │ ├── ic_launcher.xml
│ │ │ │ └── ic_launcher_round.xml
│ │ │ ├── menu
│ │ │ │ ├── expenses_menu.xml
│ │ │ │ └── dropdownmenu.xml
│ │ │ ├── values
│ │ │ │ ├── colors.xml
│ │ │ │ ├── strings.xml
│ │ │ │ └── themes.xml
│ │ │ ├── layout
│ │ │ │ ├── category_item.xml
│ │ │ │ ├── activity_container.xml
│ │ │ │ ├── expenses_list_layout.xml
│ │ │ │ ├── expenses_item_layout.xml
│ │ │ │ ├── add_expenses_layout.xml
│ │ │ │ └── filters_layout.xml
│ │ │ ├── navigation
│ │ │ │ └── nav_graph.xml
│ │ │ └── drawable-v24
│ │ │ │ └── ic_launcher_foreground.xml
│ │ └── AndroidManifest.xml
│ └── test
│ │ └── java
│ │ └── otus
│ │ └── demo
│ │ └── totalcoverage
│ │ ├── testutils
│ │ ├── RxRule.kt
│ │ └── ExpensesFactory.kt
│ │ ├── expenseslist
│ │ ├── ExpensesRepositoryImplTest.kt
│ │ ├── FiltersInteractorTest.kt
│ │ ├── ExpensesViewModelTest.kt
│ │ └── ExpensesFragmentTest.kt
│ │ ├── addexpense
│ │ ├── AddExpensesInteractorTest.kt
│ │ ├── AddExpenseViewModelTest.kt
│ │ └── AddExpenseFragmentTest.kt
│ │ └── expensesfilter
│ │ └── FiltersFragmentTest.kt
├── proguard-rules.pro
└── build.gradle
├── settings.gradle
├── .idea
├── .gitignore
├── codeStyles
│ ├── codeStyleConfig.xml
│ └── Project.xml
├── compiler.xml
├── dictionaries
│ └── antonkazakov.xml
├── vcs.xml
├── gradle.xml
├── misc.xml
└── jarRepositories.xml
├── gradle
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── .gitignore
├── gradle.properties
├── gradlew.bat
└── gradlew
/app/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/settings.gradle:
--------------------------------------------------------------------------------
1 | include ':app'
2 | rootProject.name = "TotalCoverage"
--------------------------------------------------------------------------------
/.idea/.gitignore:
--------------------------------------------------------------------------------
1 | # Default ignored files
2 | /shelf/
3 | /workspace.xml
4 |
--------------------------------------------------------------------------------
/app/src/main/java/otus/demo/totalcoverage/Open.kt:
--------------------------------------------------------------------------------
1 | package otus.demo.totalcoverage
2 |
3 | annotation class Open
4 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/antonkazakov/TotalCoverage/HEAD/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/app/src/main/res/drawable/beer.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/antonkazakov/TotalCoverage/HEAD/app/src/main/res/drawable/beer.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable/foods.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/antonkazakov/TotalCoverage/HEAD/app/src/main/res/drawable/foods.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable/health.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/antonkazakov/TotalCoverage/HEAD/app/src/main/res/drawable/health.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable/travel.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/antonkazakov/TotalCoverage/HEAD/app/src/main/res/drawable/travel.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable/transport.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/antonkazakov/TotalCoverage/HEAD/app/src/main/res/drawable/transport.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/antonkazakov/TotalCoverage/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/antonkazakov/TotalCoverage/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/antonkazakov/TotalCoverage/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/antonkazakov/TotalCoverage/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/antonkazakov/TotalCoverage/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/antonkazakov/TotalCoverage/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/antonkazakov/TotalCoverage/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/antonkazakov/TotalCoverage/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/antonkazakov/TotalCoverage/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/antonkazakov/TotalCoverage/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/java/otus/demo/totalcoverage/utils/NeedsTesting.kt:
--------------------------------------------------------------------------------
1 | package otus.demo.totalcoverage.utils
2 |
3 | @Target(AnnotationTarget.CLASS)
4 | annotation class NeedsTesting
5 |
--------------------------------------------------------------------------------
/app/src/main/java/otus/demo/totalcoverage/di/FeatureScope.kt:
--------------------------------------------------------------------------------
1 | package otus.demo.totalcoverage.di
2 |
3 | import javax.inject.Scope
4 |
5 | @Scope
6 | annotation class FeatureScope
7 |
--------------------------------------------------------------------------------
/.idea/codeStyles/codeStyleConfig.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/.idea/compiler.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/dictionaries/antonkazakov.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | interactor
5 |
6 |
7 |
--------------------------------------------------------------------------------
/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | #Mon Jun 14 16:46:02 MSK 2021
2 | distributionBase=GRADLE_USER_HOME
3 | distributionPath=wrapper/dists
4 | zipStoreBase=GRADLE_USER_HOME
5 | zipStorePath=wrapper/dists
6 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
7 |
--------------------------------------------------------------------------------
/app/src/main/java/otus/demo/totalcoverage/baseexpenses/ExpenseResponse.kt:
--------------------------------------------------------------------------------
1 | package otus.demo.totalcoverage.baseexpenses
2 |
3 | data class ExpenseResponse(
4 | val id: Long,
5 | val name: String,
6 | val category: Category,
7 | val comment: String?,
8 | val amount: Long,
9 | val timeStamp: Long
10 | )
--------------------------------------------------------------------------------
/app/src/main/java/otus/demo/totalcoverage/addexpense/AddExpenseRequest.kt:
--------------------------------------------------------------------------------
1 | package otus.demo.totalcoverage.addexpense
2 |
3 | import otus.demo.totalcoverage.baseexpenses.Category
4 |
5 | data class AddExpenseRequest(
6 | val name: String,
7 | val category: Category,
8 | val amount: Long,
9 | val comment: String?
10 | )
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_baseline_filter_list_24.xml:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_baseline_add_24.xml:
--------------------------------------------------------------------------------
1 |
7 |
10 |
11 |
--------------------------------------------------------------------------------
/app/src/main/java/otus/demo/totalcoverage/baseexpenses/Expense.kt:
--------------------------------------------------------------------------------
1 | package otus.demo.totalcoverage.baseexpenses
2 |
3 | import java.io.Serializable
4 |
5 | data class Expense(
6 | val id: Long,
7 | val title: String,
8 | val category: Category,
9 | val comment: String? = null,
10 | val amount: Long,
11 | val date: String
12 | ) : Serializable
13 |
14 | enum class Category {
15 | FOOD, HEALTH, TRANSPORT, BARS, TRAVEL
16 | }
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.iml
2 | .gradle
3 | /local.properties
4 | /.idea/caches
5 | /.idea/libraries
6 | /.idea/modules.xml
7 | /.idea/workspace.xml
8 | /.idea/navEditor.xml
9 | /.idea/assetWizardSettings.xml
10 | .DS_Store
11 | build
12 | /captures
13 | .externalNativeBuild
14 | .idea
15 | .kotlintest
16 | run.sh
17 | *.idea
18 | *.lock
19 | *.apk
20 | *.aab
21 | output-metadata.json
22 | /fastlane/README.md
23 | /fastlane/report.xml
24 | /build.properties
25 | gradle.properties
26 | *.hprof
--------------------------------------------------------------------------------
/app/src/main/res/menu/expenses_menu.xml:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/app/src/main/java/otus/demo/totalcoverage/expensesfilter/Filter.kt:
--------------------------------------------------------------------------------
1 | package otus.demo.totalcoverage.expensesfilter
2 |
3 | import otus.demo.totalcoverage.baseexpenses.Category
4 | import java.io.Serializable
5 |
6 | data class Filter(
7 | val categories: List = Category.values().toList(),
8 | val amountRange: LongRange = Long.MIN_VALUE..Long.MAX_VALUE,
9 | val sort: Sort = Sort.DESC_DATE
10 | ) : Serializable
11 |
12 | enum class Sort {
13 | ASC, DESC, DESC_DATE, ASC_DATE
14 | }
--------------------------------------------------------------------------------
/app/src/main/java/otus/demo/totalcoverage/ContainerActivity.kt:
--------------------------------------------------------------------------------
1 | package otus.demo.totalcoverage
2 |
3 | import androidx.appcompat.app.AppCompatActivity
4 | import android.os.Bundle
5 | import otus.demo.totalcoverage.di.AppComponent
6 | import otus.demo.totalcoverage.di.DaggerAppComponent
7 |
8 | class ContainerActivity : AppCompatActivity() {
9 |
10 | override fun onCreate(savedInstanceState: Bundle?) {
11 | super.onCreate(savedInstanceState)
12 | setContentView(R.layout.activity_container)
13 | }
14 | }
--------------------------------------------------------------------------------
/app/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #673ab7
4 | #9a67ea
5 | #320b86
6 | #ffd54f
7 | #ffff81
8 | #c8a415
9 | #ffffff
10 | #000000
11 |
--------------------------------------------------------------------------------
/app/src/main/java/otus/demo/totalcoverage/baseexpenses/ExpensesService.kt:
--------------------------------------------------------------------------------
1 | package otus.demo.totalcoverage.baseexpenses
2 |
3 | import io.reactivex.Single
4 | import otus.demo.totalcoverage.addexpense.AddExpenseRequest
5 | import retrofit2.http.Body
6 | import retrofit2.http.GET
7 | import retrofit2.http.POST
8 |
9 | interface ExpensesService {
10 |
11 | @GET("expenses")
12 | suspend fun getExpenses(): List
13 |
14 | @POST("expenses")
15 | fun addExpense(@Body addExpenseRequest: AddExpenseRequest): Single
16 | }
--------------------------------------------------------------------------------
/app/src/main/res/menu/dropdownmenu.xml:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/app/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | Expenses
3 | No expenses :)
4 | Сначала крупные покупки
5 | Сначала мелкие покупки
6 | Выбрать категорию
7 | Сначала старые покупки
8 | Сначала новые покупки
9 | Применить
10 | Сбросить
11 |
--------------------------------------------------------------------------------
/app/src/main/java/otus/demo/totalcoverage/ExpensesApp.kt:
--------------------------------------------------------------------------------
1 | package otus.demo.totalcoverage
2 |
3 | import android.app.Application
4 | import otus.demo.totalcoverage.di.AppComponent
5 | import otus.demo.totalcoverage.di.DaggerAppComponent
6 |
7 | open class ExpensesApp : Application() {
8 |
9 | private lateinit var appComponent: AppComponent
10 |
11 | override fun onCreate() {
12 | super.onCreate()
13 | appComponent = DaggerAppComponent.create()
14 | }
15 |
16 | open fun getAppComponent(): AppComponent {
17 | return appComponent
18 | }
19 |
20 | protected open fun initAnalytics(){
21 | //some analytics initialization
22 | }
23 | }
--------------------------------------------------------------------------------
/app/src/main/res/layout/category_item.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
16 |
--------------------------------------------------------------------------------
/.idea/gradle.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
19 |
20 |
--------------------------------------------------------------------------------
/app/src/main/java/otus/demo/totalcoverage/expenseslist/ExpensesRepositoryImpl.kt:
--------------------------------------------------------------------------------
1 | package otus.demo.totalcoverage.expenseslist
2 |
3 | import otus.demo.totalcoverage.baseexpenses.Expense
4 | import otus.demo.totalcoverage.baseexpenses.ExpensesService
5 | import otus.demo.totalcoverage.utils.NeedsTesting
6 | import javax.inject.Inject
7 |
8 | @NeedsTesting
9 | class ExpensesRepositoryImpl @Inject constructor(
10 | private val expensesService: ExpensesService,
11 | private val expensesMapper: ExpensesMapper
12 | ) : ExpensesRepository {
13 |
14 | override suspend fun getExpenses(): List {
15 | return expensesService.getExpenses()
16 | .map { expensesMapper.map(it) }
17 | }
18 | }
19 |
20 | interface ExpensesRepository {
21 |
22 | suspend fun getExpenses(): List
23 | }
24 |
--------------------------------------------------------------------------------
/app/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
--------------------------------------------------------------------------------
/app/src/main/res/values/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
17 |
--------------------------------------------------------------------------------
/app/src/test/java/otus/demo/totalcoverage/testutils/RxRule.kt:
--------------------------------------------------------------------------------
1 | package otus.demo.totalcoverage.testutils
2 |
3 | import io.reactivex.android.plugins.RxAndroidPlugins
4 | import io.reactivex.plugins.RxJavaPlugins
5 | import io.reactivex.schedulers.Schedulers
6 | import org.junit.rules.ExternalResource
7 |
8 | class RxRule : ExternalResource() {
9 |
10 | override fun before() {
11 | super.before()
12 | replaceSchedulers()
13 | }
14 |
15 | private fun replaceSchedulers() {
16 | RxAndroidPlugins.setInitMainThreadSchedulerHandler { Schedulers.trampoline() }
17 | RxJavaPlugins.setIoSchedulerHandler { Schedulers.trampoline() }
18 | RxJavaPlugins.setInitNewThreadSchedulerHandler { Schedulers.trampoline() }
19 | RxJavaPlugins.setSingleSchedulerHandler { Schedulers.trampoline() }
20 | }
21 |
22 | override fun after() {
23 | super.after()
24 | RxJavaPlugins.reset()
25 | RxAndroidPlugins.reset()
26 | }
27 | }
--------------------------------------------------------------------------------
/app/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
6 |
15 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/app/src/main/java/otus/demo/totalcoverage/addexpense/CategoryItem.kt:
--------------------------------------------------------------------------------
1 | package otus.demo.totalcoverage.addexpense
2 |
3 | import android.content.Context
4 | import android.util.AttributeSet
5 | import android.widget.FrameLayout
6 | import android.widget.ImageView
7 | import otus.demo.totalcoverage.R
8 | import otus.demo.totalcoverage.baseexpenses.Category
9 |
10 | class CategoryItem @JvmOverloads constructor(
11 | context: Context,
12 | attrs: AttributeSet? = null,
13 | defStyleAttr: Int = 0
14 | ) : FrameLayout(context, attrs, defStyleAttr) {
15 |
16 | fun populate(categoryItem: Category) {
17 | val icon = when (categoryItem) {
18 | Category.FOOD -> R.drawable.foods
19 | Category.HEALTH -> R.drawable.health
20 | Category.TRANSPORT -> R.drawable.transport
21 | Category.BARS -> R.drawable.beer
22 | Category.TRAVEL -> R.drawable.travel
23 | }
24 | findViewById(R.id.imageView)
25 | .setImageDrawable(context.getDrawable(icon))
26 | }
27 | }
--------------------------------------------------------------------------------
/app/src/main/res/layout/activity_container.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
21 |
22 |
--------------------------------------------------------------------------------
/.idea/misc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
--------------------------------------------------------------------------------
/app/src/main/java/otus/demo/totalcoverage/expenseslist/ExpensesMapper.kt:
--------------------------------------------------------------------------------
1 | package otus.demo.totalcoverage.expenseslist
2 |
3 | import otus.demo.totalcoverage.Open
4 | import otus.demo.totalcoverage.baseexpenses.Expense
5 | import otus.demo.totalcoverage.baseexpenses.ExpenseResponse
6 | import otus.demo.totalcoverage.utils.NeedsTesting
7 | import java.text.SimpleDateFormat
8 | import java.util.*
9 | import javax.inject.Inject
10 |
11 | @NeedsTesting
12 | @Open
13 | class ExpensesMapper @Inject constructor() {
14 |
15 | fun map(expensesResponse: ExpenseResponse): Expense {
16 | return with(expensesResponse) {
17 | Expense(
18 | id = id,
19 | title = name,
20 | category = category,
21 | amount = amount,
22 | comment = comment,
23 | date = timeStamp.formatDate()
24 | )
25 | }
26 | }
27 |
28 | private fun Long.formatDate(): String {
29 | val simpleDateFormat = SimpleDateFormat("dd-MM-yyyy HH:mm", Locale.getDefault())
30 | return simpleDateFormat.format(Date(this))
31 | }
32 | }
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | # Project-wide Gradle settings.
2 | # IDE (e.g. Android Studio) users:
3 | # Gradle settings configured through the IDE *will override*
4 | # any settings specified in this file.
5 | # For more details on how to configure your build environment visit
6 | # http://www.gradle.org/docs/current/userguide/build_environment.html
7 | # Specifies the JVM arguments used for the daemon process.
8 | # The setting is particularly useful for tweaking memory settings.
9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
10 | # When configured, Gradle will run in incubating parallel mode.
11 | # This option should only be used with decoupled projects. More details, visit
12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
13 | # org.gradle.parallel=true
14 | # AndroidX package structure to make it clearer which packages are bundled with the
15 | # Android operating system, and which are packaged with your app"s APK
16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn
17 | android.useAndroidX=true
18 | # Automatically convert third-party libraries to use AndroidX
19 | android.enableJetifier=true
20 | # Kotlin code style for this project: "official" or "obsolete":
21 | kotlin.code.style=official
--------------------------------------------------------------------------------
/app/src/main/java/otus/demo/totalcoverage/addexpense/AddExpensesComponent.kt:
--------------------------------------------------------------------------------
1 | package otus.demo.totalcoverage.addexpense
2 |
3 | import androidx.lifecycle.ViewModelProvider
4 | import dagger.Binds
5 | import dagger.Component
6 | import dagger.Module
7 | import dagger.Provides
8 | import io.reactivex.disposables.CompositeDisposable
9 | import otus.demo.totalcoverage.di.AppComponent
10 | import otus.demo.totalcoverage.di.FeatureScope
11 |
12 | @FeatureScope
13 | @Component(
14 | modules = [AddExpensesModule::class],
15 | dependencies = [AppComponent::class]
16 | )
17 | interface AddExpensesComponent {
18 |
19 | companion object {
20 | fun getAddExpensesComponent(appComponent: AppComponent): AddExpensesComponent {
21 | return DaggerAddExpensesComponent.builder().appComponent(appComponent).build()
22 | }
23 | }
24 |
25 | fun inject(expensesListFragment: AddExpenseFragment)
26 | }
27 |
28 | @Module
29 | interface AddExpensesModule {
30 |
31 | companion object {
32 |
33 | @Provides
34 | fun providesScope(): CompositeDisposable {
35 | return CompositeDisposable()
36 | }
37 | }
38 |
39 | @Binds
40 | fun bindFactory(expensesViewModelFactory: AddExpenseViewModelModelFactory): ViewModelProvider.Factory
41 | }
--------------------------------------------------------------------------------
/app/src/test/java/otus/demo/totalcoverage/expenseslist/ExpensesRepositoryImplTest.kt:
--------------------------------------------------------------------------------
1 | package otus.demo.totalcoverage.expenseslist
2 |
3 | import kotlinx.coroutines.test.runTest
4 | import org.junit.Assert.assertEquals
5 | import org.junit.Test
6 | import org.mockito.kotlin.*
7 | import otus.demo.totalcoverage.baseexpenses.ExpensesService
8 | import otus.demo.totalcoverage.testutils.ExpensesFactory
9 |
10 | class ExpensesRepositoryImplTest {
11 |
12 | private val expensesService: ExpensesService = mock()
13 | private val expensesMapper: ExpensesMapper = mock()
14 |
15 | private val repository = ExpensesRepositoryImpl(expensesService, expensesMapper)
16 |
17 | @Test
18 | fun `should return mapped expenses`() {
19 | runTest {
20 | whenever(expensesService.getExpenses()).thenReturn(ExpensesFactory.getExpenseResponses())
21 | whenever(expensesMapper.map(any())).thenReturn(ExpensesFactory.getExpense())
22 | val expected = listOf(ExpensesFactory.getExpense(), ExpensesFactory.getExpense())
23 |
24 | val actual = repository.getExpenses()
25 |
26 | assertEquals(expected, actual)
27 | verify(expensesService).getExpenses()
28 | verify(expensesMapper, times(2)).map(any())
29 | }
30 | }
31 | }
--------------------------------------------------------------------------------
/app/src/test/java/otus/demo/totalcoverage/addexpense/AddExpensesInteractorTest.kt:
--------------------------------------------------------------------------------
1 | package otus.demo.totalcoverage.addexpense
2 |
3 | import io.reactivex.Single
4 | import org.junit.Test
5 | import org.mockito.kotlin.any
6 | import org.mockito.kotlin.mock
7 | import org.mockito.kotlin.whenever
8 | import otus.demo.totalcoverage.baseexpenses.Category
9 | import otus.demo.totalcoverage.baseexpenses.ExpensesService
10 | import otus.demo.totalcoverage.expenseslist.ExpensesMapper
11 | import otus.demo.totalcoverage.testutils.ExpensesFactory
12 |
13 | class AddExpensesInteractorTest {
14 |
15 | private val expensesService: ExpensesService = mock()
16 | private val expensesMapper: ExpensesMapper = mock()
17 |
18 | private val addExpensesInteractor = AddExpensesInteractor(expensesService, expensesMapper)
19 |
20 | @Test
21 | fun `should emit Expense and complete`() {
22 | val expected = ExpensesFactory.getExpense()
23 | whenever(expensesService.addExpense(any())).thenReturn(Single.just(ExpensesFactory.getExpenseResponse()))
24 | whenever(expensesMapper.map(any())).thenReturn(expected)
25 |
26 | addExpensesInteractor
27 | .addExpense("dummy_title", 100L, Category.BARS, "dummy_comment")
28 | .test()
29 | .assertValue(expected)
30 | }
31 | }
--------------------------------------------------------------------------------
/app/src/main/java/otus/demo/totalcoverage/expenseslist/FiltersInteractor.kt:
--------------------------------------------------------------------------------
1 | package otus.demo.totalcoverage.expenseslist
2 |
3 | import otus.demo.totalcoverage.Open
4 | import otus.demo.totalcoverage.baseexpenses.ExpenseResponse
5 | import otus.demo.totalcoverage.baseexpenses.ExpensesService
6 | import otus.demo.totalcoverage.expensesfilter.Filter
7 | import otus.demo.totalcoverage.expensesfilter.Sort
8 | import otus.demo.totalcoverage.utils.NeedsTesting
9 | import javax.inject.Inject
10 |
11 | @NeedsTesting
12 | @Open
13 | class FiltersInteractor @Inject constructor(
14 | private val expensesService: ExpensesService
15 | ) {
16 |
17 | suspend fun getFilteredExpenses(filter: Filter): List {
18 | return expensesService.getExpenses()
19 | .filter { it.category in filter.categories }
20 | .filter { it.amount in filter.amountRange }
21 | .sortedWith(comparatorFactory(filter.sort))
22 | }
23 |
24 | private fun comparatorFactory(sort: Sort): Comparator {
25 | return when (sort) {
26 | Sort.ASC -> compareBy { it.amount }
27 | Sort.DESC -> compareByDescending { it.amount }
28 | Sort.ASC_DATE -> compareBy { it.timeStamp }
29 | Sort.DESC_DATE -> compareByDescending { it.timeStamp }
30 | }
31 | }
32 | }
--------------------------------------------------------------------------------
/.idea/jarRepositories.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
--------------------------------------------------------------------------------
/app/src/main/java/otus/demo/totalcoverage/baseexpenses/FakeExpensesServiceImpl.kt:
--------------------------------------------------------------------------------
1 | package otus.demo.totalcoverage.baseexpenses
2 |
3 | import io.reactivex.Single
4 | import otus.demo.totalcoverage.addexpense.AddExpenseRequest
5 | import java.util.concurrent.atomic.AtomicLong
6 | import javax.inject.Inject
7 |
8 | class FakeExpensesServiceImpl
9 | @Inject constructor() : ExpensesService {
10 |
11 | private val expenses = mutableListOf()
12 | private val counter = AtomicLong(0)
13 |
14 | override suspend fun getExpenses(): List {
15 | return expenses
16 | }
17 |
18 | override fun addExpense(addExpenseRequest: AddExpenseRequest): Single {
19 | val response = map(addExpenseRequest)
20 | val wasAdded = expenses.add(response)
21 | return if (wasAdded) Single.just(response) else Single.error(RuntimeException("Something went wrong"))
22 | }
23 |
24 | private fun map(addExpenseRequest: AddExpenseRequest): ExpenseResponse {
25 | return ExpenseResponse(
26 | id = counter.getAndIncrement(),
27 | name = addExpenseRequest.name,
28 | category = addExpenseRequest.category,
29 | comment = addExpenseRequest.comment,
30 | amount = addExpenseRequest.amount,
31 | timeStamp = System.currentTimeMillis()
32 | )
33 | }
34 | }
--------------------------------------------------------------------------------
/app/src/main/java/otus/demo/totalcoverage/addexpense/CategoriesAdapter.kt:
--------------------------------------------------------------------------------
1 | package otus.demo.totalcoverage.addexpense
2 |
3 | import android.view.LayoutInflater
4 | import android.view.ViewGroup
5 | import androidx.recyclerview.widget.RecyclerView
6 | import otus.demo.totalcoverage.R
7 | import otus.demo.totalcoverage.baseexpenses.Category
8 |
9 | class CategoriesAdapter(
10 | private val action: (category: Category) -> Unit
11 | ) : RecyclerView.Adapter() {
12 |
13 | class CategoriesVH(private val categoryItem: CategoryItem) :
14 | RecyclerView.ViewHolder(categoryItem) {
15 |
16 | fun populate(category: Category) {
17 | categoryItem.populate(category)
18 | }
19 | }
20 |
21 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CategoriesVH {
22 | return CategoriesVH(
23 | LayoutInflater.from(parent.context)
24 | .inflate(R.layout.category_item, parent, false) as CategoryItem
25 | )
26 | }
27 |
28 | override fun onBindViewHolder(holder: CategoriesVH, position: Int) {
29 | holder.populate(Category.values()[holder.adapterPosition])
30 | holder.itemView.setOnClickListener {
31 | action(Category.values()[holder.adapterPosition])
32 | }
33 | }
34 |
35 | override fun getItemCount(): Int {
36 | return Category.values().size
37 | }
38 | }
39 |
40 |
--------------------------------------------------------------------------------
/app/src/main/java/otus/demo/totalcoverage/addexpense/AddExpensesInteractor.kt:
--------------------------------------------------------------------------------
1 | package otus.demo.totalcoverage.addexpense
2 |
3 | import io.reactivex.Single
4 | import otus.demo.totalcoverage.Open
5 | import otus.demo.totalcoverage.baseexpenses.Category
6 | import otus.demo.totalcoverage.baseexpenses.Expense
7 | import otus.demo.totalcoverage.baseexpenses.ExpensesService
8 | import otus.demo.totalcoverage.expenseslist.ExpensesMapper
9 | import otus.demo.totalcoverage.utils.NeedsTesting
10 | import javax.inject.Inject
11 |
12 | @NeedsTesting
13 | @Open
14 | class AddExpensesInteractor @Inject constructor(
15 | private val expensesService: ExpensesService,
16 | private val expensesMapper: ExpensesMapper
17 | ) {
18 |
19 | fun addExpense(
20 | title: String,
21 | amount: Long,
22 | category: Category,
23 | comment: String? = null
24 | ): Single {
25 | return expensesService
26 | .addExpense(assembleRequest(title, amount, category, comment))
27 | .map { expensesMapper.map(it) }
28 | }
29 |
30 | private fun assembleRequest(
31 | title: String,
32 | amount: Long,
33 | category: Category,
34 | comment: String? = null
35 | ): AddExpenseRequest {
36 | return AddExpenseRequest(
37 | name = title,
38 | amount = amount,
39 | category = category,
40 | comment = comment
41 | )
42 | }
43 | }
--------------------------------------------------------------------------------
/app/src/test/java/otus/demo/totalcoverage/testutils/ExpensesFactory.kt:
--------------------------------------------------------------------------------
1 | package otus.demo.totalcoverage.testutils
2 |
3 | import otus.demo.totalcoverage.baseexpenses.Category
4 | import otus.demo.totalcoverage.baseexpenses.Expense
5 | import otus.demo.totalcoverage.baseexpenses.ExpenseResponse
6 |
7 | object ExpensesFactory {
8 |
9 | fun getExpenses() = listOf(getExpense(), getExpense())
10 |
11 | fun getExpense(): Expense {
12 | return Expense(
13 | id = 100,
14 | title = "dummy_name",
15 | category = Category.FOOD,
16 | comment = "dummy_comment",
17 | amount = 3000L,
18 | date = "22-06-2021 10:01"
19 | )
20 | }
21 |
22 | fun getExpenseResponse(): ExpenseResponse {
23 | return ExpenseResponse(
24 | 1,
25 | "Some food",
26 | Category.FOOD,
27 | "Some grocery shop",
28 | 1200L,
29 | 1624345281000L
30 | )
31 | }
32 |
33 | fun getExpenseResponses(): List {
34 | return listOf(
35 | ExpenseResponse(
36 | 1,
37 | "Some food",
38 | Category.FOOD,
39 | "Some grocery shop",
40 | 1200L,
41 | 1624345281000L
42 | ),
43 | ExpenseResponse(1, "Some food", Category.BARS, "Some bar", 3000L, 1624345281000L),
44 | )
45 | }
46 | }
--------------------------------------------------------------------------------
/app/src/main/java/otus/demo/totalcoverage/di/NetworkModule.kt:
--------------------------------------------------------------------------------
1 | package otus.demo.totalcoverage.di
2 |
3 | import dagger.Component
4 | import dagger.Module
5 | import dagger.Provides
6 | import otus.demo.totalcoverage.baseexpenses.ExpensesService
7 | import otus.demo.totalcoverage.baseexpenses.FakeExpensesServiceImpl
8 | import retrofit2.Retrofit
9 | import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory
10 | import retrofit2.converter.gson.GsonConverterFactory
11 | import javax.inject.Singleton
12 |
13 | private const val URL = "http://localhost"
14 |
15 | @Singleton
16 | @Component(modules = [NetworkModule::class])
17 | interface AppComponent {
18 |
19 | fun provideExpensesService(): ExpensesService
20 | }
21 |
22 | @Module
23 | object NetworkModule {
24 |
25 | // @Provides
26 | // @Singleton
27 | // fun provideExpensesNetworkService(retrofit: Retrofit): ExpensesService {
28 | // return retrofit.create(ExpensesService::class.java)
29 | // }
30 |
31 | @Provides
32 | @Singleton
33 | fun provideFakeExpensesNetworkService(): ExpensesService {
34 | return FakeExpensesServiceImpl()
35 | }
36 |
37 | @Provides
38 | @Singleton
39 | fun provideRetrofitClient(): Retrofit {
40 | return Retrofit.Builder()
41 | .baseUrl(URL)
42 | .addCallAdapterFactory(RxJava2CallAdapterFactory.create())
43 | .addConverterFactory(GsonConverterFactory.create())
44 | .build()
45 | }
46 | }
--------------------------------------------------------------------------------
/app/src/main/res/layout/expenses_list_layout.xml:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
16 |
17 |
26 |
27 |
32 |
33 |
--------------------------------------------------------------------------------
/app/src/main/java/otus/demo/totalcoverage/expenseslist/ExpensesComponent.kt:
--------------------------------------------------------------------------------
1 | package otus.demo.totalcoverage.expenseslist
2 |
3 | import androidx.lifecycle.ViewModelProvider
4 | import dagger.Binds
5 | import dagger.Component
6 | import dagger.Module
7 | import dagger.Provides
8 | import kotlinx.coroutines.CoroutineDispatcher
9 | import kotlinx.coroutines.Dispatchers
10 | import otus.demo.totalcoverage.di.AppComponent
11 | import otus.demo.totalcoverage.di.FeatureScope
12 | import javax.inject.Named
13 | import javax.inject.Qualifier
14 |
15 | @FeatureScope
16 | @Component(
17 | modules = [ExpensesModule::class],
18 | dependencies = [AppComponent::class]
19 | )
20 | interface ExpensesComponent {
21 |
22 | companion object {
23 |
24 | fun getExpensesComponent(appComponent: AppComponent): ExpensesComponent {
25 | return DaggerExpensesComponent.builder().appComponent(appComponent).build()
26 | }
27 | }
28 |
29 | fun inject(expensesFragment: ExpensesFragment)
30 | }
31 |
32 | @Module
33 | interface ExpensesModule {
34 |
35 | companion object {
36 |
37 | @IO
38 | @Provides
39 | fun providesIoDispatcher(): CoroutineDispatcher {
40 | return Dispatchers.IO
41 | }
42 | }
43 |
44 | @Binds
45 | fun bindRepository(expensesRepositoryImpl: ExpensesRepositoryImpl): ExpensesRepository
46 |
47 | @Binds
48 | fun bindFactory(expensesViewModelFactory: ExpensesViewModelFactory): ViewModelProvider.Factory
49 | }
50 |
51 | @Qualifier
52 | annotation class IO
--------------------------------------------------------------------------------
/app/src/main/java/otus/demo/totalcoverage/expenseslist/ExpensesAdapter.kt:
--------------------------------------------------------------------------------
1 | package otus.demo.totalcoverage.expenseslist
2 |
3 | import android.view.LayoutInflater
4 | import android.view.ViewGroup
5 | import androidx.recyclerview.widget.RecyclerView
6 | import otus.demo.totalcoverage.Open
7 | import otus.demo.totalcoverage.R
8 | import otus.demo.totalcoverage.baseexpenses.Expense
9 | import javax.inject.Inject
10 |
11 | @Open
12 | class ExpensesAdapter @Inject constructor() :
13 | RecyclerView.Adapter() {
14 |
15 | val items: MutableList = arrayListOf()
16 |
17 | fun addItems(expenses: List) {
18 | items.clear()
19 | items.addAll(expenses)
20 | notifyDataSetChanged()
21 | }
22 |
23 | fun addItem(expense: Expense) {
24 | items.add(expense)
25 | notifyItemChanged(items.size)
26 | }
27 |
28 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ExpensesViewHolder(
29 | LayoutInflater.from(parent.context)
30 | .inflate(R.layout.expenses_item_layout, parent, false) as ExpensesItemView
31 | )
32 |
33 | override fun onBindViewHolder(holder: ExpensesViewHolder, position: Int) {
34 | holder.populate(items[position])
35 | }
36 |
37 | override fun getItemCount() = items.size
38 | }
39 |
40 | class ExpensesViewHolder(private val expensesItemView: ExpensesItemView) :
41 | RecyclerView.ViewHolder(expensesItemView) {
42 |
43 | fun populate(expense: Expense) {
44 | expensesItemView.populate(expense)
45 | }
46 | }
--------------------------------------------------------------------------------
/app/src/main/res/navigation/nav_graph.xml:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
11 |
16 |
23 |
24 |
28 |
32 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable-v24/ic_launcher_foreground.xml:
--------------------------------------------------------------------------------
1 |
8 |
9 |
10 |
16 |
19 |
22 |
23 |
24 |
25 |
31 |
--------------------------------------------------------------------------------
/app/src/main/java/otus/demo/totalcoverage/expenseslist/ExpensesItemView.kt:
--------------------------------------------------------------------------------
1 | package otus.demo.totalcoverage.expenseslist
2 |
3 | import android.content.Context
4 | import android.util.AttributeSet
5 | import android.widget.ImageView
6 | import android.widget.TextView
7 | import androidx.constraintlayout.widget.ConstraintLayout
8 | import otus.demo.totalcoverage.R
9 | import otus.demo.totalcoverage.baseexpenses.Category
10 | import otus.demo.totalcoverage.baseexpenses.Expense
11 | import otus.demo.totalcoverage.utils.NeedsTesting
12 |
13 | @NeedsTesting
14 | class ExpensesItemView @JvmOverloads constructor(
15 | context: Context,
16 | attrs: AttributeSet? = null,
17 | defStyleAttr: Int = 0
18 | ) : ConstraintLayout(context, attrs, defStyleAttr) {
19 |
20 | private lateinit var nameText: TextView
21 | private lateinit var commentText: TextView
22 | private lateinit var amountText: TextView
23 | private lateinit var categoryImage: ImageView
24 |
25 | override fun onFinishInflate() {
26 | super.onFinishInflate()
27 | commentText = findViewById(R.id.tv_comment)
28 | amountText = findViewById(R.id.tv_amount)
29 | nameText = findViewById(R.id.tv_name)
30 | categoryImage = findViewById(R.id.img_category)
31 | }
32 |
33 | fun populate(expense: Expense) {
34 | val image = when (expense.category) {
35 | Category.FOOD -> R.drawable.foods
36 | Category.HEALTH -> R.drawable.health
37 | Category.TRANSPORT -> R.drawable.transport
38 | Category.BARS -> R.drawable.beer
39 | Category.TRAVEL -> R.drawable.travel
40 | }
41 | categoryImage.setImageDrawable(context.getDrawable(image))
42 | nameText.text = expense.title
43 | commentText.text = expense.comment
44 | amountText.text = "${expense.amount} ₽"
45 | }
46 | }
--------------------------------------------------------------------------------
/app/src/test/java/otus/demo/totalcoverage/addexpense/AddExpenseViewModelTest.kt:
--------------------------------------------------------------------------------
1 | package otus.demo.totalcoverage.addexpense
2 |
3 | import androidx.arch.core.executor.testing.InstantTaskExecutorRule
4 | import io.reactivex.Single
5 | import org.junit.Assert
6 | import org.junit.Assert.assertEquals
7 | import org.junit.Rule
8 | import org.junit.Test
9 | import org.mockito.kotlin.any
10 | import org.mockito.kotlin.anyOrNull
11 | import org.mockito.kotlin.mock
12 | import org.mockito.kotlin.whenever
13 | import otus.demo.totalcoverage.baseexpenses.Category
14 | import otus.demo.totalcoverage.testutils.ExpensesFactory
15 | import otus.demo.totalcoverage.testutils.RxRule
16 | import java.lang.RuntimeException
17 |
18 | class AddExpenseViewModelTest {
19 |
20 | @get:Rule
21 | val rxRule = RxRule()
22 |
23 | @get:Rule
24 | val instantTaskExecutorRule = InstantTaskExecutorRule()
25 |
26 | private val addExpensesInteractor: AddExpensesInteractor = mock()
27 |
28 | private val addExpenseViewModel =
29 | AddExpenseViewModel(addExpensesInteractor)
30 |
31 | @Test
32 | fun `should send Success event`() {
33 | whenever(addExpensesInteractor.addExpense(any(), any(), any(), anyOrNull()))
34 | .thenReturn(Single.just(ExpensesFactory.getExpense()))
35 |
36 | addExpenseViewModel
37 | .addExpense("dummy_title", "100", Category.BARS, "dummy_comment")
38 |
39 | val actual = addExpenseViewModel.liveData.value
40 | assertEquals(Success(ExpensesFactory.getExpense()), actual)
41 | }
42 |
43 | @Test
44 | fun `should send Error event when failure was emited`() {
45 | val expectedException = RuntimeException("failure")
46 | whenever(addExpensesInteractor.addExpense(any(), any(), any(), anyOrNull()))
47 | .thenReturn(Single.error(expectedException))
48 | addExpenseViewModel
49 | .addExpense("dummy_title", "100", Category.BARS, "dummy_comment")
50 |
51 | val actual = addExpenseViewModel.liveData.value
52 | assertEquals(Error(expectedException), actual)
53 | }
54 | }
--------------------------------------------------------------------------------
/app/src/test/java/otus/demo/totalcoverage/expensesfilter/FiltersFragmentTest.kt:
--------------------------------------------------------------------------------
1 | package otus.demo.totalcoverage.expensesfilter
2 |
3 | import androidx.fragment.app.testing.launchFragmentInContainer
4 | import androidx.navigation.Navigation
5 | import androidx.navigation.testing.TestNavHostController
6 | import androidx.test.core.app.ApplicationProvider
7 | import androidx.test.espresso.Espresso.onView
8 | import androidx.test.espresso.action.ViewActions
9 | import androidx.test.espresso.matcher.ViewMatchers
10 | import androidx.test.ext.junit.runners.AndroidJUnit4
11 | import org.junit.Assert.*
12 | import org.junit.Test
13 | import org.junit.runner.RunWith
14 | import otus.demo.totalcoverage.R
15 | import otus.demo.totalcoverage.baseexpenses.Category
16 | import otus.demo.totalcoverage.expensesfilter.Filter
17 | import otus.demo.totalcoverage.expensesfilter.FiltersFragment
18 |
19 | @RunWith(AndroidJUnit4::class)
20 | class FiltersFragmentTest {
21 |
22 | @Test
23 | fun `23`() {
24 | //given:
25 | val expected = Filter(listOf(Category.BARS), (0L..2000L))
26 | val navController = TestNavHostController(
27 | ApplicationProvider.getApplicationContext()
28 | )
29 | val titleScenario = launchFragmentInContainer()
30 | titleScenario.onFragment { fragment ->
31 | navController.setGraph(R.navigation.nav_graph)
32 | Navigation.setViewNavController(fragment.requireView(), navController)
33 | }
34 |
35 | //when:
36 | onView(ViewMatchers.withId(R.id.submit_filters_button)).perform(ViewActions.click())
37 |
38 | //then 'Filter equal to expected':
39 | titleScenario.onFragment { fragment ->
40 | fragment.parentFragmentManager.setFragmentResultListener(
41 | "FILTERED_EXPENSES", fragment
42 | ) { _, b ->
43 | assertEquals(expected, b.get("FILTERS_KEY"))
44 | }
45 | }
46 |
47 | //and 'destination is expensesListFragment':
48 | assertEquals(
49 | navController.currentDestination?.id,
50 | R.id.expensesListFragment
51 | )
52 | }
53 | }
--------------------------------------------------------------------------------
/app/src/main/java/otus/demo/totalcoverage/addexpense/AddExpenseViewModel.kt:
--------------------------------------------------------------------------------
1 | package otus.demo.totalcoverage.addexpense
2 |
3 | import androidx.lifecycle.LiveData
4 | import androidx.lifecycle.MutableLiveData
5 | import androidx.lifecycle.ViewModel
6 | import androidx.lifecycle.ViewModelProvider
7 | import io.reactivex.android.schedulers.AndroidSchedulers
8 | import io.reactivex.disposables.Disposable
9 | import io.reactivex.schedulers.Schedulers
10 | import otus.demo.totalcoverage.Open
11 | import otus.demo.totalcoverage.baseexpenses.Category
12 | import otus.demo.totalcoverage.baseexpenses.Expense
13 | import otus.demo.totalcoverage.utils.NeedsTesting
14 | import java.io.Serializable
15 | import javax.inject.Inject
16 |
17 | @NeedsTesting
18 | @Open
19 | class AddExpenseViewModel
20 | @Inject constructor(
21 | private val addExpensesInteractor: AddExpensesInteractor,
22 | ) : ViewModel() {
23 |
24 | private val _liveData: MutableLiveData = MutableLiveData()
25 | var liveData: LiveData = _liveData
26 |
27 | var disposable: Disposable? = null
28 |
29 | fun addExpense(
30 | title: String,
31 | amount: String,
32 | category: Category,
33 | comment: String? = null
34 | ) {
35 | disposable = addExpensesInteractor.addExpense(title, amount.toLong(), category, comment)
36 | .subscribeOn(Schedulers.io())
37 | .observeOn(AndroidSchedulers.mainThread())
38 | .subscribe({ _liveData.value = Success(it) }, { _liveData.value = Error(it) })
39 | }
40 |
41 | override fun onCleared() {
42 | super.onCleared()
43 | disposable?.dispose()
44 | }
45 | }
46 |
47 | @Open
48 | class AddExpenseViewModelModelFactory @Inject constructor(
49 | private val addExpensesInteractor: AddExpensesInteractor
50 | ) : ViewModelProvider.Factory {
51 |
52 | override fun create(modelClass: Class): T {
53 | if (modelClass.isAssignableFrom(AddExpenseViewModel::class.java))
54 | return AddExpenseViewModel(addExpensesInteractor) as T
55 | else throw IllegalArgumentException()
56 | }
57 | }
58 |
59 | sealed class AddExpenseResult : Serializable
60 | data class Error(val throwable: Throwable?) : AddExpenseResult()
61 | data class Success(val value: Expense) : AddExpenseResult()
--------------------------------------------------------------------------------
/app/src/main/res/layout/expenses_item_layout.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
19 |
20 |
31 |
32 |
42 |
43 |
55 |
--------------------------------------------------------------------------------
/app/src/test/java/otus/demo/totalcoverage/expenseslist/FiltersInteractorTest.kt:
--------------------------------------------------------------------------------
1 | package otus.demo.totalcoverage.expenseslist
2 |
3 | import kotlinx.coroutines.test.runTest
4 | import org.junit.Assert.assertEquals
5 | import org.junit.Before
6 | import org.junit.Test
7 | import org.mockito.kotlin.mock
8 | import org.mockito.kotlin.whenever
9 | import otus.demo.totalcoverage.baseexpenses.Category
10 | import otus.demo.totalcoverage.baseexpenses.ExpenseResponse
11 | import otus.demo.totalcoverage.baseexpenses.ExpensesService
12 | import otus.demo.totalcoverage.expensesfilter.Filter
13 | import otus.demo.totalcoverage.expensesfilter.Sort
14 |
15 | class FiltersInteractorTest {
16 |
17 | private val expensesService: ExpensesService = mock()
18 | private val filtersInteractor = FiltersInteractor(expensesService)
19 |
20 | private val firstResponse = ExpenseResponse(
21 | 1, "first", Category.BARS, null, 1000L, 1624345281000L
22 | )
23 | private val secondResponse = ExpenseResponse(
24 | 2, "second", Category.BARS, null, 3000L, 1624345261000L
25 | )
26 | private val thirdResponse = ExpenseResponse(
27 | 3, "third", Category.TRAVEL, null, 2000L, 1624345221000L
28 | )
29 |
30 | @Before
31 | fun before() {
32 | runTest {
33 | whenever(expensesService.getExpenses()).thenReturn(
34 | mutableListOf(
35 | firstResponse,
36 | secondResponse,
37 | thirdResponse
38 | )
39 | )
40 | }
41 | }
42 |
43 | @Test
44 | fun `should sort descending by amount`() {
45 | runTest {
46 | val filter = Filter(sort = Sort.DESC)
47 |
48 | val expected = listOf(
49 | secondResponse,
50 | thirdResponse,
51 | firstResponse
52 | )
53 |
54 | val actual = filtersInteractor.getFilteredExpenses(filter)
55 |
56 | assertEquals(expected, actual)
57 | }
58 | }
59 |
60 | @Test
61 | fun `should sort by asc amount and filter bars`() {
62 | runTest {
63 | val filter = Filter(categories = listOf(Category.BARS), sort = Sort.ASC)
64 |
65 | val expected = listOf(
66 | firstResponse, secondResponse
67 | )
68 |
69 | val actual = filtersInteractor.getFilteredExpenses(filter)
70 |
71 | assertEquals(expected, actual)
72 | }
73 | }
74 | }
--------------------------------------------------------------------------------
/app/src/main/res/layout/add_expenses_layout.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
18 |
19 |
28 |
29 |
39 |
40 |
48 |
49 |
56 |
57 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @if "%DEBUG%" == "" @echo off
2 | @rem ##########################################################################
3 | @rem
4 | @rem Gradle startup script for Windows
5 | @rem
6 | @rem ##########################################################################
7 |
8 | @rem Set local scope for the variables with windows NT shell
9 | if "%OS%"=="Windows_NT" setlocal
10 |
11 | set DIRNAME=%~dp0
12 | if "%DIRNAME%" == "" set DIRNAME=.
13 | set APP_BASE_NAME=%~n0
14 | set APP_HOME=%DIRNAME%
15 |
16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
17 | set DEFAULT_JVM_OPTS=
18 |
19 | @rem Find java.exe
20 | if defined JAVA_HOME goto findJavaFromJavaHome
21 |
22 | set JAVA_EXE=java.exe
23 | %JAVA_EXE% -version >NUL 2>&1
24 | if "%ERRORLEVEL%" == "0" goto init
25 |
26 | echo.
27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
28 | echo.
29 | echo Please set the JAVA_HOME variable in your environment to match the
30 | echo location of your Java installation.
31 |
32 | goto fail
33 |
34 | :findJavaFromJavaHome
35 | set JAVA_HOME=%JAVA_HOME:"=%
36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
37 |
38 | if exist "%JAVA_EXE%" goto init
39 |
40 | echo.
41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
42 | echo.
43 | echo Please set the JAVA_HOME variable in your environment to match the
44 | echo location of your Java installation.
45 |
46 | goto fail
47 |
48 | :init
49 | @rem Get command-line arguments, handling Windows variants
50 |
51 | if not "%OS%" == "Windows_NT" goto win9xME_args
52 |
53 | :win9xME_args
54 | @rem Slurp the command line arguments.
55 | set CMD_LINE_ARGS=
56 | set _SKIP=2
57 |
58 | :win9xME_args_slurp
59 | if "x%~1" == "x" goto execute
60 |
61 | set CMD_LINE_ARGS=%*
62 |
63 | :execute
64 | @rem Setup the command line
65 |
66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
67 |
68 | @rem Execute Gradle
69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
70 |
71 | :end
72 | @rem End local scope for the variables with windows NT shell
73 | if "%ERRORLEVEL%"=="0" goto mainEnd
74 |
75 | :fail
76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
77 | rem the _cmd.exe /c_ return code!
78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
79 | exit /b 1
80 |
81 | :mainEnd
82 | if "%OS%"=="Windows_NT" endlocal
83 |
84 | :omega
85 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/filters_layout.xml:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
14 |
15 |
22 |
23 |
30 |
31 |
38 |
39 |
46 |
47 |
54 |
55 |
62 |
--------------------------------------------------------------------------------
/app/src/test/java/otus/demo/totalcoverage/expenseslist/ExpensesViewModelTest.kt:
--------------------------------------------------------------------------------
1 | package otus.demo.totalcoverage.expenseslist
2 |
3 | import app.cash.turbine.test
4 | import kotlinx.coroutines.Dispatchers
5 | import kotlinx.coroutines.newSingleThreadContext
6 | import kotlinx.coroutines.test.TestCoroutineDispatcher
7 | import kotlinx.coroutines.test.resetMain
8 | import kotlinx.coroutines.test.runTest
9 | import kotlinx.coroutines.test.setMain
10 | import org.junit.After
11 | import org.junit.Assert
12 | import org.junit.Before
13 | import org.junit.Test
14 | import org.mockito.kotlin.mock
15 | import org.mockito.kotlin.whenever
16 | import otus.demo.totalcoverage.baseexpenses.Category
17 | import otus.demo.totalcoverage.baseexpenses.Expense
18 | import kotlin.time.ExperimentalTime
19 |
20 | class ExpensesViewModelTest {
21 |
22 | private val filtersInteractor: FiltersInteractor = mock()
23 | private val expensesRepository: ExpensesRepository = mock()
24 | private val expensesMapper: ExpensesMapper = mock()
25 | private val testDispatcher = TestCoroutineDispatcher()
26 |
27 | private val expensesViewModel =
28 | ExpensesViewModel(
29 | filtersInteractor,
30 | expensesRepository,
31 | expensesMapper,
32 | testDispatcher
33 | )
34 |
35 | @Before
36 | fun before() {
37 | Dispatchers.setMain(testDispatcher)
38 | }
39 |
40 | @ExperimentalTime
41 | @Test
42 | fun `should emit Success with non empty expenses list`() {
43 | runTest {
44 | //given:
45 | val expected = Expense(
46 | 2,
47 | "Some sport equipment",
48 | Category.SPORT,
49 | amount = 120,
50 | date = "20-06-2021"
51 | )
52 | whenever(expensesRepository.getExpenses()).thenReturn(
53 | listOf(expected)
54 | )
55 |
56 | expensesViewModel.getExpenses()
57 | expensesViewModel.stateFlow.test {
58 | Assert.assertEquals(Success(listOf(expected)), expectMostRecentItem())
59 | }
60 | }
61 | }
62 |
63 | @ExperimentalTime
64 | @Test
65 | fun `should emit instance of Empty when expenses are empty`() {
66 | runTest {
67 | whenever(expensesRepository.getExpenses()).thenReturn(
68 | emptyList()
69 | )
70 |
71 | expensesViewModel.getExpenses()
72 | expensesViewModel.stateFlow.test {
73 | Assert.assertEquals(Empty, expectMostRecentItem())
74 | }
75 | }
76 | }
77 |
78 | @ExperimentalTime
79 | @Test
80 | fun `should emit instance of Error when IOException was thrown`() {
81 | runTest {
82 | val expectedException = RuntimeException("Error")
83 | whenever(expensesRepository.getExpenses()).thenThrow(expectedException)
84 |
85 | expensesViewModel.getExpenses()
86 | expensesViewModel.stateFlow.test {
87 | //проверить почему не работает при смене тест диспатчера
88 | Assert.assertEquals(Error(expectedException), awaitItem())
89 | }
90 | }
91 | }
92 |
93 | @After
94 | fun after() {
95 | Dispatchers.resetMain()
96 | }
97 | }
--------------------------------------------------------------------------------
/app/src/main/java/otus/demo/totalcoverage/expenseslist/ExpensesViewModel.kt:
--------------------------------------------------------------------------------
1 | package otus.demo.totalcoverage.expenseslist
2 |
3 | import androidx.lifecycle.ViewModel
4 | import androidx.lifecycle.ViewModelProvider
5 | import androidx.lifecycle.viewModelScope
6 | import kotlinx.coroutines.CoroutineDispatcher
7 | import kotlinx.coroutines.flow.MutableStateFlow
8 | import kotlinx.coroutines.flow.StateFlow
9 | import kotlinx.coroutines.launch
10 | import kotlinx.coroutines.withContext
11 | import otus.demo.totalcoverage.Open
12 | import otus.demo.totalcoverage.baseexpenses.Expense
13 | import otus.demo.totalcoverage.expensesfilter.Filter
14 | import otus.demo.totalcoverage.utils.NeedsTesting
15 | import javax.inject.Inject
16 |
17 | @NeedsTesting
18 | @Open
19 | class ExpensesViewModel constructor(
20 | private val filtersInteractor: FiltersInteractor,
21 | private val expensesRepository: ExpensesRepository,
22 | private val expensesMapper: ExpensesMapper,
23 | @IO private val ioDispatcher: CoroutineDispatcher,
24 | ) : ViewModel() {
25 |
26 | private val _stateFlow: MutableStateFlow = MutableStateFlow(Empty)
27 | val stateFlow: StateFlow = _stateFlow
28 |
29 | fun getExpenses() {
30 | viewModelScope.launch {
31 | try {
32 | val expenses = withContext(ioDispatcher) {
33 | expensesRepository.getExpenses()
34 | }
35 | if (expenses.isNotEmpty()) {
36 | _stateFlow.emit(Success(expenses))
37 | } else {
38 | _stateFlow.emit(Empty)
39 | }
40 | } catch (ex: Exception) {
41 | when (ex) {
42 | is RuntimeException -> _stateFlow.emit(Error(ex))
43 | }
44 | }
45 | }
46 | }
47 |
48 | fun getFilteredExpenses(filter: Filter) {
49 | viewModelScope.launch {
50 | try {
51 | val expenses = withContext(ioDispatcher) {
52 | filtersInteractor.getFilteredExpenses(filter)
53 | .map { expensesMapper.map(it) }
54 | }
55 | _stateFlow.emit(Success(expenses))
56 | } catch (ex: Exception) {
57 | when (ex) {
58 | is RuntimeException -> _stateFlow.emit(Error(ex))
59 | }
60 | }
61 | }
62 | }
63 | }
64 |
65 | sealed class Result
66 | data class Error(val throwable: Throwable?) : Result()
67 | object Empty : Result()
68 | data class Success(val value: List) : Result()
69 |
70 | @Open
71 | class ExpensesViewModelFactory @Inject constructor(
72 | private val filtersInteractor: FiltersInteractor,
73 | private val expensesRepository: ExpensesRepositoryImpl,
74 | private val expensesMapper: ExpensesMapper,
75 | @IO private val ioCoroutineDispatcher: CoroutineDispatcher
76 | ) : ViewModelProvider.Factory {
77 |
78 | override fun create(modelClass: Class): T {
79 | if (modelClass.isAssignableFrom(ExpensesViewModel::class.java))
80 | return ExpensesViewModel(
81 | filtersInteractor,
82 | expensesRepository,
83 | expensesMapper,
84 | ioCoroutineDispatcher
85 | ) as T
86 | else throw IllegalArgumentException()
87 | }
88 | }
--------------------------------------------------------------------------------
/app/src/main/java/otus/demo/totalcoverage/addexpense/AddExpenseFragment.kt:
--------------------------------------------------------------------------------
1 | package otus.demo.totalcoverage.addexpense
2 |
3 | import android.os.Bundle
4 | import android.view.LayoutInflater
5 | import android.view.View
6 | import android.view.ViewGroup
7 | import android.widget.Button
8 | import android.widget.EditText
9 | import android.widget.Toast
10 | import androidx.core.os.bundleOf
11 | import androidx.fragment.app.Fragment
12 | import androidx.fragment.app.setFragmentResult
13 | import androidx.lifecycle.ViewModelProvider
14 | import androidx.navigation.fragment.findNavController
15 | import androidx.recyclerview.widget.LinearLayoutManager
16 | import androidx.recyclerview.widget.RecyclerView
17 | import otus.demo.totalcoverage.ExpensesApp
18 | import otus.demo.totalcoverage.R
19 | import otus.demo.totalcoverage.baseexpenses.Category
20 | import otus.demo.totalcoverage.expenseslist.EXPENSE_KEY
21 | import otus.demo.totalcoverage.expenseslist.KEY
22 | import javax.inject.Inject
23 |
24 | class AddExpenseFragment : Fragment() {
25 |
26 | @Inject
27 | lateinit var viewModelFactory: ViewModelProvider.Factory
28 |
29 | private lateinit var addExpenseViewModel: AddExpenseViewModel
30 |
31 | private lateinit var nameInput: EditText
32 | private lateinit var amountInput: EditText
33 | private lateinit var commentInput: EditText
34 | private lateinit var submitButton: Button
35 | private lateinit var recyclerView: RecyclerView
36 | private var category:Category = Category.FOOD
37 |
38 | override fun onCreate(savedInstanceState: Bundle?) {
39 | super.onCreate(savedInstanceState)
40 | AddExpensesComponent.getAddExpensesComponent((requireActivity().application as ExpensesApp).getAppComponent())
41 | .inject(this)
42 | }
43 |
44 | override fun onCreateView(
45 | inflater: LayoutInflater,
46 | container: ViewGroup?,
47 | savedInstanceState: Bundle?
48 | ): View? {
49 | return inflater.inflate(R.layout.add_expenses_layout, container, false)
50 | }
51 |
52 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
53 | super.onViewCreated(view, savedInstanceState)
54 | recyclerView = view.findViewById(R.id.category_recycler)
55 | nameInput = view.findViewById(R.id.title_edittext)
56 | amountInput = view.findViewById(R.id.amount_edittext)
57 | commentInput = view.findViewById(R.id.comment_edittext)
58 | submitButton = view.findViewById(R.id.submit_button)
59 |
60 | recyclerView.layoutManager = LinearLayoutManager(context, RecyclerView.HORIZONTAL, false)
61 | recyclerView.adapter = CategoriesAdapter(){
62 | category = it
63 | }
64 |
65 | submitButton.setOnClickListener {
66 | addExpenseViewModel.addExpense(
67 | nameInput.text.toString(),
68 | amountInput.text.toString(),
69 | category,
70 | commentInput.text.toString()
71 | )
72 | }
73 | addExpenseViewModel =
74 | ViewModelProvider(this, viewModelFactory)[AddExpenseViewModel::class.java]
75 |
76 | }
77 |
78 | override fun onStart() {
79 | super.onStart()
80 | addExpenseViewModel.liveData.observe(viewLifecycleOwner) { result ->
81 | when (result) {
82 | is Success -> {
83 | setFragmentResult(
84 | KEY,
85 | bundleOf(EXPENSE_KEY to result.value)
86 | )
87 | findNavController().navigateUp()
88 | }
89 | is Error -> {
90 | Toast.makeText(activity, result.throwable?.message, Toast.LENGTH_LONG).show()
91 | }
92 | }
93 | }
94 | }
95 | }
--------------------------------------------------------------------------------
/app/build.gradle:
--------------------------------------------------------------------------------
1 | plugins {
2 | id 'com.android.application'
3 | id 'kotlin-android'
4 | id 'kotlin-kapt'
5 | id "org.jetbrains.kotlin.plugin.allopen" version "1.9.21"
6 | }
7 |
8 | android {
9 | compileSdkVersion 34
10 | namespace "otus.demo.totalcoverage"
11 |
12 | defaultConfig {
13 | applicationId "otus.demo.totalcoverage"
14 | minSdkVersion 28
15 | targetSdkVersion 34
16 | versionCode 1
17 | versionName "1.0"
18 |
19 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
20 | }
21 |
22 | buildTypes {
23 | release {
24 | minifyEnabled false
25 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
26 | }
27 | }
28 | compileOptions {
29 | sourceCompatibility JavaVersion.VERSION_1_8
30 | targetCompatibility JavaVersion.VERSION_1_8
31 | }
32 | kotlinOptions {
33 | jvmTarget = '1.8'
34 | }
35 | testOptions {
36 | unitTests {
37 | includeAndroidResources = true
38 | }
39 | }
40 | }
41 |
42 | dependencies {
43 | implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
44 |
45 | implementation 'androidx.core:core-ktx:1.7.0'
46 | implementation 'androidx.appcompat:appcompat:1.4.1'
47 | implementation 'com.google.android.material:material:1.6.0'
48 | implementation 'androidx.constraintlayout:constraintlayout:2.1.3'
49 | implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0'
50 |
51 | implementation "io.reactivex.rxjava2:rxjava:2.2.21"
52 | implementation 'io.reactivex.rxjava2:rxandroid:2.1.1'
53 |
54 | def retrofit_version = "2.9.0"
55 | implementation "com.squareup.retrofit2:retrofit:$retrofit_version"
56 | implementation "com.squareup.retrofit2:converter-gson:$retrofit_version"
57 | implementation "com.squareup.retrofit2:adapter-rxjava2:$retrofit_version"
58 |
59 | def nav_version = "2.7.7"
60 | implementation "androidx.navigation:navigation-fragment-ktx:$nav_version"
61 | implementation("androidx.navigation:navigation-ui-ktx:$nav_version")
62 |
63 |
64 | debugImplementation "androidx.fragment:fragment-testing:1.4.1"
65 | testImplementation 'junit:junit:4.13.2'
66 | testImplementation 'androidx.test.ext:junit:1.1.3'
67 | testImplementation 'androidx.test.espresso:espresso-core:3.4.0'
68 | testImplementation 'androidx.test:core:1.4.0'
69 | testImplementation "org.mockito.kotlin:mockito-kotlin:4.0.0"
70 | testImplementation "androidx.navigation:navigation-testing:$nav_version"
71 | testImplementation 'org.robolectric:robolectric:4.8'
72 | testImplementation "androidx.arch.core:core-testing:2.1.0"
73 | testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.1'
74 | testImplementation 'app.cash.turbine:turbine:0.8.0'
75 |
76 | androidTestImplementation 'junit:junit:4.13.2'
77 | androidTestImplementation 'androidx.test.ext:junit:1.1.3'
78 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
79 | androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.4.0'
80 | androidTestImplementation 'androidx.test:core:1.4.0'
81 | androidTestImplementation "org.mockito.kotlin:mockito-kotlin:4.0.0"
82 | androidTestImplementation "androidx.navigation:navigation-testing:$nav_version"
83 | androidTestImplementation "androidx.arch.core:core-testing:2.1.0"
84 | androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.1'
85 | androidTestImplementation 'app.cash.turbine:turbine:0.8.0'
86 | implementation 'androidx.test.espresso:espresso-idling-resource:3.4.0'
87 | implementation 'com.google.dagger:dagger:2.51.1'
88 | kaptAndroidTest 'com.google.dagger:dagger-compiler:2.51.1'
89 | kapt 'com.google.dagger:dagger-compiler:2.51.1'
90 | }
91 |
92 | allOpen {
93 | annotation("otus.demo.totalcoverage.Open")
94 | }
--------------------------------------------------------------------------------
/app/src/main/java/otus/demo/totalcoverage/expensesfilter/FiltersFragment.kt:
--------------------------------------------------------------------------------
1 | package otus.demo.totalcoverage.expensesfilter
2 |
3 | import android.os.Bundle
4 | import android.view.LayoutInflater
5 | import android.view.MenuItem
6 | import android.view.View
7 | import android.view.ViewGroup
8 | import android.widget.Button
9 | import androidx.appcompat.widget.PopupMenu
10 | import androidx.core.os.bundleOf
11 | import androidx.fragment.app.setFragmentResult
12 | import androidx.navigation.fragment.findNavController
13 | import com.google.android.material.bottomsheet.BottomSheetDialogFragment
14 | import otus.demo.totalcoverage.R
15 | import otus.demo.totalcoverage.baseexpenses.Category
16 | import otus.demo.totalcoverage.expenseslist.FILTERED_KEY
17 |
18 | const val FILTERS_KEY = "FILTERS_KEY"
19 |
20 | class FiltersFragment : BottomSheetDialogFragment() {
21 |
22 | private lateinit var submitButton: Button
23 | private lateinit var clearButton: Button
24 | private lateinit var categoryButton: Button
25 | private lateinit var ascButton: Button
26 | private lateinit var descButton: Button
27 | private lateinit var dateDescButton: Button
28 | private lateinit var dateAscButton: Button
29 |
30 | private var sort: Sort = Sort.DESC_DATE
31 | private var categories = Category.values().toList()
32 |
33 | override fun onCreateView(
34 | inflater: LayoutInflater,
35 | container: ViewGroup?,
36 | savedInstanceState: Bundle?
37 | ): View? {
38 | return inflater.inflate(R.layout.filters_layout, container, false)
39 | }
40 |
41 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
42 | super.onViewCreated(view, savedInstanceState)
43 | bindViews(view)
44 |
45 | ascButton.setOnClickListener {
46 | sort = Sort.ASC
47 | }
48 | descButton.setOnClickListener {
49 | sort = Sort.DESC
50 | }
51 | dateDescButton.setOnClickListener {
52 | sort = Sort.DESC_DATE
53 | }
54 | dateAscButton.setOnClickListener {
55 | sort = Sort.ASC_DATE
56 | }
57 | submitButton.setOnClickListener {
58 | setFragmentResult(
59 | FILTERED_KEY,
60 | bundleOf(FILTERS_KEY to Filter(sort = sort, categories = categories))
61 | )
62 | findNavController().navigateUp()
63 | }
64 | clearButton.setOnClickListener {
65 | setFragmentResult(
66 | FILTERED_KEY,
67 | bundleOf(FILTERS_KEY to Filter())
68 | )
69 | findNavController().navigateUp()
70 | }
71 | categoryButton.setOnClickListener {
72 | showMenu()
73 | }
74 | }
75 |
76 | private fun bindViews(view: View) {
77 | submitButton = view.findViewById(R.id.submit_filters_button)
78 | categoryButton = view.findViewById(R.id.category_button)
79 | ascButton = view.findViewById(R.id.asc_button)
80 | descButton = view.findViewById(R.id.desc_button)
81 | dateAscButton = view.findViewById(R.id.asc_date_button)
82 | dateDescButton = view.findViewById(R.id.desc_date_button)
83 | clearButton = view.findViewById(R.id.clear_filters_button)
84 | }
85 |
86 | private fun showMenu() {
87 | val popup = PopupMenu(requireContext(), categoryButton)
88 | popup.menuInflater.inflate(R.menu.dropdownmenu, popup.menu)
89 |
90 | popup.setOnMenuItemClickListener { menuItem: MenuItem ->
91 | when (menuItem.itemId) {
92 | R.id.foods -> categories = listOf(Category.FOOD)
93 | R.id.transport -> categories = listOf(Category.TRANSPORT)
94 | R.id.travel -> categories = listOf(Category.TRAVEL)
95 | R.id.health -> categories = listOf(Category.HEALTH)
96 | R.id.bars -> categories = listOf(Category.BARS)
97 | }
98 | true
99 | }
100 | popup.show()
101 | }
102 | }
--------------------------------------------------------------------------------
/.idea/codeStyles/Project.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 | xmlns:android
18 |
19 | ^$
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 | xmlns:.*
29 |
30 | ^$
31 |
32 |
33 | BY_NAME
34 |
35 |
36 |
37 |
38 |
39 |
40 | .*:id
41 |
42 | http://schemas.android.com/apk/res/android
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 | .*:name
52 |
53 | http://schemas.android.com/apk/res/android
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 | name
63 |
64 | ^$
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 | style
74 |
75 | ^$
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 | .*
85 |
86 | ^$
87 |
88 |
89 | BY_NAME
90 |
91 |
92 |
93 |
94 |
95 |
96 | .*
97 |
98 | http://schemas.android.com/apk/res/android
99 |
100 |
101 | ANDROID_ATTRIBUTE_ORDER
102 |
103 |
104 |
105 |
106 |
107 |
108 | .*
109 |
110 | .*
111 |
112 |
113 | BY_NAME
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 |
--------------------------------------------------------------------------------
/app/src/main/java/otus/demo/totalcoverage/expenseslist/ExpensesFragment.kt:
--------------------------------------------------------------------------------
1 | package otus.demo.totalcoverage.expenseslist
2 |
3 | import android.os.Bundle
4 | import android.view.*
5 | import android.widget.TextView
6 | import android.widget.Toast
7 | import androidx.annotation.VisibleForTesting
8 | import androidx.appcompat.widget.Toolbar
9 | import androidx.fragment.app.Fragment
10 | import androidx.fragment.app.setFragmentResultListener
11 | import androidx.lifecycle.ViewModelProvider
12 | import androidx.lifecycle.lifecycleScope
13 | import androidx.navigation.fragment.findNavController
14 | import androidx.recyclerview.widget.RecyclerView
15 | import com.google.android.material.floatingactionbutton.FloatingActionButton
16 | import kotlinx.coroutines.flow.collect
17 | import kotlinx.coroutines.launch
18 | import org.jetbrains.annotations.TestOnly
19 | import otus.demo.totalcoverage.ContainerActivity
20 | import otus.demo.totalcoverage.ExpensesApp
21 | import otus.demo.totalcoverage.R
22 | import otus.demo.totalcoverage.addexpense.AddExpenseViewModel
23 | import otus.demo.totalcoverage.baseexpenses.Expense
24 | import otus.demo.totalcoverage.expensesfilter.FILTERS_KEY
25 | import otus.demo.totalcoverage.expensesfilter.Filter
26 | import javax.inject.Inject
27 |
28 | const val KEY = "EXPENSES"
29 | const val FILTERED_KEY = "FILTERED_EXPENSES"
30 | const val EXPENSE_KEY = "EXPENSE_KEY"
31 |
32 | class ExpensesFragment : Fragment() {
33 |
34 | @Inject
35 | lateinit var viewModelFactory: ViewModelProvider.Factory
36 |
37 | @Inject
38 | lateinit var adapter: ExpensesAdapter
39 |
40 | private lateinit var expensesViewModel: ExpensesViewModel
41 |
42 | private lateinit var expensesRecycler: RecyclerView
43 | private lateinit var emptyText: TextView
44 | private lateinit var addExpenseButton: FloatingActionButton
45 |
46 | override fun onCreate(savedInstanceState: Bundle?) {
47 | super.onCreate(savedInstanceState)
48 | ExpensesComponent.getExpensesComponent((requireActivity().application as ExpensesApp).getAppComponent())
49 | .inject(this)
50 | setHasOptionsMenu(true)
51 | }
52 |
53 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
54 | super.onViewCreated(view, savedInstanceState)
55 | emptyText = view.findViewById(R.id.empty_text)
56 | expensesRecycler = view.findViewById(R.id.expenses_recycler)
57 | expensesRecycler.adapter = adapter
58 | addExpenseButton = view.findViewById(R.id.add_expense_fab)
59 | addExpenseButton.setOnClickListener {
60 | findNavController().navigate(R.id.action_expensesListFragment_to_addExpenseFragment)
61 | }
62 |
63 | setFragmentResultListener(KEY) { _, bundle ->
64 | val expense = bundle.getSerializable(EXPENSE_KEY) as Expense
65 | adapter.addItem(expense)
66 | }
67 |
68 | setFragmentResultListener(FILTERED_KEY) { _, bundle ->
69 | val filter = bundle.getSerializable(FILTERS_KEY) as Filter
70 | expensesViewModel.getFilteredExpenses(filter)
71 | }
72 |
73 | expensesViewModel =
74 | ViewModelProvider(this, viewModelFactory).get(ExpensesViewModel::class.java)
75 | lifecycleScope.launchWhenStarted {
76 | expensesViewModel.stateFlow.collect { result ->
77 | when (result) {
78 | is Success -> {
79 | emptyText.visibility = View.GONE
80 | adapter.addItems(result.value)
81 | }
82 | is Error -> {
83 | Toast.makeText(activity, result.throwable?.message, Toast.LENGTH_LONG)
84 | .show()
85 | }
86 | Empty -> {
87 | emptyText.visibility = View.VISIBLE
88 | }
89 | }
90 | }
91 | }
92 | expensesViewModel.getExpenses()
93 | }
94 |
95 | override fun onCreateView(
96 | inflater: LayoutInflater,
97 | container: ViewGroup?,
98 | savedInstanceState: Bundle?
99 | ): View? {
100 | return inflater.inflate(R.layout.expenses_list_layout, container, false)
101 | }
102 |
103 | override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
104 | inflater.inflate(R.menu.expenses_menu, menu)
105 | menu.findItem(R.id.filters).setOnMenuItemClickListener {
106 | findNavController().navigate(R.id.action_expensesListFragment_to_filtersFragment)
107 | true
108 | }
109 | }
110 | }
--------------------------------------------------------------------------------
/app/src/test/java/otus/demo/totalcoverage/addexpense/AddExpenseFragmentTest.kt:
--------------------------------------------------------------------------------
1 | package otus.demo.totalcoverage.addexpense
2 |
3 | import androidx.arch.core.executor.testing.InstantTaskExecutorRule
4 | import androidx.fragment.app.testing.launchFragmentInContainer
5 | import androidx.lifecycle.Lifecycle
6 | import androidx.lifecycle.MutableLiveData
7 | import androidx.navigation.Navigation
8 | import androidx.navigation.testing.TestNavHostController
9 | import androidx.test.core.app.ApplicationProvider
10 | import androidx.test.espresso.Espresso
11 | import androidx.test.espresso.action.ViewActions
12 | import androidx.test.espresso.matcher.ViewMatchers
13 | import androidx.test.ext.junit.runners.AndroidJUnit4
14 | import org.junit.Assert.assertEquals
15 | import org.junit.Rule
16 | import org.junit.Test
17 | import org.junit.runner.RunWith
18 | import org.mockito.kotlin.*
19 | import org.robolectric.annotation.Config
20 | import org.robolectric.shadows.ShadowToast
21 | import otus.demo.totalcoverage.R
22 | import otus.demo.totalcoverage.baseexpenses.Category
23 | import otus.demo.totalcoverage.baseexpenses.Expense
24 | import otus.demo.totalcoverage.expenseslist.ExpensesFragment
25 |
26 | @RunWith(AndroidJUnit4::class)
27 | @Config(instrumentedPackages = ["androidx.loader.content"])
28 | class AddExpenseFragmentTest {
29 |
30 | @get:Rule
31 | val instantTaskExecutorRule = InstantTaskExecutorRule()
32 |
33 | private val viewModel = mock()
34 |
35 | @Test
36 | fun `should call addExpense function when submit_button was clicked`() {
37 | val factory: AddExpenseViewModelModelFactory = mock()
38 | whenever(factory.create(any())).thenReturn(viewModel)
39 | val liveData = MutableLiveData()
40 | whenever(viewModel.liveData).thenReturn(liveData)
41 | val fragmentScenario =
42 | launchFragmentInContainer(initialState = Lifecycle.State.CREATED)
43 |
44 | fragmentScenario.onFragment { fragment ->
45 | fragment.viewModelFactory = factory
46 | fragmentScenario.moveToState(Lifecycle.State.STARTED)
47 | }
48 |
49 | Espresso.onView(ViewMatchers.withId(R.id.amount_edittext))
50 | .perform(ViewActions.typeText("100"))
51 | Espresso.onView(ViewMatchers.withId(R.id.title_edittext))
52 | .perform(ViewActions.typeText("Some title"))
53 | Espresso.onView(ViewMatchers.withId(R.id.comment_edittext))
54 | .perform(ViewActions.typeText("Some comment"))
55 |
56 | Espresso.onView(ViewMatchers.withId(R.id.submit_button))
57 | .perform(ViewActions.click())
58 |
59 | verify(viewModel).addExpense("Some title", "100", Category.BARS, "Some comment")
60 | }
61 |
62 | @Test
63 | fun `should navigate up with payload when Success event was emited`() {
64 | //given:
65 | val expected = Expense(0L, "", Category.BARS, "", 1L, "")
66 |
67 | val liveData = MutableLiveData()
68 | whenever(viewModel.liveData).thenReturn(liveData)
69 |
70 | val factoryMock = mock()
71 | whenever(factoryMock.create(any())).thenReturn(viewModel)
72 |
73 | val fragmentScenario =
74 | launchFragmentInContainer(initialState = Lifecycle.State.CREATED)
75 |
76 | val navController = TestNavHostController(
77 | ApplicationProvider.getApplicationContext()
78 | )
79 | fragmentScenario.onFragment { fragment ->
80 | fragment.viewModelFactory = factoryMock
81 | navController.setGraph(R.navigation.nav_graph)
82 | fragmentScenario.moveToState(Lifecycle.State.STARTED)
83 | Navigation.setViewNavController(fragment.requireView(), navController)
84 | }
85 |
86 | //when:
87 | liveData.value = Success(expected)
88 |
89 | //then:
90 | fragmentScenario.onFragment { fragment ->
91 | fragment.parentFragmentManager.setFragmentResultListener(
92 | "EXPENSES", fragment
93 | ) { _, b ->
94 | assertEquals(expected, b.get("EXPENSE_KEY"))
95 | }
96 | }
97 |
98 | //and:
99 | assertEquals(
100 | navController.currentDestination?.id,
101 | R.id.expensesListFragment
102 | )
103 | }
104 |
105 | @Test
106 | fun `should show Toast with message when Error event was emited`() {
107 | //given:
108 | val liveData = MutableLiveData()
109 | whenever(viewModel.liveData).thenReturn(liveData)
110 |
111 | val factoryMock = mock()
112 | whenever(factoryMock.create(any())).thenReturn(viewModel)
113 |
114 | val fragmentScenario =
115 | launchFragmentInContainer(initialState = Lifecycle.State.CREATED)
116 | val navController = TestNavHostController(
117 | ApplicationProvider.getApplicationContext()
118 | )
119 | fragmentScenario.onFragment { fragment ->
120 | fragment.viewModelFactory = factoryMock
121 | navController.setGraph(R.navigation.nav_graph)
122 | fragmentScenario.moveToState(Lifecycle.State.STARTED)
123 | Navigation.setViewNavController(fragment.requireView(), navController)
124 |
125 | }
126 |
127 | //when:
128 | liveData.value = Error(RuntimeException("Some error"))
129 |
130 | //then 'show toast with expected message':
131 | assertEquals(
132 | "Some error", ShadowToast.getTextOfLatestToast()
133 | )
134 | }
135 | }
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | ##############################################################################
4 | ##
5 | ## Gradle start up script for UN*X
6 | ##
7 | ##############################################################################
8 |
9 | # Attempt to set APP_HOME
10 | # Resolve links: $0 may be a link
11 | PRG="$0"
12 | # Need this for relative symlinks.
13 | while [ -h "$PRG" ] ; do
14 | ls=`ls -ld "$PRG"`
15 | link=`expr "$ls" : '.*-> \(.*\)$'`
16 | if expr "$link" : '/.*' > /dev/null; then
17 | PRG="$link"
18 | else
19 | PRG=`dirname "$PRG"`"/$link"
20 | fi
21 | done
22 | SAVED="`pwd`"
23 | cd "`dirname \"$PRG\"`/" >/dev/null
24 | APP_HOME="`pwd -P`"
25 | cd "$SAVED" >/dev/null
26 |
27 | APP_NAME="Gradle"
28 | APP_BASE_NAME=`basename "$0"`
29 |
30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
31 | DEFAULT_JVM_OPTS=""
32 |
33 | # Use the maximum available, or set MAX_FD != -1 to use that value.
34 | MAX_FD="maximum"
35 |
36 | warn () {
37 | echo "$*"
38 | }
39 |
40 | die () {
41 | echo
42 | echo "$*"
43 | echo
44 | exit 1
45 | }
46 |
47 | # OS specific support (must be 'true' or 'false').
48 | cygwin=false
49 | msys=false
50 | darwin=false
51 | nonstop=false
52 | case "`uname`" in
53 | CYGWIN* )
54 | cygwin=true
55 | ;;
56 | Darwin* )
57 | darwin=true
58 | ;;
59 | MINGW* )
60 | msys=true
61 | ;;
62 | NONSTOP* )
63 | nonstop=true
64 | ;;
65 | esac
66 |
67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
68 |
69 | # Determine the Java command to use to start the JVM.
70 | if [ -n "$JAVA_HOME" ] ; then
71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
72 | # IBM's JDK on AIX uses strange locations for the executables
73 | JAVACMD="$JAVA_HOME/jre/sh/java"
74 | else
75 | JAVACMD="$JAVA_HOME/bin/java"
76 | fi
77 | if [ ! -x "$JAVACMD" ] ; then
78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
79 |
80 | Please set the JAVA_HOME variable in your environment to match the
81 | location of your Java installation."
82 | fi
83 | else
84 | JAVACMD="java"
85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
86 |
87 | Please set the JAVA_HOME variable in your environment to match the
88 | location of your Java installation."
89 | fi
90 |
91 | # Increase the maximum file descriptors if we can.
92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
93 | MAX_FD_LIMIT=`ulimit -H -n`
94 | if [ $? -eq 0 ] ; then
95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
96 | MAX_FD="$MAX_FD_LIMIT"
97 | fi
98 | ulimit -n $MAX_FD
99 | if [ $? -ne 0 ] ; then
100 | warn "Could not set maximum file descriptor limit: $MAX_FD"
101 | fi
102 | else
103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
104 | fi
105 | fi
106 |
107 | # For Darwin, add options to specify how the application appears in the dock
108 | if $darwin; then
109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
110 | fi
111 |
112 | # For Cygwin, switch paths to Windows format before running java
113 | if $cygwin ; then
114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"`
115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
116 | JAVACMD=`cygpath --unix "$JAVACMD"`
117 |
118 | # We build the pattern for arguments to be converted via cygpath
119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
120 | SEP=""
121 | for dir in $ROOTDIRSRAW ; do
122 | ROOTDIRS="$ROOTDIRS$SEP$dir"
123 | SEP="|"
124 | done
125 | OURCYGPATTERN="(^($ROOTDIRS))"
126 | # Add a user-defined pattern to the cygpath arguments
127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then
128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
129 | fi
130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
131 | i=0
132 | for arg in "$@" ; do
133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
135 |
136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
138 | else
139 | eval `echo args$i`="\"$arg\""
140 | fi
141 | i=$((i+1))
142 | done
143 | case $i in
144 | (0) set -- ;;
145 | (1) set -- "$args0" ;;
146 | (2) set -- "$args0" "$args1" ;;
147 | (3) set -- "$args0" "$args1" "$args2" ;;
148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;;
149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
154 | esac
155 | fi
156 |
157 | # Escape application args
158 | save () {
159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
160 | echo " "
161 | }
162 | APP_ARGS=$(save "$@")
163 |
164 | # Collect all arguments for the java command, following the shell quoting and substitution rules
165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
166 |
167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
169 | cd "$(dirname "$0")"
170 | fi
171 |
172 | exec "$JAVACMD" "$@"
173 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_launcher_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
11 |
16 |
21 |
26 |
31 |
36 |
41 |
46 |
51 |
56 |
61 |
66 |
71 |
76 |
81 |
86 |
91 |
96 |
101 |
106 |
111 |
116 |
121 |
126 |
131 |
136 |
141 |
146 |
151 |
156 |
161 |
166 |
171 |
172 |
--------------------------------------------------------------------------------
/app/src/test/java/otus/demo/totalcoverage/expenseslist/ExpensesFragmentTest.kt:
--------------------------------------------------------------------------------
1 | package otus.demo.totalcoverage.expenseslist
2 |
3 | import androidx.core.os.bundleOf
4 | import androidx.fragment.app.testing.launchFragmentInContainer
5 | import androidx.lifecycle.Lifecycle
6 | import androidx.navigation.Navigation
7 | import androidx.navigation.testing.TestNavHostController
8 | import androidx.test.core.app.ApplicationProvider
9 | import androidx.test.espresso.Espresso
10 | import androidx.test.espresso.ViewAction
11 | import androidx.test.espresso.action.ViewActions
12 | import androidx.test.espresso.assertion.ViewAssertions
13 | import androidx.test.espresso.assertion.ViewAssertions.matches
14 | import androidx.test.espresso.matcher.ViewMatchers
15 | import androidx.test.espresso.matcher.ViewMatchers.withId
16 | import androidx.test.ext.junit.runners.AndroidJUnit4
17 | import kotlinx.coroutines.Dispatchers
18 | import kotlinx.coroutines.flow.MutableStateFlow
19 | import kotlinx.coroutines.runBlocking
20 | import kotlinx.coroutines.test.TestCoroutineDispatcher
21 | import kotlinx.coroutines.test.resetMain
22 | import kotlinx.coroutines.test.runTest
23 | import kotlinx.coroutines.test.setMain
24 | import org.hamcrest.Matchers
25 | import org.junit.After
26 | import org.junit.Assert.*
27 | import org.junit.Before
28 | import org.junit.Test
29 | import org.junit.runner.RunWith
30 | import org.mockito.ArgumentMatchers.matches
31 | import org.mockito.kotlin.any
32 | import org.mockito.kotlin.mock
33 | import org.mockito.kotlin.verify
34 | import org.mockito.kotlin.whenever
35 | import org.robolectric.annotation.Config
36 | import org.robolectric.shadows.ShadowToast
37 | import otus.demo.totalcoverage.R
38 | import otus.demo.totalcoverage.baseexpenses.Category
39 | import otus.demo.totalcoverage.expensesfilter.Filter
40 | import otus.demo.totalcoverage.testutils.ExpensesFactory
41 | import java.lang.RuntimeException
42 | import java.util.regex.Pattern.matches
43 |
44 | @RunWith(AndroidJUnit4::class)
45 | @Config(instrumentedPackages = ["androidx.loader.content"])
46 | class ExpensesFragmentTest {
47 |
48 | private val expensesViewModel: ExpensesViewModel = mock()
49 |
50 | @Test
51 | fun `should show Toast with message when Error emited`() {
52 | runTest {
53 | //given:
54 | val flow = MutableStateFlow(Empty)
55 | whenever(expensesViewModel.stateFlow).thenReturn(flow)
56 |
57 | val factoryMock = mock()
58 | whenever(factoryMock.create(any())).thenReturn(expensesViewModel)
59 |
60 | val fragmentScenario =
61 | launchFragmentInContainer(initialState = Lifecycle.State.CREATED)
62 | fragmentScenario.onFragment { fragment ->
63 | fragment.viewModelFactory = factoryMock
64 | fragmentScenario.moveToState(Lifecycle.State.STARTED)
65 | }
66 |
67 | //when:
68 | flow.emit(Error(RuntimeException("Some Error")))
69 |
70 | //then:
71 | assertEquals("Some Error", ShadowToast.getTextOfLatestToast())
72 | }
73 | }
74 |
75 | @Test
76 | fun `should notify adapter when Success emited`() {
77 | runTest {
78 | //given:
79 | val adapterMock = mock()
80 | val expected = ExpensesFactory.getExpenses()
81 | val flow = MutableStateFlow(Empty)
82 | whenever(expensesViewModel.stateFlow).thenReturn(flow)
83 |
84 | val factoryMock = mock()
85 | whenever(factoryMock.create(any())).thenReturn(expensesViewModel)
86 |
87 | val fragmentScenario =
88 | launchFragmentInContainer(initialState = Lifecycle.State.CREATED)
89 | fragmentScenario.onFragment { fragment ->
90 | fragment.adapter = adapterMock
91 | fragment.viewModelFactory = factoryMock
92 | fragmentScenario.moveToState(Lifecycle.State.STARTED)
93 | }
94 |
95 | //when:
96 | flow.emit(Success(expected))
97 |
98 | //then:
99 | verify(adapterMock).addItems(expected)
100 | Espresso.onView(withId(R.id.empty_text)).check(matches(Matchers.not(ViewMatchers.isDisplayed())))
101 | }
102 | }
103 |
104 | @Test
105 | fun `should show empty text when Empty emited`() {
106 | runTest {
107 | //given:
108 | val adapterMock = mock()
109 |
110 | val flow = MutableStateFlow(Empty)
111 | whenever(expensesViewModel.stateFlow).thenReturn(flow)
112 |
113 | val factoryMock = mock()
114 | whenever(factoryMock.create(any())).thenReturn(expensesViewModel)
115 |
116 | val fragmentScenario =
117 | launchFragmentInContainer(initialState = Lifecycle.State.CREATED)
118 | fragmentScenario.onFragment { fragment ->
119 | fragment.adapter = adapterMock
120 | fragment.viewModelFactory = factoryMock
121 | fragmentScenario.moveToState(Lifecycle.State.STARTED)
122 | }
123 |
124 | //when:
125 | flow.emit(Empty)
126 |
127 | //then:
128 | Espresso.onView(ViewMatchers.withId(R.id.empty_text))
129 | .check(matches(ViewMatchers.isDisplayed()))
130 | }
131 | }
132 |
133 | @Test
134 | fun `should navigate to add expenses fragment when add button clicked`() {
135 | val fragmentScenario =
136 | launchFragmentInContainer()
137 | val navController = TestNavHostController(
138 | ApplicationProvider.getApplicationContext()
139 | )
140 |
141 | fragmentScenario.onFragment { fragment ->
142 | navController.setGraph(R.navigation.nav_graph)
143 | Navigation.setViewNavController(fragment.requireView(), navController)
144 | }
145 |
146 | Espresso.onView(ViewMatchers.withId(R.id.add_expense_fab)).perform(
147 | ViewActions.click()
148 | )
149 |
150 | //and:
151 | assertEquals(
152 | navController.currentDestination?.id,
153 | R.id.addExpenseFragment
154 | )
155 | }
156 |
157 | @Test
158 | fun `should navigate to filters fragment when filters menu button clicked`() {
159 | //given:
160 | val fragmentScenario =
161 | launchFragmentInContainer()
162 | val navController = TestNavHostController(
163 | ApplicationProvider.getApplicationContext()
164 | )
165 |
166 | fragmentScenario.onFragment { fragment ->
167 | navController.setGraph(R.navigation.nav_graph)
168 | Navigation.setViewNavController(fragment.requireView(), navController)
169 | }
170 |
171 | //when:
172 | Espresso.openContextualActionModeOverflowMenu()
173 | Espresso.onView(ViewMatchers.withText("filters")).perform(
174 | ViewActions.click()
175 | )
176 |
177 | //and:
178 | assertEquals(
179 | navController.currentDestination?.id,
180 | R.id.filtersFragment
181 | )
182 | }
183 |
184 | @Test
185 | fun `should add call addItem when result with EXPENSES key received`() {
186 | //given:
187 | val adapterMock = mock()
188 |
189 | val expected = ExpensesFactory.getExpense()
190 |
191 | val fragmentScenario =
192 | launchFragmentInContainer()
193 |
194 | fragmentScenario.onFragment { fragment ->
195 | fragment.adapter = adapterMock
196 | }
197 |
198 | //when:
199 | fragmentScenario.onFragment { fragment ->
200 | fragment.parentFragmentManager.setFragmentResult(
201 | "EXPENSES", bundleOf(
202 | "EXPENSE_KEY" to expected
203 | )
204 | )
205 | }
206 |
207 | //and:
208 | verify(adapterMock).addItem(expected)
209 | }
210 |
211 | @Test
212 | fun `should add call addItem when result with FILTERS key received`() {
213 | //given:
214 | val adapterMock = mock()
215 | val expected = Filter(listOf(Category.BARS, Category.FOOD))
216 |
217 | val factoryMock = mock()
218 | whenever(factoryMock.create(any())).thenReturn(expensesViewModel)
219 |
220 | val fragmentScenario =
221 | launchFragmentInContainer(initialState = Lifecycle.State.CREATED)
222 | fragmentScenario.onFragment { fragment ->
223 | fragment.adapter = adapterMock
224 | fragment.viewModelFactory = factoryMock
225 | fragmentScenario.moveToState(Lifecycle.State.STARTED)
226 | }
227 |
228 | //when:
229 | fragmentScenario.onFragment { fragment ->
230 | fragment.parentFragmentManager.setFragmentResult(
231 | "FILTERED_EXPENSES", bundleOf(
232 | "FILTERS_KEY" to expected
233 | )
234 | )
235 | }
236 |
237 | //then:
238 | verify(expensesViewModel).getFilteredExpenses(expected)
239 | }
240 | }
--------------------------------------------------------------------------------