├── 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 | 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 | 4 | 5 | 12 | -------------------------------------------------------------------------------- /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 | 3 | 6 | 9 | 12 | 15 | 18 | -------------------------------------------------------------------------------- /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 | 14 | 15 | 16 | 17 | 18 | 19 | 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 | 9 | 10 | 14 | 15 | 19 | 20 | 24 | 25 | 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 |