├── app ├── .gitignore ├── src │ ├── debug │ │ ├── assets │ │ │ ├── empty_list.json │ │ │ ├── server_error.json │ │ │ ├── categories.json │ │ │ └── blog_posts.json │ │ └── java │ │ │ └── com │ │ │ └── codingwithmitch │ │ │ └── espressodaggerexamples │ │ │ └── util │ │ │ └── EspressoIdlingResource.kt │ ├── main │ │ ├── res │ │ │ ├── drawable │ │ │ │ ├── default_image.png │ │ │ │ └── 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 │ │ │ ├── values │ │ │ │ ├── dimen.xml │ │ │ │ ├── strings.xml │ │ │ │ ├── styles.xml │ │ │ │ └── colors.xml │ │ │ ├── mipmap-xxxhdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ └── ic_launcher_round.png │ │ │ ├── anim │ │ │ │ ├── fade_out.xml │ │ │ │ ├── fade_in.xml │ │ │ │ ├── slide_in_left.xml │ │ │ │ ├── slide_in_right.xml │ │ │ │ ├── slide_out_left.xml │ │ │ │ └── slide_out_right.xml │ │ │ ├── mipmap-anydpi-v26 │ │ │ │ ├── ic_launcher.xml │ │ │ │ └── ic_launcher_round.xml │ │ │ ├── layout │ │ │ │ ├── fragment_final.xml │ │ │ │ ├── fragment_list.xml │ │ │ │ ├── activity_main.xml │ │ │ │ ├── layout_blog_list_item.xml │ │ │ │ └── fragment_detail.xml │ │ │ ├── navigation │ │ │ │ └── main_nav_graph.xml │ │ │ └── drawable-v24 │ │ │ │ └── ic_launcher_foreground.xml │ │ ├── java │ │ │ └── com │ │ │ │ └── codingwithmitch │ │ │ │ └── espressodaggerexamples │ │ │ │ ├── util │ │ │ │ ├── StateEvent.kt │ │ │ │ ├── GlideManager.kt │ │ │ │ ├── ErrorState.kt │ │ │ │ ├── DebugExtensions.kt │ │ │ │ ├── ApiResult.kt │ │ │ │ ├── TopSpacingItemDecoration.kt │ │ │ │ ├── Constants.kt │ │ │ │ ├── GlideRequestManager.kt │ │ │ │ ├── DataState.kt │ │ │ │ ├── ApiResponseHandler.kt │ │ │ │ └── ErrorStack.kt │ │ │ │ ├── di │ │ │ │ ├── keys │ │ │ │ │ └── MainViewModelKey.kt │ │ │ │ ├── AppModule.kt │ │ │ │ ├── RepositoryModule.kt │ │ │ │ ├── FragmentModule.kt │ │ │ │ ├── ViewModelModule.kt │ │ │ │ ├── AppComponent.kt │ │ │ │ └── InternalBindingsModule.kt │ │ │ │ ├── ui │ │ │ │ ├── UICommunicationListener.kt │ │ │ │ ├── ViewExtensions.kt │ │ │ │ ├── viewmodel │ │ │ │ │ ├── state │ │ │ │ │ │ ├── MainViewState.kt │ │ │ │ │ │ └── MainStateEvent.kt │ │ │ │ │ ├── Getters.kt │ │ │ │ │ ├── Setters.kt │ │ │ │ │ └── MainViewModel.kt │ │ │ │ ├── FinalFragment.kt │ │ │ │ ├── DetailFragment.kt │ │ │ │ ├── BlogPostListAdapter.kt │ │ │ │ ├── ListFragment.kt │ │ │ │ └── MainActivity.kt │ │ │ │ ├── models │ │ │ │ ├── Category.kt │ │ │ │ └── BlogPost.kt │ │ │ │ ├── repository │ │ │ │ ├── MainRepository.kt │ │ │ │ ├── RepositoryExtensions.kt │ │ │ │ └── MainRepositoryImpl.kt │ │ │ │ ├── api │ │ │ │ └── ApiService.kt │ │ │ │ ├── BaseApplication.kt │ │ │ │ ├── fragments │ │ │ │ ├── MainNavHostFragment.kt │ │ │ │ └── MainFragmentFactory.kt │ │ │ │ ├── viewmodels │ │ │ │ └── MainViewModelFactory.kt │ │ │ │ └── views │ │ │ │ └── ScalingImageView.kt │ │ └── AndroidManifest.xml │ ├── release │ │ └── java │ │ │ └── com │ │ │ └── codingwithmitch │ │ │ └── espressodaggerexamples │ │ │ └── util │ │ │ └── EspressoIdlingResource.kt │ ├── androidTest │ │ └── java │ │ │ └── com │ │ │ └── codingwithmitch │ │ │ └── espressodaggerexamples │ │ │ ├── util │ │ │ ├── FakeGlideRequestManager.kt │ │ │ ├── Constants.kt │ │ │ ├── EspressoIdlingResourceRule.kt │ │ │ └── JsonUtil.kt │ │ │ ├── MockTestRunner.kt │ │ │ ├── ui │ │ │ ├── suites │ │ │ │ └── RunAllTests.kt │ │ │ ├── BaseMainActivityTests.kt │ │ │ ├── DetailFragmentTest.kt │ │ │ ├── ListFragmentNavigationTests.kt │ │ │ ├── MainNavigationTests.kt │ │ │ ├── ListFragmentErrorTests.kt │ │ │ └── ListFragmentIntegrationTests.kt │ │ │ ├── TestBaseApplication.kt │ │ │ ├── di │ │ │ ├── TestViewModelModule.kt │ │ │ ├── TestFragmentModule.kt │ │ │ ├── TestAppModule.kt │ │ │ └── TestAppComponent.kt │ │ │ ├── viewmodels │ │ │ └── FakeMainViewModelFactory.kt │ │ │ ├── api │ │ │ └── FakeApiService.kt │ │ │ ├── fragments │ │ │ └── FakeMainFragmentFactory.kt │ │ │ └── repository │ │ │ └── FakeMainRepositoryImpl.kt │ └── test │ │ └── java │ │ └── com │ │ └── codingwithmitch │ │ └── espressodaggerexamples │ │ └── ExampleUnitTest.kt ├── proguard-rules.pro └── build.gradle ├── settings.gradle ├── .idea ├── dictionaries │ └── user.xml ├── codeStyles │ ├── codeStyleConfig.xml │ └── Project.xml ├── vcs.xml ├── misc.xml ├── runConfigurations.xml └── gradle.xml ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── .gitignore ├── gradle.properties ├── README.md ├── gradlew.bat └── gradlew /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /app/src/debug/assets/empty_list.json: -------------------------------------------------------------------------------- 1 | [ 2 | 3 | ] -------------------------------------------------------------------------------- /app/src/debug/assets/server_error.json: -------------------------------------------------------------------------------- 1 | { 2 | "response": "SERVER ERROR" 3 | } 4 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | include ':app' 2 | rootProject.name='EspressoDaggerExamples' 3 | -------------------------------------------------------------------------------- /.idea/dictionaries/user.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mitchtabian/EspressoDaggerExamples/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /app/src/main/res/drawable/default_image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mitchtabian/EspressoDaggerExamples/HEAD/app/src/main/res/drawable/default_image.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mitchtabian/EspressoDaggerExamples/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mitchtabian/EspressoDaggerExamples/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mitchtabian/EspressoDaggerExamples/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mitchtabian/EspressoDaggerExamples/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/values/dimen.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 55dp 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mitchtabian/EspressoDaggerExamples/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mitchtabian/EspressoDaggerExamples/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/mitchtabian/EspressoDaggerExamples/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/mitchtabian/EspressoDaggerExamples/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/mitchtabian/EspressoDaggerExamples/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/mitchtabian/EspressoDaggerExamples/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/java/com/codingwithmitch/espressodaggerexamples/util/StateEvent.kt: -------------------------------------------------------------------------------- 1 | package com.codingwithmitch.espressodaggerexamples.util 2 | 3 | interface StateEvent { 4 | 5 | fun errorInfo(): String 6 | } -------------------------------------------------------------------------------- /app/src/debug/assets/categories.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "pk": 0, 4 | "category_name": "fun" 5 | }, 6 | { 7 | "pk": 1, 8 | "category_name": "dogs" 9 | }, 10 | { 11 | "pk": 2, 12 | "category_name": "earthporn" 13 | } 14 | ] -------------------------------------------------------------------------------- /app/src/main/res/anim/fade_out.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | -------------------------------------------------------------------------------- /app/src/main/java/com/codingwithmitch/espressodaggerexamples/util/GlideManager.kt: -------------------------------------------------------------------------------- 1 | package com.codingwithmitch.espressodaggerexamples.util 2 | 3 | import android.widget.ImageView 4 | 5 | interface GlideManager { 6 | 7 | fun setImage(imageUrl: String, imageView: ImageView) 8 | } -------------------------------------------------------------------------------- /.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 | .cxx 15 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Thu Jan 23 09:42:19 PST 2020 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-5.4.1-all.zip 7 | -------------------------------------------------------------------------------- /app/src/release/java/com/codingwithmitch/espressodaggerexamples/util/EspressoIdlingResource.kt: -------------------------------------------------------------------------------- 1 | package com.codingwithmitch.espressodaggerexamples.util 2 | 3 | 4 | object EspressoIdlingResource { 5 | 6 | fun increment() { 7 | } 8 | 9 | fun decrement() { 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /app/src/main/java/com/codingwithmitch/espressodaggerexamples/util/ErrorState.kt: -------------------------------------------------------------------------------- 1 | package com.codingwithmitch.espressodaggerexamples.util 2 | 3 | import android.os.Parcelable 4 | import kotlinx.android.parcel.Parcelize 5 | 6 | @Parcelize 7 | data class ErrorState( 8 | var message: String 9 | ) : Parcelable -------------------------------------------------------------------------------- /app/src/main/res/anim/fade_in.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/anim/slide_in_left.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/anim/slide_in_right.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/anim/slide_out_left.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/anim/slide_out_right.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/java/com/codingwithmitch/espressodaggerexamples/di/keys/MainViewModelKey.kt: -------------------------------------------------------------------------------- 1 | package com.codingwithmitch.daggermultifeature.feature1.di.keys 2 | 3 | import androidx.lifecycle.ViewModel 4 | import dagger.MapKey 5 | import kotlin.reflect.KClass 6 | 7 | @MapKey 8 | @Target(AnnotationTarget.FUNCTION) 9 | annotation class MainViewModelKey(val value: KClass) 10 | -------------------------------------------------------------------------------- /app/src/main/java/com/codingwithmitch/espressodaggerexamples/util/DebugExtensions.kt: -------------------------------------------------------------------------------- 1 | package com.codingwithmitch.espressodaggerexamples.util 2 | 3 | import android.util.Log 4 | import com.codingwithmitch.espressodaggerexamples.util.Constants.DEBUG 5 | import com.codingwithmitch.espressodaggerexamples.util.Constants.TAG 6 | 7 | fun printLogD(className: String?, message: String ) { 8 | if (DEBUG) { 9 | Log.d(TAG, "$className: $message") 10 | } 11 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/com/codingwithmitch/espressodaggerexamples/util/FakeGlideRequestManager.kt: -------------------------------------------------------------------------------- 1 | package com.codingwithmitch.espressodaggerexamples.util 2 | 3 | import android.widget.ImageView 4 | import javax.inject.Inject 5 | import javax.inject.Singleton 6 | 7 | @Singleton 8 | class FakeGlideRequestManager 9 | @Inject 10 | constructor(): GlideManager{ 11 | 12 | override fun setImage(imageUrl: String, imageView: ImageView){ 13 | // does nothing 14 | } 15 | } -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | EspressoDaggerExamples 3 | 4 | List Fragment 5 | Detail Fragment 6 | 7 | There\'s nothing here! 8 | 9 | Error 10 | Ok 11 | Blog image 12 | 13 | -------------------------------------------------------------------------------- /app/src/test/java/com/codingwithmitch/espressodaggerexamples/ExampleUnitTest.kt: -------------------------------------------------------------------------------- 1 | package com.codingwithmitch.espressodaggerexamples 2 | 3 | import org.junit.Test 4 | 5 | import org.junit.Assert.* 6 | 7 | /** 8 | * Example local unit test, which will execute on the development machine (host). 9 | * 10 | * See [testing documentation](http://d.android.com/tools/testing). 11 | */ 12 | class ExampleUnitTest { 13 | @Test 14 | fun addition_isCorrect() { 15 | assertEquals(4, 2 + 2) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /app/src/main/java/com/codingwithmitch/espressodaggerexamples/util/ApiResult.kt: -------------------------------------------------------------------------------- 1 | package com.codingwithmitch.espressodaggerexamples.util 2 | 3 | /** 4 | * Reference: https://medium.com/@douglas.iacovelli/how-to-handle-errors-with-retrofit-and-coroutines-33e7492a912 5 | */ 6 | sealed class ApiResult { 7 | data class Success(val value: T): ApiResult() 8 | data class GenericError(val code: Int? = null, val errorMessage: String? = null): ApiResult() 9 | object NetworkError: ApiResult() 10 | } 11 | 12 | 13 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 14 | -------------------------------------------------------------------------------- /app/src/main/java/com/codingwithmitch/espressodaggerexamples/util/TopSpacingItemDecoration.kt: -------------------------------------------------------------------------------- 1 | package com.codingwithmitch.espressodaggerexamples.util 2 | 3 | import androidx.recyclerview.widget.RecyclerView 4 | import android.graphics.Rect 5 | import android.view.View 6 | 7 | 8 | class TopSpacingItemDecoration(private val padding: Int) : RecyclerView.ItemDecoration() { 9 | 10 | override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) { 11 | super.getItemOffsets(outRect, view, parent, state) 12 | outRect.top = padding 13 | } 14 | } -------------------------------------------------------------------------------- /app/src/main/java/com/codingwithmitch/espressodaggerexamples/ui/UICommunicationListener.kt: -------------------------------------------------------------------------------- 1 | package com.codingwithmitch.espressodaggerexamples.ui 2 | 3 | import com.codingwithmitch.espressodaggerexamples.models.Category 4 | 5 | interface UICommunicationListener { 6 | 7 | fun showCategoriesMenu(categories: ArrayList) 8 | 9 | fun hideCategoriesMenu() 10 | 11 | fun displayMainProgressBar(isLoading: Boolean) 12 | 13 | fun hideToolbar() 14 | 15 | fun showToolbar() 16 | 17 | fun hideStatusBar() 18 | 19 | fun showStatusBar() 20 | 21 | fun expandAppBar() 22 | 23 | } -------------------------------------------------------------------------------- /app/src/main/java/com/codingwithmitch/espressodaggerexamples/util/Constants.kt: -------------------------------------------------------------------------------- 1 | package com.codingwithmitch.espressodaggerexamples.util 2 | 3 | object Constants{ 4 | 5 | const val TAG = "AppDebug" // Tag for logs 6 | const val DEBUG = true // enable logging 7 | 8 | 9 | const val UNKNOWN_ERROR = "Unknown error" 10 | 11 | const val NETWORK_TIMEOUT = 3000L // ms (request will timeout) 12 | const val NETWORK_ERROR = "Network error" 13 | const val NETWORK_ERROR_TIMEOUT = "Network timeout" 14 | const val NETWORK_DELAY = 1000L // ms (Fake network delay to make app more realistic) 15 | 16 | } -------------------------------------------------------------------------------- /.idea/runConfigurations.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 12 | -------------------------------------------------------------------------------- /app/src/androidTest/java/com/codingwithmitch/espressodaggerexamples/util/Constants.kt: -------------------------------------------------------------------------------- 1 | package com.codingwithmitch.espressodaggerexamples.util 2 | 3 | object Constants { 4 | 5 | const val EMPTY_LIST = "empty_list.json" 6 | const val BLOG_POSTS_DATA_FILENAME = "blog_posts.json" 7 | const val SERVER_ERROR_FILENAME = "server_error.json" 8 | const val CATEGORIES_DATA_FILENAME = "categories.json" 9 | 10 | const val UNKNOWN_ERROR = "Unknown error" 11 | 12 | const val NETWORK_TIMEOUT = 3000L // ms (request will timeout) 13 | const val NETWORK_ERROR = "Network error" 14 | const val NETWORK_ERROR_TIMEOUT = "Network timeout" 15 | 16 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/com/codingwithmitch/espressodaggerexamples/MockTestRunner.kt: -------------------------------------------------------------------------------- 1 | package com.codingwithmitch.espressodaggerexamples 2 | 3 | 4 | import android.app.Application 5 | import android.content.Context 6 | import androidx.test.runner.AndroidJUnitRunner 7 | import kotlinx.coroutines.ExperimentalCoroutinesApi 8 | 9 | @ExperimentalCoroutinesApi 10 | class MockTestRunner: AndroidJUnitRunner(){ 11 | 12 | override fun newApplication( 13 | cl: ClassLoader?, 14 | className: String?, 15 | context: Context? 16 | ) : Application { 17 | return super.newApplication(cl, TestBaseApplication::class.java.name, context) 18 | } 19 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/com/codingwithmitch/espressodaggerexamples/ui/suites/RunAllTests.kt: -------------------------------------------------------------------------------- 1 | package com.codingwithmitch.espressodaggerexamples.ui.suites 2 | 3 | import com.codingwithmitch.espressodaggerexamples.ui.* 4 | import kotlinx.coroutines.InternalCoroutinesApi 5 | import org.junit.runner.RunWith 6 | import org.junit.runners.Suite 7 | 8 | @UseExperimental(InternalCoroutinesApi::class) 9 | @RunWith(Suite::class) 10 | @Suite.SuiteClasses( 11 | MainNavigationTests::class, 12 | ListFragmentNavigationTests::class, 13 | ListFragmentIntegrationTests::class, 14 | ListFragmentErrorTests::class, 15 | DetailFragmentTest::class 16 | ) 17 | class RunAllTests -------------------------------------------------------------------------------- /app/src/main/java/com/codingwithmitch/espressodaggerexamples/models/Category.kt: -------------------------------------------------------------------------------- 1 | package com.codingwithmitch.espressodaggerexamples.models 2 | 3 | import android.os.Parcelable 4 | import com.google.gson.annotations.Expose 5 | import com.google.gson.annotations.SerializedName 6 | import kotlinx.android.parcel.Parcelize 7 | 8 | @Parcelize 9 | data class Category( 10 | 11 | @SerializedName("pk") 12 | @Expose 13 | var pk: Int, 14 | 15 | @SerializedName("category_name") 16 | @Expose 17 | var category_name: String 18 | 19 | ) : Parcelable { 20 | 21 | override fun toString(): String { 22 | return "Category(pk=$pk, category='$category_name')" 23 | } 24 | } -------------------------------------------------------------------------------- /app/src/main/java/com/codingwithmitch/espressodaggerexamples/repository/MainRepository.kt: -------------------------------------------------------------------------------- 1 | package com.codingwithmitch.espressodaggerexamples.repository 2 | 3 | import com.codingwithmitch.espressodaggerexamples.util.StateEvent 4 | import com.codingwithmitch.espressodaggerexamples.ui.viewmodel.state.MainViewState 5 | import com.codingwithmitch.espressodaggerexamples.util.DataState 6 | import kotlinx.coroutines.flow.Flow 7 | 8 | interface MainRepository{ 9 | 10 | fun getBlogs(stateEvent: StateEvent, category: String): Flow> 11 | 12 | fun getAllBlogs(stateEvent: StateEvent): Flow> 13 | 14 | fun getCategories(stateEvent: StateEvent): Flow> 15 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/com/codingwithmitch/espressodaggerexamples/TestBaseApplication.kt: -------------------------------------------------------------------------------- 1 | package com.codingwithmitch.espressodaggerexamples 2 | 3 | import com.codingwithmitch.espressodaggerexamples.di.DaggerTestAppComponent 4 | import kotlinx.coroutines.ExperimentalCoroutinesApi 5 | import kotlinx.coroutines.InternalCoroutinesApi 6 | 7 | @ExperimentalCoroutinesApi 8 | @UseExperimental(InternalCoroutinesApi::class) 9 | class TestBaseApplication : BaseApplication(){ 10 | 11 | override fun initAppComponent() { 12 | appComponent = DaggerTestAppComponent.builder() 13 | .application(this) 14 | .build() 15 | } 16 | 17 | 18 | 19 | } 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /app/src/main/res/layout/fragment_final.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 16 | 17 | -------------------------------------------------------------------------------- /.idea/gradle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 18 | 19 | -------------------------------------------------------------------------------- /app/src/main/java/com/codingwithmitch/espressodaggerexamples/util/GlideRequestManager.kt: -------------------------------------------------------------------------------- 1 | package com.codingwithmitch.espressodaggerexamples.util 2 | 3 | import android.widget.ImageView 4 | import com.bumptech.glide.RequestManager 5 | import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions 6 | import javax.inject.Inject 7 | import javax.inject.Singleton 8 | 9 | @Singleton 10 | class GlideRequestManager 11 | @Inject 12 | constructor( 13 | private val requestManager: RequestManager 14 | ): GlideManager{ 15 | 16 | override fun setImage(imageUrl: String, imageView: ImageView){ 17 | requestManager 18 | .load(imageUrl) 19 | .transition(DrawableTransitionOptions.withCrossFade()) 20 | .into(imageView) 21 | } 22 | } -------------------------------------------------------------------------------- /app/src/main/java/com/codingwithmitch/espressodaggerexamples/api/ApiService.kt: -------------------------------------------------------------------------------- 1 | package com.codingwithmitch.espressodaggerexamples.api 2 | 3 | import com.codingwithmitch.espressodaggerexamples.models.BlogPost 4 | import com.codingwithmitch.espressodaggerexamples.models.Category 5 | import retrofit2.http.GET 6 | import retrofit2.http.Query 7 | 8 | interface ApiService { 9 | 10 | @GET("blogs") 11 | suspend fun getBlogPosts( 12 | @Query("category") category: String 13 | ): List 14 | 15 | @GET("blogs") 16 | suspend fun getAllBlogPosts(): List 17 | 18 | @GET("categories") 19 | suspend fun getCategories(): List 20 | 21 | companion object{ 22 | const val BASE_URL = "https://open-api.xyz/placeholder/" 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 22 | -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | #fff 5 | #7d7d7d 6 | #FF4081 7 | 8 | 9 | #0094DE 10 | #9FDAF7 11 | #0000EE 12 | 13 | 14 | #e22b2b 15 | #fc8b8d 16 | #f2f2f2 17 | #c4c4c4 18 | #a8a8a8 19 | #7d7d7d 20 | #5c5c5c 21 | 22 | #EEEEEE 23 | 24 | -------------------------------------------------------------------------------- /app/src/main/java/com/codingwithmitch/espressodaggerexamples/BaseApplication.kt: -------------------------------------------------------------------------------- 1 | package com.codingwithmitch.espressodaggerexamples 2 | 3 | import android.app.Application 4 | import com.codingwithmitch.espressodaggerexamples.di.AppComponent 5 | import com.codingwithmitch.espressodaggerexamples.di.DaggerAppComponent 6 | import kotlinx.coroutines.ExperimentalCoroutinesApi 7 | import kotlinx.coroutines.InternalCoroutinesApi 8 | 9 | @ExperimentalCoroutinesApi 10 | @InternalCoroutinesApi 11 | open class BaseApplication: Application() { 12 | 13 | private val TAG: String = "AppDebug" 14 | 15 | lateinit var appComponent: AppComponent 16 | 17 | override fun onCreate() { 18 | super.onCreate() 19 | initAppComponent() 20 | } 21 | 22 | open fun initAppComponent() { 23 | appComponent = DaggerAppComponent.builder() 24 | .application(this) 25 | .build() 26 | } 27 | 28 | } 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /app/src/androidTest/java/com/codingwithmitch/espressodaggerexamples/util/EspressoIdlingResourceRule.kt: -------------------------------------------------------------------------------- 1 | package com.codingwithmitch.espressodaggerexamples.util 2 | 3 | import androidx.test.espresso.IdlingRegistry 4 | import org.junit.rules.TestWatcher 5 | import org.junit.runner.Description 6 | 7 | class EspressoIdlingResourceRule : TestWatcher(){ 8 | 9 | private val CLASS_NAME = "EspressoIdlingResourceRule" 10 | 11 | private val idlingResource = EspressoIdlingResource.countingIdlingResource 12 | 13 | override fun finished(description: Description?) { 14 | printLogD(CLASS_NAME, "FINISHED") 15 | IdlingRegistry.getInstance().unregister(idlingResource) 16 | super.finished(description) 17 | } 18 | 19 | override fun starting(description: Description?) { 20 | printLogD(CLASS_NAME, "STARTING") 21 | IdlingRegistry.getInstance().register(idlingResource) 22 | super.starting(description) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /app/src/androidTest/java/com/codingwithmitch/espressodaggerexamples/util/JsonUtil.kt: -------------------------------------------------------------------------------- 1 | package com.codingwithmitch.espressodaggerexamples.util 2 | 3 | import android.app.Application 4 | import android.content.res.AssetManager 5 | import java.io.IOException 6 | import java.io.InputStream 7 | import javax.inject.Inject 8 | 9 | /** 10 | * Class for parsing data from fake data assets 11 | */ 12 | class JsonUtil 13 | @Inject 14 | constructor( 15 | private val application: Application 16 | ){ 17 | 18 | private val CLASS_NAME = "JsonUtil" 19 | 20 | fun readJSONFromAsset(fileName: String): String? { 21 | var json: String? = null 22 | json = try { 23 | val inputStream: InputStream = (application.assets as AssetManager).open(fileName) 24 | inputStream.bufferedReader().use{it.readText()} 25 | } catch (ex: IOException) { 26 | ex.printStackTrace() 27 | return null 28 | } 29 | return json 30 | } 31 | } -------------------------------------------------------------------------------- /app/src/main/java/com/codingwithmitch/espressodaggerexamples/di/AppModule.kt: -------------------------------------------------------------------------------- 1 | package com.codingwithmitch.espressodaggerexamples.di 2 | 3 | import android.app.Application 4 | import com.bumptech.glide.Glide 5 | import com.codingwithmitch.espressodaggerexamples.util.GlideManager 6 | import com.codingwithmitch.espressodaggerexamples.util.GlideRequestManager 7 | import dagger.Module 8 | import dagger.Provides 9 | import kotlinx.coroutines.ExperimentalCoroutinesApi 10 | import kotlinx.coroutines.InternalCoroutinesApi 11 | import javax.inject.Singleton 12 | 13 | /* Alternative for Test: 'TestAppModule' */ 14 | @ExperimentalCoroutinesApi 15 | @InternalCoroutinesApi 16 | @Module 17 | object AppModule{ 18 | 19 | @JvmStatic 20 | @Singleton 21 | @Provides 22 | fun provideGlideRequestManager( 23 | application: Application 24 | ): GlideManager { 25 | return GlideRequestManager( 26 | Glide.with(application) 27 | ) 28 | } 29 | 30 | } 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /app/src/androidTest/java/com/codingwithmitch/espressodaggerexamples/di/TestViewModelModule.kt: -------------------------------------------------------------------------------- 1 | package com.codingwithmitch.espressodaggerexamples.di 2 | 3 | import androidx.lifecycle.ViewModelProvider 4 | import com.codingwithmitch.espressodaggerexamples.repository.FakeMainRepositoryImpl 5 | import com.codingwithmitch.espressodaggerexamples.viewmodels.FakeMainViewModelFactory 6 | import dagger.Module 7 | import dagger.Provides 8 | import kotlinx.coroutines.ExperimentalCoroutinesApi 9 | import kotlinx.coroutines.InternalCoroutinesApi 10 | import javax.inject.Singleton 11 | 12 | @ExperimentalCoroutinesApi 13 | @InternalCoroutinesApi 14 | @Module 15 | object TestViewModelModule { 16 | 17 | @JvmStatic 18 | @Singleton 19 | @Provides 20 | fun provideViewModelFactory( 21 | mainRepository: FakeMainRepositoryImpl 22 | ): ViewModelProvider.Factory{ 23 | return FakeMainViewModelFactory( 24 | mainRepository 25 | ) 26 | } 27 | } 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /app/src/debug/java/com/codingwithmitch/espressodaggerexamples/util/EspressoIdlingResource.kt: -------------------------------------------------------------------------------- 1 | package com.codingwithmitch.espressodaggerexamples.util 2 | 3 | import androidx.test.espresso.idling.CountingIdlingResource 4 | 5 | object EspressoIdlingResource { 6 | 7 | private val CLASS_NAME = "EspressoIdlingResource" 8 | 9 | private const val RESOURCE = "GLOBAL" 10 | 11 | @JvmField val countingIdlingResource = CountingIdlingResource(RESOURCE) 12 | 13 | fun increment() { 14 | printLogD(CLASS_NAME, "INCREMENTING.") 15 | countingIdlingResource.increment() 16 | } 17 | 18 | fun decrement() { 19 | if (!countingIdlingResource.isIdleNow) { 20 | printLogD(CLASS_NAME, "DECREMENTING.") 21 | countingIdlingResource.decrement() 22 | } 23 | } 24 | 25 | fun clear(): Boolean { 26 | if (!countingIdlingResource.isIdleNow) { 27 | decrement() 28 | return false 29 | } 30 | else{ 31 | return true 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /app/src/main/java/com/codingwithmitch/espressodaggerexamples/fragments/MainNavHostFragment.kt: -------------------------------------------------------------------------------- 1 | package com.codingwithmitch.espressodaggerexamples.fragments 2 | 3 | import android.content.Context 4 | import androidx.fragment.app.FragmentFactory 5 | import androidx.navigation.fragment.NavHostFragment 6 | import com.codingwithmitch.espressodaggerexamples.BaseApplication 7 | import kotlinx.coroutines.ExperimentalCoroutinesApi 8 | import kotlinx.coroutines.InternalCoroutinesApi 9 | import javax.inject.Inject 10 | 11 | @ExperimentalCoroutinesApi 12 | @InternalCoroutinesApi 13 | class MainNavHostFragment : NavHostFragment(){ 14 | 15 | private val TAG: String = "AppDebug" 16 | 17 | @Inject 18 | lateinit var mainFragmentFactory: FragmentFactory 19 | 20 | override fun onAttach(context: Context) { 21 | (activity?.application as BaseApplication).appComponent 22 | .inject(this) 23 | childFragmentManager.fragmentFactory = mainFragmentFactory 24 | super.onAttach(context) 25 | } 26 | 27 | } 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /app/src/main/java/com/codingwithmitch/espressodaggerexamples/di/RepositoryModule.kt: -------------------------------------------------------------------------------- 1 | package com.codingwithmitch.espressodaggerexamples.di 2 | 3 | import com.codingwithmitch.espressodaggerexamples.api.ApiService 4 | import com.codingwithmitch.espressodaggerexamples.repository.MainRepository 5 | import com.codingwithmitch.espressodaggerexamples.repository.MainRepositoryImpl 6 | import dagger.Module 7 | import dagger.Provides 8 | import retrofit2.Retrofit 9 | import javax.inject.Singleton 10 | 11 | @Module 12 | object RepositoryModule{ 13 | 14 | @JvmStatic 15 | @Singleton 16 | @Provides 17 | fun provideApiService(retrofitBuilder: Retrofit.Builder): ApiService { 18 | return retrofitBuilder 19 | .build() 20 | .create(ApiService::class.java) 21 | } 22 | 23 | 24 | @JvmStatic 25 | @Singleton 26 | @Provides 27 | fun provideMainRepository(apiService: ApiService): MainRepository { 28 | return MainRepositoryImpl(apiService) 29 | } 30 | } 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /app/src/androidTest/java/com/codingwithmitch/espressodaggerexamples/di/TestFragmentModule.kt: -------------------------------------------------------------------------------- 1 | package com.codingwithmitch.espressodaggerexamples.di 2 | 3 | import androidx.fragment.app.FragmentFactory 4 | import com.codingwithmitch.espressodaggerexamples.fragments.FakeMainFragmentFactory 5 | import com.codingwithmitch.espressodaggerexamples.util.GlideManager 6 | import com.codingwithmitch.espressodaggerexamples.viewmodels.FakeMainViewModelFactory 7 | import dagger.Module 8 | import dagger.Provides 9 | import kotlinx.coroutines.ExperimentalCoroutinesApi 10 | import kotlinx.coroutines.InternalCoroutinesApi 11 | import javax.inject.Singleton 12 | 13 | @ExperimentalCoroutinesApi 14 | @InternalCoroutinesApi 15 | @Module 16 | object TestFragmentModule { 17 | 18 | @JvmStatic 19 | @Singleton 20 | @Provides 21 | fun provideMainFragmentFactory( 22 | viewModelFactory: FakeMainViewModelFactory, 23 | glideManager: GlideManager 24 | ): FragmentFactory { 25 | return FakeMainFragmentFactory(viewModelFactory, glideManager) 26 | } 27 | } 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /app/src/main/java/com/codingwithmitch/espressodaggerexamples/di/FragmentModule.kt: -------------------------------------------------------------------------------- 1 | package com.codingwithmitch.espressodaggerexamples.di 2 | 3 | import androidx.fragment.app.FragmentFactory 4 | import com.codingwithmitch.espressodaggerexamples.fragments.MainFragmentFactory 5 | import com.codingwithmitch.espressodaggerexamples.util.GlideManager 6 | import com.codingwithmitch.espressodaggerexamples.viewmodels.MainViewModelFactory 7 | import dagger.Module 8 | import dagger.Provides 9 | import kotlinx.coroutines.ExperimentalCoroutinesApi 10 | import kotlinx.coroutines.InternalCoroutinesApi 11 | import javax.inject.Singleton 12 | 13 | @ExperimentalCoroutinesApi 14 | @InternalCoroutinesApi 15 | @Module 16 | object FragmentModule { 17 | 18 | @JvmStatic 19 | @Singleton 20 | @Provides 21 | fun provideMainFragmentFactory( 22 | viewModelFactory: MainViewModelFactory, 23 | glideManager: GlideManager 24 | ): FragmentFactory{ 25 | return MainFragmentFactory(viewModelFactory, glideManager) 26 | } 27 | 28 | } 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /app/src/androidTest/java/com/codingwithmitch/espressodaggerexamples/di/TestAppModule.kt: -------------------------------------------------------------------------------- 1 | package com.codingwithmitch.espressodaggerexamples.di 2 | 3 | import android.app.Application 4 | import com.codingwithmitch.espressodaggerexamples.util.GlideManager 5 | import com.codingwithmitch.espressodaggerexamples.util.FakeGlideRequestManager 6 | import com.codingwithmitch.espressodaggerexamples.util.JsonUtil 7 | import dagger.Module 8 | import dagger.Provides 9 | import kotlinx.coroutines.ExperimentalCoroutinesApi 10 | import kotlinx.coroutines.InternalCoroutinesApi 11 | import javax.inject.Singleton 12 | 13 | @ExperimentalCoroutinesApi 14 | @InternalCoroutinesApi 15 | @Module 16 | object TestAppModule{ 17 | 18 | @JvmStatic 19 | @Singleton 20 | @Provides 21 | fun provideGlideRequestManager(): GlideManager { 22 | return FakeGlideRequestManager() 23 | } 24 | 25 | @JvmStatic 26 | @Singleton 27 | @Provides 28 | fun provideJsonUtil(application: Application): JsonUtil { 29 | return JsonUtil(application) 30 | } 31 | 32 | } 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /app/src/main/java/com/codingwithmitch/espressodaggerexamples/models/BlogPost.kt: -------------------------------------------------------------------------------- 1 | package com.codingwithmitch.espressodaggerexamples.models 2 | 3 | import android.os.Parcelable 4 | import com.google.gson.annotations.Expose 5 | import com.google.gson.annotations.SerializedName 6 | import kotlinx.android.parcel.Parcelize 7 | 8 | @Parcelize 9 | data class BlogPost ( 10 | 11 | @SerializedName("pk") 12 | @Expose 13 | var pk: Int, 14 | 15 | @SerializedName("title") 16 | @Expose 17 | var title: String, 18 | 19 | @SerializedName("body") 20 | @Expose 21 | var body: String, 22 | 23 | @SerializedName("image") 24 | @Expose 25 | var image: String, 26 | 27 | @SerializedName("category") 28 | @Expose 29 | var category: String 30 | ) : Parcelable { 31 | override fun toString(): String { 32 | return "\nBlogPost(" + 33 | "\npk=$pk" + 34 | "\ntitle='$title'" + 35 | "\nbody='$body'" + 36 | "\nimage='$image'" + 37 | "\ncategory='$category'" + 38 | "\n)" + 39 | "\n----------" 40 | } 41 | } -------------------------------------------------------------------------------- /app/src/main/java/com/codingwithmitch/espressodaggerexamples/viewmodels/MainViewModelFactory.kt: -------------------------------------------------------------------------------- 1 | package com.codingwithmitch.espressodaggerexamples.viewmodels 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.ViewModelProvider 5 | import javax.inject.Inject 6 | import javax.inject.Provider 7 | import javax.inject.Singleton 8 | 9 | /* classic Map Multibinding for providing ViewModels through Factory */ 10 | @Singleton 11 | class MainViewModelFactory 12 | @Inject 13 | constructor( 14 | private val creators: Map, @JvmSuppressWildcards Provider> 15 | ) : ViewModelProvider.Factory { 16 | 17 | override fun create(modelClass: Class): T { 18 | val creator = creators[modelClass] ?: creators.entries.firstOrNull { 19 | modelClass.isAssignableFrom(it.key) 20 | }?.value ?: throw IllegalArgumentException("unknown model class $modelClass") 21 | try { 22 | @Suppress("UNCHECKED_CAST") 23 | return creator.get() as T 24 | } catch (e: Exception) { 25 | throw RuntimeException(e) 26 | } 27 | 28 | } 29 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/com/codingwithmitch/espressodaggerexamples/viewmodels/FakeMainViewModelFactory.kt: -------------------------------------------------------------------------------- 1 | package com.codingwithmitch.espressodaggerexamples.viewmodels 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.ViewModelProvider 5 | import com.codingwithmitch.espressodaggerexamples.repository.FakeMainRepositoryImpl 6 | import com.codingwithmitch.espressodaggerexamples.ui.viewmodel.MainViewModel 7 | import kotlinx.coroutines.ExperimentalCoroutinesApi 8 | import kotlinx.coroutines.InternalCoroutinesApi 9 | import javax.inject.Inject 10 | 11 | 12 | @ExperimentalCoroutinesApi 13 | @InternalCoroutinesApi 14 | class FakeMainViewModelFactory 15 | @Inject 16 | constructor( 17 | private val mainRepository: FakeMainRepositoryImpl 18 | ): ViewModelProvider.Factory { 19 | 20 | override fun create(modelClass: Class): T { 21 | if (modelClass.isAssignableFrom(MainViewModel::class.java)) { 22 | return MainViewModel(mainRepository) as T 23 | } 24 | throw IllegalArgumentException("Unknown ViewModel class") 25 | } 26 | 27 | 28 | } 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /app/src/main/java/com/codingwithmitch/espressodaggerexamples/ui/ViewExtensions.kt: -------------------------------------------------------------------------------- 1 | package com.codingwithmitch.espressodaggerexamples.ui 2 | 3 | import android.app.Activity 4 | import android.app.Application 5 | import android.os.Bundle 6 | import android.widget.Toast 7 | import androidx.fragment.app.Fragment 8 | import com.afollestad.materialdialogs.MaterialDialog 9 | import com.codingwithmitch.espressodaggerexamples.R 10 | 11 | 12 | fun Activity.displayToastMessage(message: String, length: Int){ 13 | Toast.makeText(this, message, length).show() 14 | } 15 | 16 | fun Activity.displayErrorDialog( 17 | errorMessage: String?, 18 | callback: ErrorDialogCallback 19 | ): MaterialDialog{ 20 | return MaterialDialog(this) 21 | .show{ 22 | title(R.string.text_error) 23 | message(text = errorMessage) 24 | positiveButton(R.string.text_ok){ 25 | callback.clearError() 26 | dismiss() 27 | } 28 | cancelOnTouchOutside(false) 29 | } 30 | } 31 | 32 | interface ErrorDialogCallback{ 33 | 34 | fun clearError() 35 | } 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /app/src/main/java/com/codingwithmitch/espressodaggerexamples/util/DataState.kt: -------------------------------------------------------------------------------- 1 | package com.codingwithmitch.espressodaggerexamples.util 2 | 3 | import com.codingwithmitch.espressodaggerexamples.util.Constants.UNKNOWN_ERROR 4 | 5 | data class DataState( 6 | var error: ErrorState? = null, 7 | var data: T? = null, 8 | var stateEvent: StateEvent 9 | ) { 10 | 11 | companion object { 12 | 13 | fun error( 14 | errorMessage: String?, 15 | stateEvent: StateEvent 16 | ): DataState { 17 | return DataState( 18 | error = ErrorState(errorMessage?: UNKNOWN_ERROR), 19 | data = null, 20 | stateEvent = stateEvent 21 | ) 22 | } 23 | 24 | fun data( 25 | data: T? = null, 26 | stateEvent: StateEvent 27 | ): DataState { 28 | return DataState( 29 | error = null, 30 | data = data, 31 | stateEvent = stateEvent 32 | ) 33 | } 34 | } 35 | } 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /app/src/main/java/com/codingwithmitch/espressodaggerexamples/di/ViewModelModule.kt: -------------------------------------------------------------------------------- 1 | package com.codingwithmitch.espressodaggerexamples.di 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.ViewModelProvider 5 | import com.codingwithmitch.daggermultifeature.feature1.di.keys.MainViewModelKey 6 | import com.codingwithmitch.espressodaggerexamples.ui.viewmodel.MainViewModel 7 | import com.codingwithmitch.espressodaggerexamples.viewmodels.MainViewModelFactory 8 | import dagger.Binds 9 | import dagger.Module 10 | import dagger.multibindings.IntoMap 11 | import kotlinx.coroutines.ExperimentalCoroutinesApi 12 | import kotlinx.coroutines.InternalCoroutinesApi 13 | 14 | /* Alternative is provided for test (TestViewModelModule) */ 15 | @ExperimentalCoroutinesApi 16 | @InternalCoroutinesApi 17 | @Module 18 | abstract class ViewModelModule { 19 | 20 | @Binds 21 | abstract fun bindViewModelFactory(vmFactory: MainViewModelFactory): ViewModelProvider.Factory 22 | 23 | @Binds 24 | @IntoMap 25 | @MainViewModelKey(MainViewModel::class) 26 | abstract fun bindMainViewModel(viewModel: MainViewModel): ViewModel 27 | 28 | } 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /app/src/main/java/com/codingwithmitch/espressodaggerexamples/ui/viewmodel/state/MainViewState.kt: -------------------------------------------------------------------------------- 1 | package com.codingwithmitch.espressodaggerexamples.ui.viewmodel.state 2 | 3 | import android.os.Parcelable 4 | import com.codingwithmitch.espressodaggerexamples.models.BlogPost 5 | import com.codingwithmitch.espressodaggerexamples.models.Category 6 | import kotlinx.android.parcel.Parcelize 7 | 8 | 9 | const val MAIN_VIEW_STATE_BUNDLE_KEY = "com.codingwithmitch.espressodaggerexamples.ui.viewmodel.state.MainViewState" 10 | 11 | @Parcelize 12 | data class MainViewState ( 13 | 14 | var activeJobCounter: HashSet = HashSet(), 15 | 16 | var listFragmentView: ListFragmentView = ListFragmentView(), 17 | 18 | var detailFragmentView: DetailFragmentView = DetailFragmentView() 19 | 20 | ) : Parcelable { 21 | 22 | @Parcelize 23 | data class ListFragmentView( 24 | 25 | var blogs: List? = null, 26 | var categories: List? = null, 27 | var layoutManagerState: Parcelable? = null 28 | 29 | ) : Parcelable 30 | 31 | @Parcelize 32 | data class DetailFragmentView( 33 | 34 | var selectedBlogPost: BlogPost? = null 35 | 36 | ) : Parcelable 37 | 38 | 39 | } 40 | 41 | -------------------------------------------------------------------------------- /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=-Xmx1536m 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 22 | -------------------------------------------------------------------------------- /app/src/main/java/com/codingwithmitch/espressodaggerexamples/ui/viewmodel/state/MainStateEvent.kt: -------------------------------------------------------------------------------- 1 | package com.codingwithmitch.espressodaggerexamples.ui.viewmodel.state 2 | 3 | import com.codingwithmitch.espressodaggerexamples.util.StateEvent 4 | 5 | sealed class MainStateEvent: 6 | StateEvent { 7 | 8 | class GetAllBlogs: MainStateEvent(){ 9 | 10 | override fun errorInfo(): String{ 11 | return "Unable to retrieve all blog posts." 12 | } 13 | 14 | override fun toString(): String { 15 | return "GetAllBlogs" 16 | } 17 | } 18 | 19 | class GetCategories: MainStateEvent(){ 20 | 21 | override fun errorInfo(): String{ 22 | return "Unable to retrieve categories." 23 | } 24 | 25 | override fun toString(): String { 26 | return "GetCategories" 27 | } 28 | } 29 | 30 | data class SearchBlogsByCategory( 31 | val category: String 32 | ): MainStateEvent(){ 33 | 34 | override fun errorInfo(): String{ 35 | return "Unable to retrieve all blog posts from the category '$category.'" 36 | } 37 | 38 | override fun toString(): String { 39 | return "SearchBlogsByCategory" 40 | } 41 | } 42 | 43 | 44 | } -------------------------------------------------------------------------------- /app/src/main/java/com/codingwithmitch/espressodaggerexamples/di/AppComponent.kt: -------------------------------------------------------------------------------- 1 | package com.codingwithmitch.espressodaggerexamples.di 2 | 3 | import android.app.Application 4 | import com.codingwithmitch.espressodaggerexamples.fragments.MainNavHostFragment 5 | import com.codingwithmitch.espressodaggerexamples.ui.MainActivity 6 | import com.codingwithmitch.espressodaggerexamples.ui.UICommunicationListener 7 | import dagger.BindsInstance 8 | import dagger.Component 9 | import kotlinx.coroutines.ExperimentalCoroutinesApi 10 | import kotlinx.coroutines.InternalCoroutinesApi 11 | import javax.inject.Singleton 12 | 13 | @ExperimentalCoroutinesApi 14 | @InternalCoroutinesApi 15 | @Singleton 16 | @Component(modules = [ 17 | FragmentModule::class, 18 | ViewModelModule::class, 19 | InternalBindingsModule::class, 20 | AppModule::class, 21 | RepositoryModule::class 22 | ]) 23 | interface AppComponent { 24 | 25 | @Component.Builder 26 | interface Builder { 27 | 28 | @BindsInstance 29 | fun application(app: Application): Builder 30 | 31 | fun build(): AppComponent 32 | } 33 | 34 | fun inject(mainActivity: MainActivity) 35 | 36 | fun inject(mainNavHostFragment: MainNavHostFragment) 37 | 38 | } 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /app/src/main/res/layout/fragment_list.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 15 | 16 | 21 | 22 | 23 | 24 | 25 | 26 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /app/src/main/java/com/codingwithmitch/espressodaggerexamples/di/InternalBindingsModule.kt: -------------------------------------------------------------------------------- 1 | package com.codingwithmitch.espressodaggerexamples.di 2 | 3 | import com.codingwithmitch.espressodaggerexamples.api.ApiService 4 | import com.codingwithmitch.espressodaggerexamples.util.Constants 5 | import com.google.gson.Gson 6 | import com.google.gson.GsonBuilder 7 | import dagger.Module 8 | import dagger.Provides 9 | import kotlinx.coroutines.ExperimentalCoroutinesApi 10 | import kotlinx.coroutines.InternalCoroutinesApi 11 | import retrofit2.Retrofit 12 | import retrofit2.converter.gson.GsonConverterFactory 13 | import javax.inject.Singleton 14 | 15 | 16 | /** 17 | * These are dependencies that are not provided to other parts of the application. 18 | * They are used 'internally' by other dependencies. 19 | */ 20 | /* **These will NOT be mocked in tests***/ 21 | @ExperimentalCoroutinesApi 22 | @InternalCoroutinesApi 23 | @Module 24 | object InternalBindingsModule { 25 | 26 | 27 | @JvmStatic 28 | @Singleton 29 | @Provides 30 | fun provideGsonBuilder(): Gson { 31 | return GsonBuilder().excludeFieldsWithoutExposeAnnotation().create() 32 | } 33 | 34 | @JvmStatic 35 | @Singleton 36 | @Provides 37 | fun provideRetrofitBuilder(gsonBuilder: Gson): Retrofit.Builder { 38 | return Retrofit.Builder() 39 | .baseUrl(ApiService.BASE_URL) 40 | .addConverterFactory(GsonConverterFactory.create(gsonBuilder)) 41 | } 42 | } 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /app/src/main/java/com/codingwithmitch/espressodaggerexamples/util/ApiResponseHandler.kt: -------------------------------------------------------------------------------- 1 | package com.codingwithmitch.espressodaggerexamples.util 2 | 3 | import com.codingwithmitch.espressodaggerexamples.util.Constants.NETWORK_ERROR 4 | import com.codingwithmitch.espressodaggerexamples.util.Constants.UNKNOWN_ERROR 5 | 6 | abstract class ApiResponseHandler ( 7 | response: ApiResult, 8 | stateEvent: StateEvent 9 | ){ 10 | val result: DataState = when(response){ 11 | 12 | is ApiResult.GenericError -> { 13 | DataState.error( 14 | errorMessage = stateEvent.errorInfo() 15 | + "\n\nReason: " + response.errorMessage, 16 | stateEvent = stateEvent 17 | ) 18 | } 19 | 20 | is ApiResult.NetworkError -> { 21 | DataState.error( 22 | errorMessage = stateEvent.errorInfo() 23 | + "\n\nReason: " + NETWORK_ERROR, 24 | stateEvent = stateEvent 25 | ) 26 | } 27 | 28 | is ApiResult.Success -> { 29 | if(response.value == null){ 30 | DataState.error( 31 | errorMessage = stateEvent.errorInfo() 32 | + "\n\nReason: " + UNKNOWN_ERROR, 33 | stateEvent = stateEvent 34 | ) 35 | } 36 | else{ 37 | handleSuccess(resultObj = response.value) 38 | } 39 | } 40 | 41 | } 42 | 43 | abstract fun handleSuccess(resultObj: Data): DataState 44 | 45 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/com/codingwithmitch/espressodaggerexamples/di/TestAppComponent.kt: -------------------------------------------------------------------------------- 1 | package com.codingwithmitch.espressodaggerexamples.di 2 | 3 | import android.app.Application 4 | import com.codingwithmitch.espressodaggerexamples.api.FakeApiService 5 | import com.codingwithmitch.espressodaggerexamples.repository.FakeMainRepositoryImpl 6 | import com.codingwithmitch.espressodaggerexamples.ui.* 7 | import dagger.BindsInstance 8 | import dagger.Component 9 | import kotlinx.coroutines.ExperimentalCoroutinesApi 10 | import kotlinx.coroutines.InternalCoroutinesApi 11 | import javax.inject.Singleton 12 | 13 | @ExperimentalCoroutinesApi 14 | @InternalCoroutinesApi 15 | @Singleton 16 | @Component(modules = [ 17 | TestFragmentModule::class, 18 | TestViewModelModule::class, 19 | TestAppModule::class 20 | ]) 21 | interface TestAppComponent: AppComponent { 22 | 23 | val apiService: FakeApiService 24 | 25 | val mainRepository: FakeMainRepositoryImpl 26 | 27 | @Component.Builder 28 | interface Builder { 29 | 30 | @BindsInstance 31 | fun application(app: Application): Builder 32 | 33 | fun build(): TestAppComponent 34 | } 35 | 36 | fun inject(detailFragmentTest: DetailFragmentTest) 37 | 38 | fun inject(listFragmentErrorTests: ListFragmentErrorTests) 39 | 40 | fun inject(mainActivityIntegrationTests: ListFragmentIntegrationTests) 41 | 42 | fun inject(mainNavigationTests: MainNavigationTests) 43 | 44 | fun inject(listFragmentNavigationTests: ListFragmentNavigationTests) 45 | 46 | } 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /app/src/main/java/com/codingwithmitch/espressodaggerexamples/ui/viewmodel/Getters.kt: -------------------------------------------------------------------------------- 1 | package com.codingwithmitch.espressodaggerexamples.ui.viewmodel 2 | 3 | import android.os.Parcelable 4 | import com.codingwithmitch.espressodaggerexamples.ui.viewmodel.state.MainViewState 5 | import kotlinx.coroutines.ExperimentalCoroutinesApi 6 | import kotlinx.coroutines.InternalCoroutinesApi 7 | 8 | @ExperimentalCoroutinesApi 9 | @InternalCoroutinesApi 10 | fun MainViewModel.getCurrentViewStateOrNew(): MainViewState { 11 | val value = viewState.value?.let{ 12 | it 13 | }?: MainViewState() 14 | return value 15 | } 16 | 17 | @ExperimentalCoroutinesApi 18 | @InternalCoroutinesApi 19 | fun MainViewModel.areAnyJobsActive(): Boolean{ 20 | val viewState = getCurrentViewStateOrNew() 21 | return viewState.activeJobCounter.size > 0 22 | } 23 | 24 | 25 | @ExperimentalCoroutinesApi 26 | @InternalCoroutinesApi 27 | fun MainViewModel.getNumActiveJobs(): Int { 28 | val viewState = getCurrentViewStateOrNew() 29 | return viewState.activeJobCounter.size 30 | } 31 | 32 | 33 | @ExperimentalCoroutinesApi 34 | @InternalCoroutinesApi 35 | fun MainViewModel.isJobAlreadyActive(stateEventName: String): Boolean { 36 | val viewState = getCurrentViewStateOrNew() 37 | return viewState.activeJobCounter.contains(stateEventName) 38 | } 39 | 40 | @ExperimentalCoroutinesApi 41 | @InternalCoroutinesApi 42 | fun MainViewModel.getLayoutManagerState(): Parcelable? { 43 | val viewState = getCurrentViewStateOrNew() 44 | return viewState.listFragmentView.layoutManagerState 45 | } 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | -------------------------------------------------------------------------------- /app/src/main/java/com/codingwithmitch/espressodaggerexamples/util/ErrorStack.kt: -------------------------------------------------------------------------------- 1 | package com.codingwithmitch.espressodaggerexamples.util 2 | 3 | import androidx.lifecycle.MutableLiveData 4 | import kotlinx.android.parcel.IgnoredOnParcel 5 | import java.lang.IndexOutOfBoundsException 6 | 7 | const val ERROR_STACK_BUNDLE_KEY = "com.codingwithmitch.espressodaggerexamples.util.ErrorStack" 8 | 9 | 10 | class ErrorStack: ArrayList() { 11 | 12 | @IgnoredOnParcel 13 | val errorState: MutableLiveData = MutableLiveData() 14 | 15 | override fun addAll(elements: Collection): Boolean { 16 | for(element in elements){ 17 | add(element) 18 | } 19 | return true // always return true. We don't care about result bool. 20 | } 21 | 22 | override fun add(element: ErrorState): Boolean { 23 | if(this.size == 0){ 24 | setErrorState(errorState = element) 25 | } 26 | if(this.contains(element)){ // prevent duplicate errors added to stack 27 | return false 28 | } 29 | return super.add(element) 30 | } 31 | 32 | override fun removeAt(index: Int): ErrorState { 33 | try{ 34 | val transaction = super.removeAt(index) 35 | if(this.size > 0){ 36 | setErrorState(errorState = this[0]) 37 | } 38 | else{ 39 | setErrorState(null) 40 | } 41 | return transaction 42 | }catch (e: IndexOutOfBoundsException){ 43 | e.printStackTrace() 44 | } 45 | return ErrorState("does nothing") // this does nothing 46 | } 47 | 48 | private fun setErrorState(errorState: ErrorState?){ 49 | this.errorState.value = errorState 50 | } 51 | } -------------------------------------------------------------------------------- /app/src/main/java/com/codingwithmitch/espressodaggerexamples/fragments/MainFragmentFactory.kt: -------------------------------------------------------------------------------- 1 | package com.codingwithmitch.espressodaggerexamples.fragments 2 | 3 | import androidx.fragment.app.FragmentFactory 4 | import androidx.lifecycle.ViewModelProvider 5 | import com.codingwithmitch.espressodaggerexamples.ui.DetailFragment 6 | import com.codingwithmitch.espressodaggerexamples.ui.FinalFragment 7 | import com.codingwithmitch.espressodaggerexamples.ui.ListFragment 8 | import com.codingwithmitch.espressodaggerexamples.util.GlideManager 9 | import kotlinx.coroutines.ExperimentalCoroutinesApi 10 | import kotlinx.coroutines.InternalCoroutinesApi 11 | import javax.inject.Inject 12 | import javax.inject.Singleton 13 | 14 | @ExperimentalCoroutinesApi 15 | @InternalCoroutinesApi 16 | @Singleton 17 | class MainFragmentFactory 18 | @Inject 19 | constructor( 20 | private val viewModelFactory: ViewModelProvider.Factory, 21 | private val requestManager: GlideManager 22 | ): FragmentFactory(){ 23 | 24 | override fun instantiate(classLoader: ClassLoader, className: String) = 25 | 26 | when(className){ 27 | 28 | ListFragment::class.java.name -> { 29 | val fragment = ListFragment(viewModelFactory, requestManager) 30 | fragment 31 | } 32 | 33 | DetailFragment::class.java.name -> { 34 | val fragment = DetailFragment(viewModelFactory, requestManager) 35 | fragment 36 | } 37 | 38 | FinalFragment::class.java.name -> { 39 | val fragment = FinalFragment(viewModelFactory, requestManager) 40 | fragment 41 | } 42 | 43 | else -> { 44 | super.instantiate(classLoader, className) 45 | } 46 | } 47 | } 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /app/src/main/res/navigation/main_nav_graph.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 12 | 19 | 20 | 21 | 25 | 32 | 33 | 34 | 42 | -------------------------------------------------------------------------------- /app/src/androidTest/java/com/codingwithmitch/espressodaggerexamples/ui/BaseMainActivityTests.kt: -------------------------------------------------------------------------------- 1 | package com.codingwithmitch.espressodaggerexamples.ui 2 | 3 | import com.codingwithmitch.espressodaggerexamples.TestBaseApplication 4 | import com.codingwithmitch.espressodaggerexamples.api.FakeApiService 5 | import com.codingwithmitch.espressodaggerexamples.di.TestAppComponent 6 | import com.codingwithmitch.espressodaggerexamples.repository.FakeMainRepositoryImpl 7 | import kotlinx.coroutines.ExperimentalCoroutinesApi 8 | import kotlinx.coroutines.InternalCoroutinesApi 9 | 10 | /** 11 | * All tests extend this base class for easy configuration of fake Repository 12 | * and fake ApiService. 13 | */ 14 | @ExperimentalCoroutinesApi 15 | @InternalCoroutinesApi 16 | abstract class BaseMainActivityTests{ 17 | 18 | fun configureFakeApiService( 19 | blogsDataSource: String? = null, 20 | categoriesDataSource: String? = null, 21 | networkDelay: Long? = null, 22 | application: TestBaseApplication 23 | ): FakeApiService { 24 | val apiService = (application.appComponent as TestAppComponent).apiService 25 | blogsDataSource?.let { apiService.blogPostsJsonFileName = it } 26 | categoriesDataSource?.let { apiService.categoriesJsonFileName = it } 27 | networkDelay?.let { apiService.networkDelay = it } 28 | return apiService 29 | } 30 | 31 | fun configureFakeRepository( 32 | apiService: FakeApiService, 33 | application: TestBaseApplication 34 | ): FakeMainRepositoryImpl { 35 | val mainRepository = (application.appComponent as TestAppComponent).mainRepository 36 | mainRepository.apiService = apiService 37 | return mainRepository 38 | } 39 | 40 | 41 | abstract fun injectTest(application: TestBaseApplication) 42 | } 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 12 | 13 | 19 | 22 | 25 | 26 | 27 | 28 | 34 | 35 | -------------------------------------------------------------------------------- /app/src/androidTest/java/com/codingwithmitch/espressodaggerexamples/api/FakeApiService.kt: -------------------------------------------------------------------------------- 1 | package com.codingwithmitch.espressodaggerexamples.api 2 | 3 | import com.codingwithmitch.espressodaggerexamples.models.BlogPost 4 | import com.codingwithmitch.espressodaggerexamples.models.Category 5 | import com.codingwithmitch.espressodaggerexamples.util.Constants 6 | import com.codingwithmitch.espressodaggerexamples.util.JsonUtil 7 | import com.google.gson.Gson 8 | import com.google.gson.reflect.TypeToken 9 | import kotlinx.coroutines.delay 10 | import javax.inject.Inject 11 | import javax.inject.Singleton 12 | 13 | @Singleton 14 | class FakeApiService 15 | @Inject 16 | constructor( 17 | private val jsonUtil: JsonUtil 18 | ): ApiService { 19 | 20 | var blogPostsJsonFileName: String = Constants.BLOG_POSTS_DATA_FILENAME 21 | var categoriesJsonFileName: String = Constants.CATEGORIES_DATA_FILENAME 22 | var networkDelay: Long = 0L 23 | 24 | override suspend fun getBlogPosts(category: String): List { 25 | val rawJson = jsonUtil.readJSONFromAsset(blogPostsJsonFileName) 26 | val blogs = Gson().fromJson>( 27 | rawJson, 28 | object : TypeToken>() {}.type 29 | ) 30 | val filteredBlogs = blogs.filter { blogPost -> blogPost.category.equals(category) } 31 | delay(networkDelay) 32 | return filteredBlogs 33 | } 34 | 35 | override suspend fun getAllBlogPosts(): List { 36 | val rawJson = jsonUtil.readJSONFromAsset(blogPostsJsonFileName) 37 | val blogs = Gson().fromJson>( 38 | rawJson, 39 | object : TypeToken>() {}.type 40 | ) 41 | delay(networkDelay) 42 | return blogs 43 | } 44 | 45 | override suspend fun getCategories(): List { 46 | val rawJson = jsonUtil.readJSONFromAsset(categoriesJsonFileName) 47 | val categories = Gson().fromJson>( 48 | rawJson, 49 | object : TypeToken>() {}.type 50 | ) 51 | delay(networkDelay) 52 | return categories 53 | } 54 | 55 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Advanced UI testing with Espresso 3 | 4 |
5 |

What you'll learn:

6 |
    7 |
  • 8 | Building Test FAKES for any class
    9 |
      10 |
    • Do not mistake this for mocks. That is something different.
    • 11 |
    12 |
  • 13 |
  • Custom AndroidJUnitTestRunner
  • 14 |
  • ActivityScenario
  • 15 |
  • ActivityScenarioRule
  • 16 |
  • Types of Mocking and test fakes:
    17 |
      18 |
    1. Dagger Components
    2. 19 |
    3. Dagger Modules
    4. 20 |
    5. Application class
    6. 21 |
    7. Fragment Factory
    8. 22 |
    9. Glide ImageLoader
    10. 23 |
    11. Retrofit network requests
    12. 24 |
    25 |
  • 26 |
  • Navigation Components:
    27 |
      28 |
    • Testing navigation (both fragments in isolation and end to end testing)
    • 29 |
    • Navigation Testing Artifact
    • 30 |
    31 |
  • 32 |
  • Glide (Setting images in test)
  • 33 |
  • RecyclerView Testing:
    34 |
      35 |
    • Scrolling and list item verification
    • 36 |
    • Clicking items to trigger event
    • 37 |
    38 |
  • 39 |
  • Stubbing a Test Data Source (Network)
  • 40 |
  • End-to-end tests with ActivityScenario
  • 41 |
  • Isolation tests with FragmentScenario
  • 42 |
  • Configuration changes (activity/fragment recreation)
  • 43 |
  • Test assets for providing fake network data
  • 44 |
  • Test Orchestrator:
    45 |
      46 |
    • Each test runs in its own Instrumentation instance (no/minimal shared state!)
    • 47 |
    48 |
  • 49 |
50 | 51 |
52 | 53 |

Watch the video course here: UI Testing with Jetpack and AndroidX.

54 | 55 | # Contributors 56 | 1. [@R4md4c](https://twitter.com/R4md4c) 57 | -------------------------------------------------------------------------------- /app/src/androidTest/java/com/codingwithmitch/espressodaggerexamples/fragments/FakeMainFragmentFactory.kt: -------------------------------------------------------------------------------- 1 | package com.codingwithmitch.espressodaggerexamples.fragments 2 | 3 | import androidx.fragment.app.FragmentFactory 4 | import com.codingwithmitch.espressodaggerexamples.ui.* 5 | import com.codingwithmitch.espressodaggerexamples.util.GlideManager 6 | import com.codingwithmitch.espressodaggerexamples.viewmodels.FakeMainViewModelFactory 7 | import kotlinx.coroutines.ExperimentalCoroutinesApi 8 | import kotlinx.coroutines.InternalCoroutinesApi 9 | import javax.inject.Inject 10 | import javax.inject.Singleton 11 | 12 | @ExperimentalCoroutinesApi 13 | @InternalCoroutinesApi 14 | @Singleton 15 | class FakeMainFragmentFactory 16 | @Inject 17 | constructor( 18 | private val viewModelFactory: FakeMainViewModelFactory, 19 | private val requestManager: GlideManager 20 | ): FragmentFactory(){ 21 | 22 | // used for setting a mock 23 | lateinit var uiCommunicationListener: UICommunicationListener 24 | 25 | override fun instantiate(classLoader: ClassLoader, className: String) = 26 | 27 | when(className){ 28 | 29 | ListFragment::class.java.name -> { 30 | val fragment = ListFragment(viewModelFactory, requestManager) 31 | if(::uiCommunicationListener.isInitialized){ 32 | fragment.setUICommunicationListener(uiCommunicationListener) 33 | } 34 | fragment 35 | } 36 | 37 | DetailFragment::class.java.name -> { 38 | val fragment = DetailFragment(viewModelFactory, requestManager) 39 | if(::uiCommunicationListener.isInitialized){ 40 | fragment.setUICommunicationListener(uiCommunicationListener) 41 | } 42 | fragment 43 | } 44 | 45 | FinalFragment::class.java.name -> { 46 | val fragment = FinalFragment(viewModelFactory, requestManager) 47 | if(::uiCommunicationListener.isInitialized){ 48 | fragment.setUICommunicationListener(uiCommunicationListener) 49 | } 50 | fragment 51 | } 52 | 53 | else -> { 54 | super.instantiate(classLoader, className) 55 | } 56 | } 57 | } 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /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/java/com/codingwithmitch/espressodaggerexamples/repository/RepositoryExtensions.kt: -------------------------------------------------------------------------------- 1 | package com.codingwithmitch.espressodaggerexamples.repository 2 | 3 | import com.codingwithmitch.espressodaggerexamples.util.ApiResult 4 | import com.codingwithmitch.espressodaggerexamples.util.ApiResult.* 5 | import com.codingwithmitch.espressodaggerexamples.util.Constants.NETWORK_DELAY 6 | import com.codingwithmitch.espressodaggerexamples.util.Constants.NETWORK_ERROR_TIMEOUT 7 | import com.codingwithmitch.espressodaggerexamples.util.Constants.NETWORK_TIMEOUT 8 | import com.codingwithmitch.espressodaggerexamples.util.Constants.UNKNOWN_ERROR 9 | import kotlinx.coroutines.* 10 | import retrofit2.HttpException 11 | import java.io.IOException 12 | 13 | /** 14 | * Reference: https://medium.com/@douglas.iacovelli/how-to-handle-errors-with-retrofit-and-coroutines-33e7492a912 15 | */ 16 | private val TAG: String = "AppDebug" 17 | 18 | suspend fun safeApiCall( 19 | dispatcher: CoroutineDispatcher, 20 | apiCall: suspend () -> T? 21 | ): ApiResult { 22 | return withContext(dispatcher) { 23 | try { 24 | // throws TimeoutCancellationException 25 | withTimeout(NETWORK_TIMEOUT){ 26 | delay(NETWORK_DELAY) 27 | Success(apiCall.invoke()) 28 | } 29 | } catch (throwable: Throwable) { 30 | when (throwable) { 31 | is TimeoutCancellationException -> { 32 | val code = 408 // timeout error code 33 | GenericError(code, NETWORK_ERROR_TIMEOUT) 34 | } 35 | is IOException -> { 36 | NetworkError 37 | } 38 | is HttpException -> { 39 | val code = throwable.code() 40 | val errorResponse = convertErrorBody(throwable) 41 | GenericError( 42 | code, 43 | errorResponse 44 | ) 45 | } 46 | else -> { 47 | GenericError( 48 | null, 49 | UNKNOWN_ERROR 50 | ) 51 | } 52 | } 53 | } 54 | } 55 | } 56 | 57 | 58 | private fun convertErrorBody(throwable: HttpException): String? { 59 | return try { 60 | throwable.response()?.errorBody()?.toString() 61 | } catch (exception: Exception) { 62 | UNKNOWN_ERROR 63 | } 64 | } 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 16 | 17 | 23 | 24 | 25 | 26 | 27 | 28 | 34 | 35 | 47 | 48 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /app/src/debug/assets/blog_posts.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "pk": 0, 4 | "title": "Vancouver PNE 2019", 5 | "body": "Here is Jess and I at the Vancouver PNE. We ate a lot of food.", 6 | "image": "https://cdn.open-api.xyz/open-api-static/static-blog-images/image8.png", 7 | "category": "fun" 8 | }, 9 | { 10 | "pk": 1, 11 | "title": "Ready for a Walk", 12 | "body": "Here I am at the park with my dogs Kiba and Maizy. Maizy is the smaller one and Kiba is the larger one.", 13 | "image": "https://cdn.open-api.xyz/open-api-static/static-blog-images/image2.png", 14 | "category": "dogs" 15 | }, 16 | { 17 | "pk": 2, 18 | "title": "Maizy Sleeping", 19 | "body": "I took this picture while Maizy was sleeping on the couch. She's very cute.", 20 | "image": "https://cdn.open-api.xyz/open-api-static/static-blog-images/image1.png", 21 | "category": "dogs" 22 | }, 23 | { 24 | "pk": 3, 25 | "title": "My Brother Blake", 26 | "body": "This is a picture of my brother Blake and I. We were taking some pictures for his website.", 27 | "image": "https://cdn.open-api.xyz/open-api-static/static-blog-images/image3.png", 28 | "category": "fun" 29 | }, 30 | { 31 | "pk": 4, 32 | "title": "Lounging Dogs", 33 | "body": "Kiba and Maizy are laying in the sun relaxing.", 34 | "image": "https://cdn.open-api.xyz/open-api-static/static-blog-images/image4.png", 35 | "category": "dogs" 36 | }, 37 | { 38 | "pk": 5, 39 | "title": "Mountains in Washington", 40 | "body": "This is an image I found somewhere on the internert. I love pictures like this. I believe it's in Washington, U.S.A.", 41 | "image": "https://cdn.open-api.xyz/open-api-static/static-blog-images/image5.png", 42 | "category": "earthporn" 43 | }, 44 | { 45 | "pk": 6, 46 | "title": "France Mountain Range", 47 | "body": "Another beautiful picture of nature. You can find more pictures like this one on Reddit.com, in the subreddit: '/r/earthporn'.", 48 | "image": "https://cdn.open-api.xyz/open-api-static/static-blog-images/image6.png", 49 | "category": "earthporn" 50 | }, 51 | { 52 | "pk": 7, 53 | "title": "Aldergrove Park", 54 | "body": "I walk Kiba and Maizy pretty much every day. Usually we go to a park in Aldergrove. It takes about 1 hour, 15 minutes to walk around the entire park.", 55 | "image": "https://cdn.open-api.xyz/open-api-static/static-blog-images/image7.png", 56 | "category": "dogs" 57 | }, 58 | { 59 | "pk": 8, 60 | "title": "Blake Posing for his Website", 61 | "body": "My brother Blake is a Civil Engineer. He also started a side business designing septic fields. He took his photo for his website. People love dogs.", 62 | "image": "https://cdn.open-api.xyz/open-api-static/static-blog-images/image9.png", 63 | "category": "fun" 64 | } 65 | ] -------------------------------------------------------------------------------- /app/src/main/res/layout/layout_blog_list_item.xml: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 13 | 17 | 18 | 19 | 31 | 32 | 33 | 44 | 52 | 53 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | -------------------------------------------------------------------------------- /app/src/main/java/com/codingwithmitch/espressodaggerexamples/ui/FinalFragment.kt: -------------------------------------------------------------------------------- 1 | package com.codingwithmitch.espressodaggerexamples.ui 2 | 3 | 4 | import android.content.Context 5 | import android.os.Bundle 6 | import android.util.Log 7 | import androidx.fragment.app.Fragment 8 | import android.view.View 9 | import androidx.fragment.app.activityViewModels 10 | import androidx.lifecycle.Observer 11 | import androidx.lifecycle.ViewModelProvider 12 | import com.bumptech.glide.Glide 13 | import com.codingwithmitch.espressodaggerexamples.BaseApplication 14 | 15 | import com.codingwithmitch.espressodaggerexamples.R 16 | import com.codingwithmitch.espressodaggerexamples.fragments.MainNavHostFragment 17 | import com.codingwithmitch.espressodaggerexamples.ui.viewmodel.MainViewModel 18 | import com.codingwithmitch.espressodaggerexamples.util.GlideManager 19 | import com.codingwithmitch.espressodaggerexamples.util.GlideRequestManager 20 | import com.codingwithmitch.espressodaggerexamples.util.printLogD 21 | import com.codingwithmitch.espressodaggerexamples.viewmodels.MainViewModelFactory 22 | import kotlinx.android.synthetic.main.fragment_final.* 23 | import kotlinx.coroutines.ExperimentalCoroutinesApi 24 | import kotlinx.coroutines.InternalCoroutinesApi 25 | import java.lang.ClassCastException 26 | import java.lang.Exception 27 | import javax.inject.Inject 28 | 29 | @ExperimentalCoroutinesApi 30 | @InternalCoroutinesApi 31 | class FinalFragment 32 | constructor( 33 | private val viewModelFactory: ViewModelProvider.Factory, 34 | private val requestManager: GlideManager 35 | ) 36 | : Fragment(R.layout.fragment_final) { 37 | 38 | private val CLASS_NAME = "DetailFragment" 39 | 40 | lateinit var uiCommunicationListener: UICommunicationListener 41 | 42 | val viewModel: MainViewModel by activityViewModels { 43 | viewModelFactory 44 | } 45 | 46 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 47 | super.onViewCreated(view, savedInstanceState) 48 | subscribeObservers() 49 | uiCommunicationListener.hideStatusBar() 50 | } 51 | 52 | private fun subscribeObservers(){ 53 | 54 | viewModel.viewState.observe(viewLifecycleOwner, Observer { viewState -> 55 | if(viewState != null){ 56 | viewState.detailFragmentView.selectedBlogPost?.let{ blogPost -> 57 | setImage(blogPost.image) 58 | } 59 | } 60 | }) 61 | } 62 | 63 | private fun setImage(imageUrl: String){ 64 | requestManager.setImage(imageUrl, scaling_image_view) 65 | } 66 | 67 | override fun onAttach(context: Context) { 68 | super.onAttach(context) 69 | setUICommunicationListener(null) 70 | } 71 | 72 | fun setUICommunicationListener(mockUICommuncationListener: UICommunicationListener?){ 73 | 74 | // TEST: Set interface from mock 75 | if(mockUICommuncationListener != null){ 76 | this.uiCommunicationListener = mockUICommuncationListener 77 | } 78 | else{ // PRODUCTION: if no mock, get from context 79 | try { 80 | uiCommunicationListener = (context as UICommunicationListener) 81 | }catch (e: Exception){ 82 | Log.e(CLASS_NAME, "$context must implement UICommunicationListener") 83 | } 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /app/src/main/java/com/codingwithmitch/espressodaggerexamples/ui/DetailFragment.kt: -------------------------------------------------------------------------------- 1 | package com.codingwithmitch.espressodaggerexamples.ui 2 | 3 | import android.content.Context 4 | import android.os.Bundle 5 | import android.util.Log 6 | import android.view.View 7 | import androidx.fragment.app.Fragment 8 | import androidx.fragment.app.activityViewModels 9 | import androidx.lifecycle.Observer 10 | import androidx.lifecycle.ViewModelProvider 11 | import androidx.navigation.fragment.findNavController 12 | import com.codingwithmitch.espressodaggerexamples.R 13 | import com.codingwithmitch.espressodaggerexamples.models.BlogPost 14 | import com.codingwithmitch.espressodaggerexamples.ui.viewmodel.MainViewModel 15 | import com.codingwithmitch.espressodaggerexamples.util.GlideManager 16 | import kotlinx.android.synthetic.main.fragment_detail.* 17 | import kotlinx.coroutines.* 18 | import java.lang.Exception 19 | 20 | @ExperimentalCoroutinesApi 21 | @InternalCoroutinesApi 22 | class DetailFragment 23 | constructor( 24 | private val viewModelFactory: ViewModelProvider.Factory, 25 | private val requestManager: GlideManager 26 | ) : Fragment(R.layout.fragment_detail) { 27 | 28 | private val CLASS_NAME = "DetailFragment" 29 | 30 | lateinit var uiCommunicationListener: UICommunicationListener 31 | 32 | val viewModel: MainViewModel by activityViewModels { 33 | viewModelFactory 34 | } 35 | 36 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 37 | super.onViewCreated(view, savedInstanceState) 38 | 39 | subscribeObservers() 40 | 41 | blog_image.setOnClickListener { 42 | findNavController().navigate(R.id.action_detailFragment_to_finalFragment) 43 | } 44 | 45 | initUI() 46 | } 47 | 48 | private fun initUI(){ 49 | uiCommunicationListener.showStatusBar() 50 | uiCommunicationListener.expandAppBar() 51 | uiCommunicationListener.hideCategoriesMenu() 52 | } 53 | 54 | private fun subscribeObservers(){ 55 | viewModel.viewState.observe(viewLifecycleOwner, Observer { viewState -> 56 | if(viewState != null){ 57 | viewState.detailFragmentView.selectedBlogPost?.let{ selectedBlogPost -> 58 | // printLogD(CLASS_NAME, "$selectedBlogPost") 59 | setBlogPostToView(selectedBlogPost) 60 | } 61 | } 62 | }) 63 | } 64 | 65 | private fun setBlogPostToView(blogPost: BlogPost){ 66 | requestManager 67 | .setImage(blogPost.image, blog_image) 68 | blog_title.text = blogPost.title 69 | blog_category.text = blogPost.category 70 | blog_body.text = blogPost.body 71 | } 72 | 73 | override fun onAttach(context: Context) { 74 | super.onAttach(context) 75 | setUICommunicationListener(null) 76 | } 77 | 78 | 79 | fun setUICommunicationListener(mockUICommuncationListener: UICommunicationListener?){ 80 | 81 | // TEST: Set interface from mock 82 | if(mockUICommuncationListener != null){ 83 | this.uiCommunicationListener = mockUICommuncationListener 84 | } 85 | else{ // PRODUCTION: if no mock, get from context 86 | try { 87 | uiCommunicationListener = (context as UICommunicationListener) 88 | }catch (e: Exception){ 89 | Log.e(CLASS_NAME, "$context must implement UICommunicationListener") 90 | } 91 | } 92 | } 93 | 94 | 95 | } 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | -------------------------------------------------------------------------------- /app/src/main/java/com/codingwithmitch/espressodaggerexamples/ui/viewmodel/Setters.kt: -------------------------------------------------------------------------------- 1 | package com.codingwithmitch.espressodaggerexamples.ui.viewmodel 2 | 3 | import android.os.Parcelable 4 | import com.codingwithmitch.espressodaggerexamples.models.BlogPost 5 | import com.codingwithmitch.espressodaggerexamples.models.Category 6 | import com.codingwithmitch.espressodaggerexamples.util.ErrorStack 7 | import com.codingwithmitch.espressodaggerexamples.util.ErrorState 8 | import com.codingwithmitch.espressodaggerexamples.util.EspressoIdlingResource 9 | import com.codingwithmitch.espressodaggerexamples.util.printLogD 10 | import kotlinx.coroutines.ExperimentalCoroutinesApi 11 | import kotlinx.coroutines.InternalCoroutinesApi 12 | 13 | @ExperimentalCoroutinesApi 14 | @InternalCoroutinesApi 15 | fun MainViewModel.setBlogListData(blogList: List){ 16 | val update = getCurrentViewStateOrNew() 17 | update.listFragmentView.blogs = blogList 18 | setViewState(update) 19 | } 20 | 21 | @ExperimentalCoroutinesApi 22 | @InternalCoroutinesApi 23 | fun MainViewModel.setCategoriesData(categories: List){ 24 | val update = getCurrentViewStateOrNew() 25 | update.listFragmentView.categories = categories 26 | setViewState(update) 27 | } 28 | 29 | @ExperimentalCoroutinesApi 30 | @InternalCoroutinesApi 31 | fun MainViewModel.setSelectedBlogPost(blogPost: BlogPost){ 32 | val update = getCurrentViewStateOrNew() 33 | update.detailFragmentView.selectedBlogPost = blogPost 34 | setViewState(update) 35 | } 36 | 37 | @ExperimentalCoroutinesApi 38 | @InternalCoroutinesApi 39 | fun MainViewModel.clearActiveJobCounter(){ 40 | val update = getCurrentViewStateOrNew() 41 | update.activeJobCounter.clear() 42 | setViewState(update) 43 | } 44 | 45 | @ExperimentalCoroutinesApi 46 | @InternalCoroutinesApi 47 | fun MainViewModel.addJobToCounter(stateEventName: String){ 48 | val update = getCurrentViewStateOrNew() 49 | update.activeJobCounter.add(stateEventName) 50 | setViewState(update) 51 | EspressoIdlingResource.increment() 52 | } 53 | 54 | @ExperimentalCoroutinesApi 55 | @InternalCoroutinesApi 56 | fun MainViewModel.removeJobFromCounter(stateEventName: String){ 57 | val update = getCurrentViewStateOrNew() 58 | update.activeJobCounter.remove(stateEventName) 59 | setViewState(update) 60 | EspressoIdlingResource.decrement() 61 | } 62 | 63 | @ExperimentalCoroutinesApi 64 | @InternalCoroutinesApi 65 | fun MainViewModel.clearBlogPosts(){ 66 | val update = getCurrentViewStateOrNew() 67 | update.listFragmentView.blogs = null 68 | setViewState(update) 69 | } 70 | 71 | @ExperimentalCoroutinesApi 72 | @InternalCoroutinesApi 73 | fun MainViewModel.setLayoutManagerState(layoutManagerState: Parcelable){ 74 | val update = getCurrentViewStateOrNew() 75 | update.listFragmentView.layoutManagerState = layoutManagerState 76 | setViewState(update) 77 | } 78 | 79 | @ExperimentalCoroutinesApi 80 | @InternalCoroutinesApi 81 | fun MainViewModel.clearLayoutManagerState(){ 82 | val update = getCurrentViewStateOrNew() 83 | update.listFragmentView.layoutManagerState = null 84 | setViewState(update) 85 | } 86 | 87 | @ExperimentalCoroutinesApi 88 | @InternalCoroutinesApi 89 | fun MainViewModel.appendErrorState(errorState: ErrorState){ 90 | errorStack.add(errorState) 91 | printLogD(CLASS_NAME, "Appending error state. stack size: ${errorStack.size}") 92 | } 93 | 94 | @ExperimentalCoroutinesApi 95 | @InternalCoroutinesApi 96 | fun MainViewModel.clearError(index: Int){ 97 | errorStack.removeAt(index) 98 | } 99 | 100 | 101 | @ExperimentalCoroutinesApi 102 | @InternalCoroutinesApi 103 | fun MainViewModel.setErrorStack(errorStack: ErrorStack){ 104 | this.errorStack.addAll(errorStack) 105 | } 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | -------------------------------------------------------------------------------- /app/src/main/java/com/codingwithmitch/espressodaggerexamples/ui/BlogPostListAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.codingwithmitch.espressodaggerexamples.ui 2 | 3 | import android.view.LayoutInflater 4 | import android.view.View 5 | import android.view.ViewGroup 6 | import androidx.recyclerview.widget.* 7 | import com.codingwithmitch.espressodaggerexamples.R 8 | import com.codingwithmitch.espressodaggerexamples.models.BlogPost 9 | import com.codingwithmitch.espressodaggerexamples.util.EspressoIdlingResource 10 | import com.codingwithmitch.espressodaggerexamples.util.GlideManager 11 | import com.codingwithmitch.espressodaggerexamples.util.GlideRequestManager 12 | import kotlinx.android.synthetic.main.layout_blog_list_item.view.* 13 | import kotlinx.coroutines.CoroutineScope 14 | import kotlinx.coroutines.Dispatchers.Main 15 | import kotlinx.coroutines.delay 16 | import kotlinx.coroutines.launch 17 | 18 | class BlogPostListAdapter( 19 | private val requestManager: GlideManager, 20 | private val interaction: Interaction? = null 21 | ) : 22 | RecyclerView.Adapter() { 23 | 24 | private val CLASS_NAME = "BlogPostListAdapter" 25 | 26 | val DIFF_CALLBACK = object : DiffUtil.ItemCallback() { 27 | 28 | override fun areItemsTheSame(oldItem: BlogPost, newItem: BlogPost): Boolean { 29 | return oldItem.pk == newItem.pk 30 | } 31 | 32 | override fun areContentsTheSame(oldItem: BlogPost, newItem: BlogPost): Boolean { 33 | return oldItem == newItem 34 | } 35 | 36 | } 37 | private val differ = AsyncListDiffer(this, DIFF_CALLBACK) 38 | 39 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { 40 | 41 | return BlogPostViewHolder( 42 | LayoutInflater.from(parent.context).inflate( 43 | R.layout.layout_blog_list_item, 44 | parent, 45 | false 46 | ), 47 | interaction, 48 | requestManager 49 | ) 50 | } 51 | 52 | override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { 53 | when (holder) { 54 | is BlogPostViewHolder -> { 55 | holder.bind(differ.currentList.get(position)) 56 | } 57 | } 58 | } 59 | 60 | override fun getItemCount(): Int { 61 | return differ.currentList.size 62 | } 63 | 64 | fun submitList(list: List) { 65 | val commitCallback = Runnable { 66 | 67 | /* 68 | if process died or nav back need to restore layoutmanager AFTER 69 | data is set... very annoying. 70 | Not sure why I need the delay... Can't figure this out. I've tested with lists 71 | 100x the size of this one and the 100ms delay works fine. 72 | */ 73 | CoroutineScope(Main).launch { 74 | delay(100) 75 | interaction?.restoreListPosition() 76 | } 77 | } 78 | 79 | differ.submitList(list, commitCallback) 80 | } 81 | 82 | class BlogPostViewHolder 83 | constructor( 84 | itemView: View, 85 | private val interaction: Interaction?, 86 | private val requestManager: GlideManager 87 | ) : RecyclerView.ViewHolder(itemView) { 88 | 89 | fun bind(item: BlogPost) = with(itemView) { 90 | itemView.setOnClickListener { 91 | interaction?.onItemSelected(adapterPosition, item) 92 | } 93 | requestManager 94 | .setImage(item.image, itemView.blog_image) 95 | itemView.blog_category.text = item.category 96 | itemView.blog_title.text = item.title 97 | } 98 | } 99 | 100 | interface Interaction { 101 | fun onItemSelected(position: Int, item: BlogPost) 102 | 103 | fun restoreListPosition() 104 | } 105 | } 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | -------------------------------------------------------------------------------- /app/src/main/res/layout/fragment_detail.xml: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 18 | 19 | 34 | 35 | 48 | 49 | 60 | 61 | 69 | 70 | 76 | 77 | 78 | 79 | 88 | 89 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 9 | 10 | 11 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | xmlns:android 20 | 21 | ^$ 22 | 23 | 24 | 25 |
26 |
27 | 28 | 29 | 30 | xmlns:.* 31 | 32 | ^$ 33 | 34 | 35 | BY_NAME 36 | 37 |
38 |
39 | 40 | 41 | 42 | .*:id 43 | 44 | http://schemas.android.com/apk/res/android 45 | 46 | 47 | 48 |
49 |
50 | 51 | 52 | 53 | .*:name 54 | 55 | http://schemas.android.com/apk/res/android 56 | 57 | 58 | 59 |
60 |
61 | 62 | 63 | 64 | name 65 | 66 | ^$ 67 | 68 | 69 | 70 |
71 |
72 | 73 | 74 | 75 | style 76 | 77 | ^$ 78 | 79 | 80 | 81 |
82 |
83 | 84 | 85 | 86 | .* 87 | 88 | ^$ 89 | 90 | 91 | BY_NAME 92 | 93 |
94 |
95 | 96 | 97 | 98 | .* 99 | 100 | http://schemas.android.com/apk/res/android 101 | 102 | 103 | ANDROID_ATTRIBUTE_ORDER 104 | 105 |
106 |
107 | 108 | 109 | 110 | .* 111 | 112 | .* 113 | 114 | 115 | BY_NAME 116 | 117 |
118 |
119 |
120 |
121 | 122 | 124 |
125 |
-------------------------------------------------------------------------------- /app/src/main/java/com/codingwithmitch/espressodaggerexamples/repository/MainRepositoryImpl.kt: -------------------------------------------------------------------------------- 1 | package com.codingwithmitch.espressodaggerexamples.repository 2 | 3 | import com.codingwithmitch.espressodaggerexamples.api.ApiService 4 | import com.codingwithmitch.espressodaggerexamples.models.BlogPost 5 | import com.codingwithmitch.espressodaggerexamples.models.Category 6 | import com.codingwithmitch.espressodaggerexamples.util.StateEvent 7 | import com.codingwithmitch.espressodaggerexamples.ui.viewmodel.state.MainViewState 8 | import com.codingwithmitch.espressodaggerexamples.ui.viewmodel.state.MainViewState.* 9 | import com.codingwithmitch.espressodaggerexamples.util.* 10 | import kotlinx.coroutines.Dispatchers.IO 11 | import kotlinx.coroutines.flow.Flow 12 | import kotlinx.coroutines.flow.flow 13 | import javax.inject.Inject 14 | import javax.inject.Singleton 15 | 16 | @Singleton 17 | class MainRepositoryImpl 18 | @Inject 19 | constructor( 20 | private val apiService: ApiService 21 | ) : MainRepository{ 22 | 23 | private val CLASS_NAME = "MainRepositoryImpl" 24 | 25 | override fun getBlogs(stateEvent: StateEvent, category: String): Flow> { 26 | return flow{ 27 | 28 | val response = safeApiCall(IO){apiService.getBlogPosts(category)} 29 | 30 | emit( 31 | object: ApiResponseHandler>( 32 | response = response, 33 | stateEvent = stateEvent 34 | ) { 35 | override fun handleSuccess(resultObj: List): DataState { 36 | return DataState.data( 37 | data = MainViewState( 38 | listFragmentView = ListFragmentView( 39 | blogs = resultObj 40 | ) 41 | ), 42 | stateEvent = stateEvent 43 | ) 44 | } 45 | 46 | }.result 47 | ) 48 | } 49 | } 50 | 51 | override fun getAllBlogs(stateEvent: StateEvent): Flow> { 52 | return flow{ 53 | 54 | val response = safeApiCall(IO){apiService.getAllBlogPosts()} 55 | 56 | emit( 57 | object: ApiResponseHandler>( 58 | response = response, 59 | stateEvent = stateEvent 60 | ) { 61 | override fun handleSuccess(resultObj: List): DataState { 62 | return DataState.data( 63 | data = MainViewState( 64 | listFragmentView = ListFragmentView( 65 | blogs = resultObj 66 | ) 67 | ), 68 | stateEvent = stateEvent 69 | ) 70 | } 71 | 72 | }.result 73 | ) 74 | } 75 | } 76 | 77 | override fun getCategories(stateEvent: StateEvent): Flow> { 78 | return flow{ 79 | 80 | val response = safeApiCall(IO){apiService.getCategories()} 81 | 82 | emit( 83 | object: ApiResponseHandler>( 84 | response = response, 85 | stateEvent = stateEvent 86 | ) { 87 | override fun handleSuccess(resultObj: List): DataState { 88 | return DataState.data( 89 | data = MainViewState( 90 | listFragmentView = ListFragmentView( 91 | categories = resultObj 92 | ) 93 | ), 94 | stateEvent = stateEvent 95 | ) 96 | } 97 | 98 | }.result 99 | ) 100 | } 101 | } 102 | 103 | 104 | } 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | -------------------------------------------------------------------------------- /app/src/androidTest/java/com/codingwithmitch/espressodaggerexamples/ui/DetailFragmentTest.kt: -------------------------------------------------------------------------------- 1 | package com.codingwithmitch.espressodaggerexamples.ui 2 | 3 | import androidx.fragment.app.testing.launchFragmentInContainer 4 | import androidx.test.espresso.Espresso.* 5 | import androidx.test.espresso.assertion.ViewAssertions.* 6 | import androidx.test.espresso.matcher.ViewMatchers 7 | import androidx.test.espresso.matcher.ViewMatchers.withText 8 | import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner 9 | import androidx.test.platform.app.InstrumentationRegistry 10 | import com.codingwithmitch.espressodaggerexamples.R 11 | import com.codingwithmitch.espressodaggerexamples.TestBaseApplication 12 | import com.codingwithmitch.espressodaggerexamples.di.TestAppComponent 13 | import com.codingwithmitch.espressodaggerexamples.fragments.FakeMainFragmentFactory 14 | import com.codingwithmitch.espressodaggerexamples.models.BlogPost 15 | import com.codingwithmitch.espressodaggerexamples.ui.viewmodel.setSelectedBlogPost 16 | import com.codingwithmitch.espressodaggerexamples.util.Constants.BLOG_POSTS_DATA_FILENAME 17 | import com.codingwithmitch.espressodaggerexamples.util.Constants.CATEGORIES_DATA_FILENAME 18 | import com.codingwithmitch.espressodaggerexamples.util.FakeGlideRequestManager 19 | import com.codingwithmitch.espressodaggerexamples.util.JsonUtil 20 | import com.codingwithmitch.espressodaggerexamples.viewmodels.FakeMainViewModelFactory 21 | import com.google.gson.Gson 22 | import com.google.gson.reflect.TypeToken 23 | import io.mockk.* 24 | import kotlinx.coroutines.* 25 | import org.junit.Before 26 | import org.junit.Test 27 | import org.junit.runner.RunWith 28 | import javax.inject.Inject 29 | 30 | @ExperimentalCoroutinesApi 31 | @InternalCoroutinesApi 32 | @RunWith(AndroidJUnit4ClassRunner::class) 33 | class DetailFragmentTest: BaseMainActivityTests() { 34 | 35 | private val CLASS_NAME = "DetailFragmentTest" 36 | 37 | @Inject 38 | lateinit var viewModelFactory: FakeMainViewModelFactory 39 | 40 | @Inject 41 | lateinit var requestManager: FakeGlideRequestManager 42 | 43 | @Inject 44 | lateinit var jsonUtil: JsonUtil 45 | 46 | @Inject 47 | lateinit var fragmentFactory: FakeMainFragmentFactory 48 | 49 | val uiCommunicationListener = mockk() 50 | 51 | @Before 52 | fun init(){ 53 | every { uiCommunicationListener.showStatusBar() } just runs 54 | every { uiCommunicationListener.expandAppBar() } just runs 55 | every { uiCommunicationListener.hideCategoriesMenu() } just runs 56 | } 57 | 58 | 59 | @Test 60 | fun is_selectedBlogPostDetailsSet() { 61 | 62 | val app = InstrumentationRegistry 63 | .getInstrumentation() 64 | .targetContext 65 | .applicationContext as TestBaseApplication 66 | 67 | val apiService = configureFakeApiService( 68 | blogsDataSource = BLOG_POSTS_DATA_FILENAME, 69 | categoriesDataSource = CATEGORIES_DATA_FILENAME, 70 | networkDelay = 0L, 71 | application = app 72 | ) 73 | 74 | configureFakeRepository(apiService, app) 75 | 76 | injectTest(app) 77 | 78 | fragmentFactory.uiCommunicationListener = uiCommunicationListener 79 | 80 | val scenario = launchFragmentInContainer( 81 | factory = fragmentFactory 82 | ) 83 | 84 | val rawJson = jsonUtil.readJSONFromAsset(BLOG_POSTS_DATA_FILENAME) 85 | val blogs = Gson().fromJson>( 86 | rawJson, 87 | object : TypeToken>() {}.type 88 | ) 89 | val selectedBlogPost = blogs.get(0) 90 | 91 | scenario.onFragment { fragment -> 92 | fragment.viewModel.setSelectedBlogPost(selectedBlogPost) 93 | } 94 | 95 | onView(ViewMatchers.withId(R.id.blog_title)) 96 | .check(matches(withText(selectedBlogPost.title))) 97 | 98 | onView(ViewMatchers.withId(R.id.blog_category)) 99 | .check(matches(withText(selectedBlogPost.category))) 100 | 101 | onView(ViewMatchers.withId(R.id.blog_body)) 102 | .check(matches(withText(selectedBlogPost.body))) 103 | } 104 | 105 | override fun injectTest(application: TestBaseApplication) { 106 | (application.appComponent as TestAppComponent) 107 | .inject(this) 108 | } 109 | 110 | } 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | -------------------------------------------------------------------------------- /app/src/main/java/com/codingwithmitch/espressodaggerexamples/ui/viewmodel/MainViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.codingwithmitch.espressodaggerexamples.ui.viewmodel 2 | 3 | import androidx.lifecycle.* 4 | import com.codingwithmitch.espressodaggerexamples.repository.MainRepository 5 | import com.codingwithmitch.espressodaggerexamples.ui.viewmodel.state.MainStateEvent 6 | import com.codingwithmitch.espressodaggerexamples.ui.viewmodel.state.MainStateEvent.* 7 | import com.codingwithmitch.espressodaggerexamples.ui.viewmodel.state.MainViewState 8 | import com.codingwithmitch.espressodaggerexamples.util.* 9 | import kotlinx.coroutines.ExperimentalCoroutinesApi 10 | import kotlinx.coroutines.FlowPreview 11 | import kotlinx.coroutines.InternalCoroutinesApi 12 | import kotlinx.coroutines.channels.ConflatedBroadcastChannel 13 | import kotlinx.coroutines.flow.Flow 14 | import kotlinx.coroutines.flow.asFlow 15 | import kotlinx.coroutines.flow.launchIn 16 | import kotlinx.coroutines.flow.onEach 17 | import javax.inject.Inject 18 | 19 | @UseExperimental(FlowPreview::class) 20 | @InternalCoroutinesApi 21 | @ExperimentalCoroutinesApi 22 | class MainViewModel 23 | @Inject 24 | constructor( 25 | private val mainRepository: MainRepository 26 | ) :ViewModel() { 27 | 28 | val CLASS_NAME = "MainViewModel" 29 | 30 | private val dataChannel = ConflatedBroadcastChannel>() 31 | 32 | private val _viewState: MutableLiveData = MutableLiveData() 33 | 34 | val errorStack = ErrorStack() 35 | 36 | val errorState: LiveData = errorStack.errorState 37 | 38 | val viewState: LiveData 39 | get() = _viewState 40 | 41 | init { 42 | setupChannel() 43 | } 44 | 45 | private fun setupChannel(){ 46 | dataChannel 47 | .asFlow() 48 | .onEach{ dataState -> 49 | dataState.data?.let { data -> 50 | handleNewData(dataState.stateEvent, data) 51 | } 52 | dataState.error?.let { error -> 53 | handleNewError(dataState.stateEvent, error) 54 | } 55 | } 56 | .launchIn(viewModelScope) 57 | } 58 | 59 | private fun offerToDataChannel(dataState: DataState){ 60 | if(!dataChannel.isClosedForSend){ 61 | dataChannel.offer(dataState) 62 | } 63 | } 64 | 65 | fun setStateEvent(stateEvent: MainStateEvent){ 66 | when(stateEvent){ 67 | is GetAllBlogs -> { 68 | launchJob( 69 | stateEvent, 70 | mainRepository.getAllBlogs(stateEvent) 71 | ) 72 | } 73 | 74 | is GetCategories -> { 75 | launchJob( 76 | stateEvent, 77 | mainRepository.getCategories(stateEvent) 78 | ) 79 | } 80 | 81 | is SearchBlogsByCategory -> { 82 | launchJob( 83 | stateEvent, 84 | mainRepository.getBlogs(stateEvent, stateEvent.category) 85 | ) 86 | } 87 | } 88 | } 89 | 90 | private fun handleNewError(stateEvent: StateEvent, error: ErrorState) { 91 | appendErrorState(error) 92 | removeJobFromCounter(stateEvent.toString()) 93 | } 94 | 95 | fun handleNewData(stateEvent: StateEvent, data: MainViewState){ 96 | 97 | data.listFragmentView.blogs?.let { blogs -> 98 | setBlogListData(blogs) 99 | } 100 | 101 | data.listFragmentView.categories?.let { categories -> 102 | setCategoriesData(categories) 103 | } 104 | 105 | data.detailFragmentView.selectedBlogPost?.let { blogPost -> 106 | setSelectedBlogPost(blogPost) 107 | } 108 | 109 | removeJobFromCounter(stateEvent.toString()) 110 | } 111 | 112 | private fun launchJob(stateEvent: StateEvent, jobFunction: Flow>){ 113 | if(!isJobAlreadyActive(stateEvent.toString())){ 114 | addJobToCounter(stateEvent.toString()) 115 | jobFunction 116 | .onEach { dataState -> 117 | offerToDataChannel(dataState) 118 | } 119 | .launchIn(viewModelScope) 120 | } 121 | } 122 | 123 | fun setViewState(viewState: MainViewState){ 124 | _viewState.value = viewState 125 | } 126 | 127 | 128 | } 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | -------------------------------------------------------------------------------- /app/src/androidTest/java/com/codingwithmitch/espressodaggerexamples/ui/ListFragmentNavigationTests.kt: -------------------------------------------------------------------------------- 1 | package com.codingwithmitch.espressodaggerexamples.ui 2 | 3 | import androidx.fragment.app.testing.launchFragmentInContainer 4 | import androidx.navigation.Navigation 5 | import androidx.navigation.testing.TestNavHostController 6 | import androidx.test.espresso.Espresso.onView 7 | import androidx.test.espresso.action.ViewActions.click 8 | import androidx.test.espresso.assertion.ViewAssertions.matches 9 | import androidx.test.espresso.contrib.RecyclerViewActions 10 | import androidx.test.espresso.matcher.ViewMatchers.* 11 | import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner 12 | import androidx.test.platform.app.InstrumentationRegistry 13 | import com.codingwithmitch.espressodaggerexamples.R 14 | import com.codingwithmitch.espressodaggerexamples.TestBaseApplication 15 | import com.codingwithmitch.espressodaggerexamples.di.TestAppComponent 16 | import com.codingwithmitch.espressodaggerexamples.fragments.FakeMainFragmentFactory 17 | import com.codingwithmitch.espressodaggerexamples.ui.BlogPostListAdapter.* 18 | import com.codingwithmitch.espressodaggerexamples.util.* 19 | import io.mockk.every 20 | import io.mockk.just 21 | import io.mockk.mockk 22 | import io.mockk.runs 23 | import junit.framework.Assert.assertEquals 24 | import kotlinx.coroutines.ExperimentalCoroutinesApi 25 | import kotlinx.coroutines.InternalCoroutinesApi 26 | import org.junit.Before 27 | import org.junit.FixMethodOrder 28 | import org.junit.Rule 29 | import org.junit.Test 30 | import org.junit.runner.RunWith 31 | import org.junit.runners.MethodSorters 32 | import javax.inject.Inject 33 | 34 | 35 | /** 36 | * Testing fragment navigation in isolation with Navigation Testing Artifact 37 | * https://developer.android.com/guide/navigation/navigation-testing 38 | */ 39 | @FixMethodOrder(MethodSorters.NAME_ASCENDING) 40 | @InternalCoroutinesApi 41 | @ExperimentalCoroutinesApi 42 | @RunWith(AndroidJUnit4ClassRunner::class) 43 | class ListFragmentNavigationTests : BaseMainActivityTests(){ 44 | 45 | 46 | private val CLASS_NAME = "ListFragmentNavigationTests" 47 | 48 | @get: Rule 49 | val espressoIdlingResourceRule = EspressoIdlingResourceRule() 50 | 51 | @Inject 52 | lateinit var fragmentFactory: FakeMainFragmentFactory 53 | 54 | val uiCommunicationListener = mockk() 55 | 56 | @Before 57 | fun init(){ 58 | every { uiCommunicationListener.showStatusBar() } just runs 59 | every { uiCommunicationListener.expandAppBar() } just runs 60 | every { uiCommunicationListener.hideCategoriesMenu() } just runs 61 | every { uiCommunicationListener.showCategoriesMenu(any()) } just runs 62 | } 63 | 64 | @Test 65 | fun testNavigationToDetailFragment() { 66 | val app = InstrumentationRegistry 67 | .getInstrumentation() 68 | .targetContext 69 | .applicationContext as TestBaseApplication 70 | 71 | val apiService = configureFakeApiService( 72 | blogsDataSource = Constants.BLOG_POSTS_DATA_FILENAME, 73 | categoriesDataSource = Constants.CATEGORIES_DATA_FILENAME, 74 | networkDelay = 0L, 75 | application = app 76 | ) 77 | 78 | configureFakeRepository(apiService, app) 79 | 80 | injectTest(app) 81 | 82 | fragmentFactory.uiCommunicationListener = uiCommunicationListener 83 | 84 | val navController = TestNavHostController(app) 85 | navController.setGraph(R.navigation.main_nav_graph) 86 | navController.setCurrentDestination(R.id.listFragment) 87 | 88 | val scenario = launchFragmentInContainer( 89 | factory = fragmentFactory 90 | ) 91 | 92 | scenario.onFragment { fragment -> 93 | Navigation.setViewNavController(fragment.requireView(), navController) 94 | } 95 | 96 | val recyclerView = onView(withId(R.id.recycler_view)) 97 | recyclerView.check(matches(isDisplayed())) 98 | 99 | recyclerView.perform( 100 | RecyclerViewActions.scrollToPosition(5) 101 | ) 102 | 103 | recyclerView.perform( 104 | RecyclerViewActions.actionOnItemAtPosition(5, click()) 105 | ) 106 | 107 | assertEquals(navController.currentDestination?.id, R.id.detailFragment) 108 | } 109 | 110 | override fun injectTest(application: TestBaseApplication) { 111 | (application.appComponent as TestAppComponent) 112 | .inject(this) 113 | } 114 | } 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | -------------------------------------------------------------------------------- /app/src/androidTest/java/com/codingwithmitch/espressodaggerexamples/ui/MainNavigationTests.kt: -------------------------------------------------------------------------------- 1 | package com.codingwithmitch.espressodaggerexamples.ui 2 | 3 | import androidx.test.core.app.launchActivity 4 | import androidx.test.espresso.Espresso.* 5 | import androidx.test.espresso.action.ViewActions.click 6 | import androidx.test.espresso.assertion.ViewAssertions.* 7 | import androidx.test.espresso.contrib.RecyclerViewActions 8 | import androidx.test.espresso.matcher.ViewMatchers.* 9 | import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner 10 | import androidx.test.platform.app.InstrumentationRegistry 11 | import com.codingwithmitch.espressodaggerexamples.R 12 | import com.codingwithmitch.espressodaggerexamples.TestBaseApplication 13 | import com.codingwithmitch.espressodaggerexamples.di.TestAppComponent 14 | import com.codingwithmitch.espressodaggerexamples.models.BlogPost 15 | import com.codingwithmitch.espressodaggerexamples.ui.BlogPostListAdapter.* 16 | import com.codingwithmitch.espressodaggerexamples.util.Constants 17 | import com.codingwithmitch.espressodaggerexamples.util.EspressoIdlingResourceRule 18 | import com.codingwithmitch.espressodaggerexamples.util.JsonUtil 19 | import com.google.gson.Gson 20 | import com.google.gson.reflect.TypeToken 21 | import kotlinx.coroutines.ExperimentalCoroutinesApi 22 | import kotlinx.coroutines.InternalCoroutinesApi 23 | import org.junit.FixMethodOrder 24 | import org.junit.Rule 25 | import org.junit.Test 26 | import org.junit.runner.RunWith 27 | import org.junit.runners.MethodSorters 28 | import javax.inject.Inject 29 | 30 | /** 31 | * Test the overall navigation with NavController 32 | * ListFragment -> DetailFragment -> FinalFragment 33 | */ 34 | @FixMethodOrder(MethodSorters.NAME_ASCENDING) 35 | @InternalCoroutinesApi 36 | @ExperimentalCoroutinesApi 37 | @RunWith(AndroidJUnit4ClassRunner::class) 38 | class MainNavigationTests : BaseMainActivityTests(){ 39 | 40 | @get: Rule 41 | val espressoIdlingResourceRule = EspressoIdlingResourceRule() 42 | 43 | @Inject 44 | lateinit var jsonUtil: JsonUtil 45 | 46 | @Test 47 | fun basicNavigationTest(){ 48 | 49 | val app = InstrumentationRegistry 50 | .getInstrumentation() 51 | .targetContext 52 | .applicationContext as TestBaseApplication 53 | 54 | val apiService = configureFakeApiService( 55 | blogsDataSource = Constants.BLOG_POSTS_DATA_FILENAME, 56 | categoriesDataSource = Constants.CATEGORIES_DATA_FILENAME, 57 | networkDelay = 0L, 58 | application = app 59 | ) 60 | 61 | configureFakeRepository(apiService, app) 62 | 63 | injectTest(app) 64 | 65 | val rawJson = jsonUtil.readJSONFromAsset(Constants.BLOG_POSTS_DATA_FILENAME) 66 | val blogs = Gson().fromJson>( 67 | rawJson, 68 | object : TypeToken>() {}.type 69 | ) 70 | val SELECTED_LIST_INDEX = 8 // chose 8 so the app has to scroll 71 | val selectedBlogPost = blogs.get(SELECTED_LIST_INDEX) 72 | 73 | val scenario = launchActivity() 74 | 75 | onView(withId(R.id.recycler_view)).check(matches(isDisplayed())) 76 | 77 | onView(withId(R.id.recycler_view)).perform( 78 | RecyclerViewActions.scrollToPosition(SELECTED_LIST_INDEX) 79 | ) 80 | 81 | // Nav DetailFragment 82 | onView(withId(R.id.recycler_view)).perform( 83 | RecyclerViewActions.actionOnItemAtPosition(SELECTED_LIST_INDEX, click()) 84 | ) 85 | 86 | onView(withId(R.id.blog_title)).check(matches(withText(selectedBlogPost.title))) 87 | 88 | onView(withId(R.id.blog_body)).check(matches(withText(selectedBlogPost.body))) 89 | 90 | onView(withId(R.id.blog_category)).check(matches(withText(selectedBlogPost.category))) 91 | 92 | // Nav FinalFragment 93 | onView(withId(R.id.blog_image)).perform(click()) 94 | 95 | onView(withId(R.id.scaling_image_view)).check(matches(isDisplayed())) 96 | 97 | // Back to DetailFragment 98 | pressBack() 99 | 100 | onView(withId(R.id.blog_title)).check(matches(withText(selectedBlogPost.title))) 101 | 102 | // Back to ListFragment 103 | pressBack() 104 | 105 | onView(withId(R.id.recycler_view)).check(matches(isDisplayed())) 106 | 107 | } 108 | 109 | override fun injectTest(application: TestBaseApplication) { 110 | (application.appComponent as TestAppComponent) 111 | .inject(this) 112 | } 113 | } 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.application' 2 | 3 | apply plugin: 'kotlin-android' 4 | 5 | apply plugin: 'kotlin-android-extensions' 6 | 7 | apply plugin: 'kotlin-kapt' 8 | 9 | android { 10 | compileSdkVersion 29 11 | buildToolsVersion "29.0.2" 12 | defaultConfig { 13 | applicationId "com.codingwithmitch.espressodaggerexamples" 14 | minSdkVersion 21 15 | targetSdkVersion 29 16 | versionCode 1 17 | versionName "1.0" 18 | testInstrumentationRunner "com.codingwithmitch.espressodaggerexamples.MockTestRunner" 19 | 20 | // clear state after each individual test 21 | testInstrumentationRunnerArguments clearPackageData: 'true' 22 | } 23 | buildTypes { 24 | release { 25 | minifyEnabled false 26 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 27 | } 28 | } 29 | 30 | compileOptions { 31 | sourceCompatibility = '1.8' 32 | targetCompatibility = '1.8' 33 | } 34 | 35 | tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all { 36 | kotlinOptions { 37 | jvmTarget = "1.8" 38 | } 39 | } 40 | 41 | testOptions { 42 | execution 'ANDROIDX_TEST_ORCHESTRATOR' 43 | } 44 | 45 | } 46 | 47 | dependencies { 48 | implementation fileTree(dir: 'libs', include: ['*.jar']) 49 | implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" 50 | implementation 'androidx.appcompat:appcompat:1.1.0' 51 | implementation 'androidx.constraintlayout:constraintlayout:1.1.3' 52 | testImplementation 'junit:junit:4.12' 53 | 54 | // orchestrator 55 | def orchestrator_version = "1.2.0" 56 | androidTestUtil "androidx.test:orchestrator:$orchestrator_version" 57 | 58 | // Kotlin test 59 | androidTestImplementation "org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version" 60 | 61 | // Espresso 62 | def androidx_espresso_core = "3.1.1" 63 | androidTestImplementation "androidx.test.espresso:espresso-core:$androidx_espresso_core" 64 | androidTestImplementation "androidx.test.espresso:espresso-contrib:$androidx_espresso_core" 65 | 66 | def androidx_espresso_idling_resource = "3.2.0" 67 | androidTestImplementation "androidx.test.espresso:espresso-idling-resource:$androidx_espresso_idling_resource" 68 | implementation "androidx.test.espresso:espresso-idling-resource:$androidx_espresso_idling_resource" 69 | 70 | // Mockk.io 71 | def mockk_version = "1.9.2" 72 | //def mockk_version = "1.9.3" // had issues with this 73 | androidTestImplementation "io.mockk:mockk-android:$mockk_version" 74 | 75 | // androidx.test 76 | def androidx_test_runner = "1.2.0" 77 | androidTestImplementation "androidx.test:runner:$androidx_test_runner" 78 | androidTestImplementation "androidx.test:rules:$androidx_test_runner" 79 | 80 | def androidx_test_core = "1.2.0" 81 | androidTestImplementation "androidx.test:core-ktx:$androidx_test_core" 82 | 83 | 84 | def androidx_test_ext = "1.1.1" 85 | androidTestImplementation "androidx.test.ext:junit-ktx:$androidx_test_ext" 86 | 87 | // androidx.fragment 88 | def fragment_version = "1.2.0" 89 | debugImplementation "androidx.fragment:fragment-testing:$fragment_version" 90 | implementation "androidx.fragment:fragment-ktx:$fragment_version" 91 | 92 | // -- Lifecycle Components (ViewModel, LiveData) 93 | def lifecycle_version = "2.2.0-alpha03" 94 | implementation "androidx.lifecycle:lifecycle-runtime:$lifecycle_version" 95 | 96 | // Dagger 97 | def dagger_version = "2.25.4" 98 | implementation "com.google.dagger:dagger:$dagger_version" 99 | kapt "com.google.dagger:dagger-compiler:$dagger_version" 100 | kaptAndroidTest "com.google.dagger:dagger-compiler:$dagger_version" 101 | 102 | // jetpack navigation components 103 | def nav_version = "2.3.0-alpha02" 104 | implementation "androidx.navigation:navigation-fragment-ktx:$nav_version" 105 | implementation "androidx.navigation:navigation-ui-ktx:$nav_version" 106 | implementation "androidx.navigation:navigation-runtime:$nav_version" 107 | // Navigation testing artifact 108 | androidTestImplementation "androidx.navigation:navigation-testing:$nav_version" 109 | 110 | // -- Retrofit2 111 | def retrofit2_version = "2.6.0" 112 | implementation "com.squareup.retrofit2:retrofit:$retrofit2_version" 113 | implementation "com.squareup.retrofit2:converter-gson:$retrofit2_version" 114 | 115 | //glide 116 | def glide_version = "4.9.0" 117 | implementation "com.github.bumptech.glide:glide:$glide_version" 118 | kapt "com.github.bumptech.glide:compiler:$glide_version" 119 | 120 | // Recyclerview 121 | def recyclerview_version = "1.1.0-beta03" 122 | implementation "androidx.recyclerview:recyclerview:$recyclerview_version" 123 | 124 | // material dialogs 125 | def matieral_dialogs_version = "3.1.0" 126 | implementation "com.afollestad.material-dialogs:core:$matieral_dialogs_version" 127 | } 128 | -------------------------------------------------------------------------------- /app/src/androidTest/java/com/codingwithmitch/espressodaggerexamples/repository/FakeMainRepositoryImpl.kt: -------------------------------------------------------------------------------- 1 | package com.codingwithmitch.espressodaggerexamples.repository 2 | 3 | import com.codingwithmitch.espressodaggerexamples.api.FakeApiService 4 | import com.codingwithmitch.espressodaggerexamples.models.BlogPost 5 | import com.codingwithmitch.espressodaggerexamples.models.Category 6 | import com.codingwithmitch.espressodaggerexamples.ui.viewmodel.state.MainViewState 7 | import com.codingwithmitch.espressodaggerexamples.util.ApiResponseHandler 8 | import com.codingwithmitch.espressodaggerexamples.util.DataState 9 | import com.codingwithmitch.espressodaggerexamples.util.StateEvent 10 | import kotlinx.coroutines.Dispatchers 11 | import kotlinx.coroutines.flow.Flow 12 | import kotlinx.coroutines.flow.flow 13 | import javax.inject.Inject 14 | import javax.inject.Singleton 15 | 16 | 17 | /** 18 | * The only difference between this and the real MainRepositoryImpl is the ApiService is 19 | * fake and it's not being injected so I can change it at runtime. 20 | * That way I can alter the FakeApiService for each individual test. 21 | */ 22 | @Singleton 23 | class FakeMainRepositoryImpl 24 | @Inject 25 | constructor(): MainRepository{ 26 | 27 | private val CLASS_NAME: String = "FakeMainRepositoryImpl" 28 | 29 | lateinit var apiService: FakeApiService 30 | 31 | private fun throwExceptionIfApiServiceNotInitialzied(){ 32 | if(!::apiService.isInitialized){ 33 | throw UninitializedPropertyAccessException( 34 | "Did you forget to set the ApiService in FakeMainRepositoryImpl?" 35 | ) 36 | } 37 | } 38 | 39 | @Throws(UninitializedPropertyAccessException::class) 40 | override fun getBlogs(stateEvent: StateEvent, category: String): Flow> { 41 | throwExceptionIfApiServiceNotInitialzied() 42 | return flow{ 43 | 44 | val response = safeApiCall(Dispatchers.IO){apiService.getBlogPosts(category)} 45 | 46 | emit( 47 | object: ApiResponseHandler>( 48 | response = response, 49 | stateEvent = stateEvent 50 | ) { 51 | override fun handleSuccess(resultObj: List): DataState { 52 | return DataState.data( 53 | data = MainViewState( 54 | listFragmentView = MainViewState.ListFragmentView( 55 | blogs = resultObj 56 | ) 57 | ), 58 | stateEvent = stateEvent 59 | ) 60 | } 61 | 62 | }.result 63 | ) 64 | } 65 | } 66 | 67 | @Throws(UninitializedPropertyAccessException::class) 68 | override fun getAllBlogs(stateEvent: StateEvent): Flow> { 69 | throwExceptionIfApiServiceNotInitialzied() 70 | return flow{ 71 | 72 | val response = safeApiCall(Dispatchers.IO){apiService.getAllBlogPosts()} 73 | 74 | emit( 75 | object: ApiResponseHandler>( 76 | response = response, 77 | stateEvent = stateEvent 78 | ) { 79 | override fun handleSuccess(resultObj: List): DataState { 80 | return DataState.data( 81 | data = MainViewState( 82 | listFragmentView = MainViewState.ListFragmentView( 83 | blogs = resultObj 84 | ) 85 | ), 86 | stateEvent = stateEvent 87 | ) 88 | } 89 | 90 | }.result 91 | ) 92 | } 93 | } 94 | 95 | @Throws(UninitializedPropertyAccessException::class) 96 | override fun getCategories(stateEvent: StateEvent): Flow> { 97 | throwExceptionIfApiServiceNotInitialzied() 98 | return flow{ 99 | 100 | val response = safeApiCall(Dispatchers.IO){apiService.getCategories()} 101 | 102 | emit( 103 | object: ApiResponseHandler>( 104 | response = response, 105 | stateEvent = stateEvent 106 | ) { 107 | override fun handleSuccess(resultObj: List): DataState { 108 | return DataState.data( 109 | data = MainViewState( 110 | listFragmentView = MainViewState.ListFragmentView( 111 | categories = resultObj 112 | ) 113 | ), 114 | stateEvent = stateEvent 115 | ) 116 | } 117 | 118 | }.result 119 | ) 120 | } 121 | } 122 | 123 | 124 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/com/codingwithmitch/espressodaggerexamples/ui/ListFragmentErrorTests.kt: -------------------------------------------------------------------------------- 1 | package com.codingwithmitch.espressodaggerexamples.ui 2 | 3 | import androidx.test.core.app.launchActivity 4 | import androidx.test.espresso.Espresso.onView 5 | import androidx.test.espresso.assertion.ViewAssertions.matches 6 | import androidx.test.espresso.matcher.ViewMatchers.* 7 | import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner 8 | import androidx.test.platform.app.InstrumentationRegistry 9 | import com.codingwithmitch.espressodaggerexamples.R 10 | import com.codingwithmitch.espressodaggerexamples.TestBaseApplication 11 | import com.codingwithmitch.espressodaggerexamples.di.TestAppComponent 12 | import com.codingwithmitch.espressodaggerexamples.ui.viewmodel.state.MainStateEvent 13 | import com.codingwithmitch.espressodaggerexamples.util.Constants 14 | import com.codingwithmitch.espressodaggerexamples.util.Constants.BLOG_POSTS_DATA_FILENAME 15 | import com.codingwithmitch.espressodaggerexamples.util.Constants.CATEGORIES_DATA_FILENAME 16 | import com.codingwithmitch.espressodaggerexamples.util.Constants.SERVER_ERROR_FILENAME 17 | import com.codingwithmitch.espressodaggerexamples.util.EspressoIdlingResourceRule 18 | import kotlinx.coroutines.ExperimentalCoroutinesApi 19 | import kotlinx.coroutines.InternalCoroutinesApi 20 | import org.junit.FixMethodOrder 21 | import org.junit.Rule 22 | import org.junit.Test 23 | import org.junit.runner.RunWith 24 | import org.junit.runners.MethodSorters 25 | 26 | /** 27 | * Separate class for the error testing because because the error dialogs 28 | * are shown in MainActivity. 29 | * (ActivityScenario, not FragmentScenario.) 30 | */ 31 | @FixMethodOrder(MethodSorters.NAME_ASCENDING) 32 | @InternalCoroutinesApi 33 | @ExperimentalCoroutinesApi 34 | @RunWith(AndroidJUnit4ClassRunner::class) 35 | class ListFragmentErrorTests: BaseMainActivityTests() { 36 | 37 | private val CLASS_NAME = "ListFragmentErrorTests" 38 | 39 | @get: Rule 40 | val espressoIdlingResourceRule = EspressoIdlingResourceRule() 41 | 42 | @Test 43 | fun isErrorDialogShown_UnknownError() { 44 | val app = InstrumentationRegistry 45 | .getInstrumentation() 46 | .targetContext 47 | .applicationContext as TestBaseApplication 48 | 49 | val apiService = configureFakeApiService( 50 | blogsDataSource = SERVER_ERROR_FILENAME, // force "Unknown error" 51 | categoriesDataSource = CATEGORIES_DATA_FILENAME, 52 | networkDelay = 0L, 53 | application = app 54 | ) 55 | 56 | configureFakeRepository(apiService, app) 57 | 58 | injectTest(app) 59 | 60 | val scenario = launchActivity() 61 | 62 | onView(withText(R.string.text_error)).check(matches(isDisplayed())) 63 | 64 | onView(withSubstring(Constants.UNKNOWN_ERROR)).check(matches(isDisplayed())) 65 | } 66 | 67 | 68 | @Test 69 | fun doesNetworkTimeout_networkTimeoutError() { 70 | 71 | val app = InstrumentationRegistry 72 | .getInstrumentation() 73 | .targetContext 74 | .applicationContext as TestBaseApplication 75 | 76 | val apiService = configureFakeApiService( 77 | blogsDataSource = BLOG_POSTS_DATA_FILENAME, 78 | categoriesDataSource = CATEGORIES_DATA_FILENAME, 79 | networkDelay = 4000L, // force timeout (4000 > 3000) 80 | application = app 81 | ) 82 | 83 | configureFakeRepository(apiService, app) 84 | 85 | injectTest(app) 86 | 87 | val scenario = launchActivity() 88 | 89 | onView(withText(R.string.text_error)) 90 | .check(matches(isDisplayed())) 91 | 92 | onView(withSubstring(Constants.NETWORK_ERROR_TIMEOUT)) 93 | .check(matches(isDisplayed())) 94 | 95 | } 96 | 97 | @Test 98 | fun isErrorDialogShown_CannotRetrieveCategories() { 99 | val app = InstrumentationRegistry 100 | .getInstrumentation() 101 | .targetContext 102 | .applicationContext as TestBaseApplication 103 | 104 | val apiService = configureFakeApiService( 105 | blogsDataSource = BLOG_POSTS_DATA_FILENAME, 106 | categoriesDataSource = SERVER_ERROR_FILENAME, // force error 107 | networkDelay = 0L, 108 | application = app 109 | ) 110 | 111 | configureFakeRepository(apiService, app) 112 | 113 | injectTest(app) 114 | 115 | val scenario = launchActivity() 116 | 117 | onView(withText(R.string.text_error)).check(matches(isDisplayed())) 118 | 119 | onView(withSubstring(MainStateEvent.GetCategories().errorInfo())) 120 | .check(matches(isDisplayed())) 121 | } 122 | 123 | @Test 124 | fun isErrorDialogShown_CannotRetrieveBlogPosts() { 125 | val app = InstrumentationRegistry 126 | .getInstrumentation() 127 | .targetContext 128 | .applicationContext as TestBaseApplication 129 | 130 | val apiService = configureFakeApiService( 131 | blogsDataSource = SERVER_ERROR_FILENAME, // force error 132 | categoriesDataSource = CATEGORIES_DATA_FILENAME, 133 | networkDelay = 0L, 134 | application = app 135 | ) 136 | 137 | configureFakeRepository(apiService, app) 138 | 139 | injectTest(app) 140 | 141 | val scenario = launchActivity() 142 | 143 | onView(withText(R.string.text_error)).check(matches(isDisplayed())) 144 | 145 | onView(withSubstring(MainStateEvent.GetAllBlogs().errorInfo())) 146 | .check(matches(isDisplayed())) 147 | } 148 | 149 | override fun injectTest(application: TestBaseApplication){ 150 | (application.appComponent as TestAppComponent) 151 | .inject(this) 152 | } 153 | 154 | } 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | -------------------------------------------------------------------------------- /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 | 7 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 110 | 115 | 120 | 125 | 130 | 135 | 140 | 145 | 150 | 155 | 160 | 165 | 170 | 171 | -------------------------------------------------------------------------------- /app/src/main/java/com/codingwithmitch/espressodaggerexamples/ui/ListFragment.kt: -------------------------------------------------------------------------------- 1 | package com.codingwithmitch.espressodaggerexamples.ui 2 | 3 | 4 | import android.content.Context 5 | import android.os.Bundle 6 | import android.util.Log 7 | import android.view.View 8 | import androidx.fragment.app.Fragment 9 | import androidx.fragment.app.activityViewModels 10 | import androidx.lifecycle.Observer 11 | import androidx.lifecycle.ViewModelProvider 12 | import androidx.navigation.fragment.findNavController 13 | import androidx.recyclerview.widget.LinearLayoutManager 14 | import androidx.swiperefreshlayout.widget.SwipeRefreshLayout 15 | import com.codingwithmitch.espressodaggerexamples.R 16 | import com.codingwithmitch.espressodaggerexamples.fragments.MainNavHostFragment 17 | import com.codingwithmitch.espressodaggerexamples.models.BlogPost 18 | import com.codingwithmitch.espressodaggerexamples.ui.viewmodel.* 19 | import com.codingwithmitch.espressodaggerexamples.ui.viewmodel.state.MainStateEvent.* 20 | import com.codingwithmitch.espressodaggerexamples.ui.viewmodel.state.MainViewState 21 | import com.codingwithmitch.espressodaggerexamples.util.* 22 | import kotlinx.android.synthetic.main.fragment_list.* 23 | import kotlinx.coroutines.* 24 | import kotlinx.coroutines.Dispatchers.Main 25 | import java.lang.Exception 26 | import javax.inject.Inject 27 | import javax.inject.Singleton 28 | 29 | @ExperimentalCoroutinesApi 30 | @InternalCoroutinesApi 31 | class ListFragment 32 | constructor( 33 | private val viewModelFactory: ViewModelProvider.Factory, 34 | private val requestManager: GlideManager 35 | ) : Fragment(R.layout.fragment_list), 36 | BlogPostListAdapter.Interaction, 37 | SwipeRefreshLayout.OnRefreshListener 38 | { 39 | 40 | private val CLASS_NAME = "ListFragment" 41 | 42 | lateinit var uiCommunicationListener: UICommunicationListener 43 | 44 | lateinit var listAdapter: BlogPostListAdapter 45 | 46 | val viewModel: MainViewModel by activityViewModels { 47 | viewModelFactory 48 | } 49 | 50 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 51 | super.onViewCreated(view, savedInstanceState) 52 | swipe_refresh.setOnRefreshListener(this) 53 | initRecyclerView() 54 | subscribeObservers() 55 | initData() 56 | } 57 | 58 | override fun onPause() { 59 | super.onPause() 60 | saveLayoutManagerState() 61 | } 62 | 63 | private fun saveLayoutManagerState(){ 64 | recycler_view.layoutManager?.onSaveInstanceState()?.let { lmState -> 65 | viewModel.setLayoutManagerState(lmState) 66 | } 67 | } 68 | 69 | fun restoreLayoutManager() { 70 | viewModel.getLayoutManagerState()?.let { lmState -> 71 | recycler_view?.layoutManager?.onRestoreInstanceState(lmState) 72 | } 73 | } 74 | 75 | private fun initData(){ 76 | val viewState = viewModel.getCurrentViewStateOrNew() 77 | if(viewState.listFragmentView.blogs == null 78 | || viewState.listFragmentView.categories == null){ 79 | viewModel.setStateEvent(GetAllBlogs()) 80 | viewModel.setStateEvent(GetCategories()) 81 | } 82 | } 83 | 84 | /* 85 | I'm creating an observer in this fragment b/c I want more control 86 | over it. When a blog is selected I immediately stop observing. 87 | Mainly for hiding the menu in DetailFragment. 88 | "uiCommunicationListener.hideCategoriesMenu()" 89 | */ 90 | val observer: Observer = Observer { viewState -> 91 | if(viewState != null){ 92 | 93 | viewState.listFragmentView.let{ view -> 94 | view.blogs?.let { blogs -> 95 | listAdapter.apply { 96 | submitList(blogs) 97 | } 98 | displayTheresNothingHereTV((blogs.size > 0)) 99 | } 100 | view.categories?.let { categories -> 101 | uiCommunicationListener.showCategoriesMenu( 102 | categories = ArrayList(categories) 103 | ) 104 | } 105 | } 106 | } 107 | } 108 | 109 | private fun displayTheresNothingHereTV(isDataAvailable: Boolean){ 110 | if(isDataAvailable){ 111 | no_data_textview.visibility = View.GONE 112 | } 113 | else{ 114 | no_data_textview.visibility = View.VISIBLE 115 | } 116 | } 117 | 118 | private fun subscribeObservers(){ 119 | viewModel.viewState.observe(viewLifecycleOwner, observer) 120 | } 121 | 122 | override fun onRefresh() { 123 | initData() 124 | swipe_refresh.isRefreshing = false 125 | } 126 | 127 | private fun initRecyclerView(){ 128 | recycler_view.apply { 129 | layoutManager = LinearLayoutManager(this@ListFragment.context) 130 | addItemDecoration(TopSpacingItemDecoration(30)) 131 | listAdapter = BlogPostListAdapter(requestManager, this@ListFragment) 132 | adapter = listAdapter 133 | } 134 | } 135 | 136 | override fun restoreListPosition() { 137 | restoreLayoutManager() 138 | } 139 | 140 | override fun onItemSelected(position: Int, item: BlogPost) { 141 | removeViewStateObserver() 142 | viewModel.setSelectedBlogPost(blogPost = item) 143 | findNavController().navigate(R.id.action_listFragment_to_detailFragment) 144 | } 145 | 146 | private fun removeViewStateObserver(){ 147 | viewModel.viewState.removeObserver(observer) 148 | } 149 | 150 | override fun onAttach(context: Context) { 151 | super.onAttach(context) 152 | setUICommunicationListener(null) 153 | } 154 | 155 | 156 | fun setUICommunicationListener(mockUICommuncationListener: UICommunicationListener?){ 157 | 158 | // TEST: Set interface from mock 159 | if(mockUICommuncationListener != null){ 160 | this.uiCommunicationListener = mockUICommuncationListener 161 | } 162 | else{ // PRODUCTION: if no mock, get from context 163 | try { 164 | uiCommunicationListener = (context as UICommunicationListener) 165 | }catch (e: Exception){ 166 | Log.e(CLASS_NAME, "$context must implement UICommunicationListener") 167 | } 168 | } 169 | } 170 | 171 | } 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | -------------------------------------------------------------------------------- /app/src/main/java/com/codingwithmitch/espressodaggerexamples/ui/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.codingwithmitch.espressodaggerexamples.ui 2 | 3 | import androidx.appcompat.app.AppCompatActivity 4 | import android.os.Bundle 5 | import android.view.MenuItem 6 | import android.view.View 7 | import androidx.activity.viewModels 8 | import androidx.fragment.app.Fragment 9 | import androidx.lifecycle.Observer 10 | import androidx.lifecycle.ViewModelProvider 11 | import androidx.navigation.findNavController 12 | import androidx.navigation.ui.setupWithNavController 13 | import com.afollestad.materialdialogs.MaterialDialog 14 | import com.codingwithmitch.espressodaggerexamples.BaseApplication 15 | import com.codingwithmitch.espressodaggerexamples.R 16 | import com.codingwithmitch.espressodaggerexamples.fragments.MainNavHostFragment 17 | import com.codingwithmitch.espressodaggerexamples.models.Category 18 | import com.codingwithmitch.espressodaggerexamples.ui.viewmodel.* 19 | import com.codingwithmitch.espressodaggerexamples.ui.viewmodel.state.MAIN_VIEW_STATE_BUNDLE_KEY 20 | import com.codingwithmitch.espressodaggerexamples.ui.viewmodel.state.MainStateEvent.* 21 | import com.codingwithmitch.espressodaggerexamples.ui.viewmodel.state.MainViewState 22 | import com.codingwithmitch.espressodaggerexamples.util.* 23 | import com.google.android.material.appbar.AppBarLayout 24 | import kotlinx.android.synthetic.main.activity_main.* 25 | import kotlinx.coroutines.* 26 | import javax.inject.Inject 27 | 28 | @ExperimentalCoroutinesApi 29 | @InternalCoroutinesApi 30 | class MainActivity : AppCompatActivity(), 31 | UICommunicationListener 32 | { 33 | 34 | private val CLASS_NAME = "MainActivity" 35 | 36 | @Inject 37 | lateinit var viewModelFactory: ViewModelProvider.Factory 38 | 39 | val viewModel: MainViewModel by viewModels { 40 | viewModelFactory 41 | } 42 | 43 | // keep reference of dialogs for dismissing if activity destroyed 44 | // also prevent recreation of same dialog when activity recreated 45 | private val dialogs: HashMap = HashMap() 46 | 47 | override fun onCreate(savedInstanceState: Bundle?) { 48 | (application as BaseApplication).appComponent 49 | .inject(this) 50 | super.onCreate(savedInstanceState) 51 | setContentView(R.layout.activity_main) 52 | 53 | setupActionBar() 54 | 55 | subscribeObservers() 56 | 57 | restoreInstanceState(savedInstanceState) 58 | } 59 | 60 | private fun restoreInstanceState(savedInstanceState: Bundle?){ 61 | savedInstanceState?.let { inState -> 62 | (inState[MAIN_VIEW_STATE_BUNDLE_KEY] as MainViewState?)?.let { viewState -> 63 | viewModel.setViewState(viewState) 64 | } 65 | (inState[ERROR_STACK_BUNDLE_KEY] as ArrayList?)?.let { stack -> 66 | val errorStack = ErrorStack() 67 | errorStack.addAll(stack) 68 | viewModel.setErrorStack(errorStack) 69 | } 70 | } 71 | } 72 | 73 | override fun onSaveInstanceState(outState: Bundle) { 74 | viewModel.clearActiveJobCounter() 75 | outState.putParcelable( 76 | MAIN_VIEW_STATE_BUNDLE_KEY, 77 | viewModel.getCurrentViewStateOrNew() 78 | ) 79 | outState.putParcelableArrayList( 80 | ERROR_STACK_BUNDLE_KEY, 81 | viewModel.errorStack 82 | ) 83 | super.onSaveInstanceState(outState) 84 | } 85 | 86 | private fun subscribeObservers(){ 87 | viewModel.viewState.observe(this, Observer { viewState -> 88 | if(viewState != null){ 89 | // uiCommunicationListener.displayMainProgressBar(viewModel.areAnyJobsActive()) 90 | displayMainProgressBar(viewModel.areAnyJobsActive()) 91 | } 92 | }) 93 | 94 | viewModel.errorState.observe(this, Observer { errorState -> 95 | errorState?.let { 96 | displayErrorMessage(errorState) 97 | } 98 | }) 99 | } 100 | 101 | private fun displayErrorMessage(errorState: ErrorState) { 102 | if(!dialogs.containsKey(errorState.message)){ 103 | dialogs.put( 104 | errorState.message, 105 | displayErrorDialog(errorState.message, object: ErrorDialogCallback{ 106 | override fun clearError() { 107 | viewModel.clearError(0) 108 | } 109 | }) 110 | ) 111 | } 112 | } 113 | 114 | private fun setupActionBar() { 115 | tool_bar.setupWithNavController( 116 | findNavController(R.id.nav_host_fragment) 117 | ) 118 | } 119 | 120 | private fun onMenuItemSelected(categories: List, menuItem: MenuItem): Boolean{ 121 | for(category in categories){ 122 | if(category.pk == menuItem.itemId){ 123 | viewModel.clearLayoutManagerState() 124 | if(category.category_name.equals(MENU_ITEM_NAME_GET_ALL_BLOGS)){ 125 | viewModel.setStateEvent(GetAllBlogs()) 126 | }else{ 127 | viewModel.setStateEvent(SearchBlogsByCategory(category.category_name)) 128 | } 129 | return true 130 | } 131 | } 132 | return false 133 | } 134 | 135 | override fun showCategoriesMenu(categories: ArrayList) { 136 | printLogD(CLASS_NAME, "showCategoriesMenu: ${categories}") 137 | val menu = tool_bar.menu 138 | menu.clear() 139 | categories.add(Category(MENU_ITEM_ID_GET_ALL_BLOGS, MENU_ITEM_NAME_GET_ALL_BLOGS)) 140 | for((index, category) in categories.withIndex()){ 141 | menu.add(0, category.pk , index, category.category_name) 142 | } 143 | tool_bar.invalidate() 144 | tool_bar.setOnMenuItemClickListener { menuItem -> 145 | onMenuItemSelected(categories, menuItem) 146 | } 147 | } 148 | 149 | override fun hideCategoriesMenu() { 150 | printLogD(CLASS_NAME, "hideCategoriesMenu") 151 | tool_bar.menu.clear() 152 | tool_bar.invalidate() 153 | } 154 | 155 | override fun displayMainProgressBar(isLoading: Boolean){ 156 | if(isLoading){ 157 | main_progress_bar.visibility = View.VISIBLE 158 | } 159 | else{ 160 | main_progress_bar.visibility = View.GONE 161 | } 162 | } 163 | 164 | override fun hideToolbar() { 165 | tool_bar.visibility = View.GONE 166 | } 167 | 168 | override fun showToolbar() { 169 | tool_bar.visibility = View.VISIBLE 170 | } 171 | 172 | override fun hideStatusBar() { 173 | window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN 174 | hideToolbar() 175 | } 176 | 177 | override fun showStatusBar() { 178 | window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_VISIBLE 179 | showToolbar() 180 | } 181 | 182 | override fun expandAppBar() { 183 | findViewById(R.id.app_bar).setExpanded(true) 184 | } 185 | 186 | override fun onDestroy() { 187 | cleanUpOnDestroy() 188 | super.onDestroy() 189 | } 190 | 191 | private fun cleanUpOnDestroy(){ 192 | for(dialog in dialogs){ 193 | dialog.value.dismiss() 194 | } 195 | } 196 | 197 | companion object { 198 | 199 | const val MENU_ITEM_ID_GET_ALL_BLOGS = 99999999 200 | const val MENU_ITEM_NAME_GET_ALL_BLOGS = "All" 201 | } 202 | 203 | } 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | -------------------------------------------------------------------------------- /app/src/main/java/com/codingwithmitch/espressodaggerexamples/views/ScalingImageView.kt: -------------------------------------------------------------------------------- 1 | package com.codingwithmitch.espressodaggerexamples.views 2 | 3 | 4 | import android.content.Context 5 | import android.graphics.Matrix 6 | import android.graphics.PointF 7 | import android.graphics.drawable.Drawable 8 | import android.util.AttributeSet 9 | import android.util.Log 10 | import android.view.GestureDetector 11 | import android.view.MotionEvent 12 | import android.view.ScaleGestureDetector 13 | import android.view.View 14 | import android.widget.ImageView 15 | 16 | 17 | /** 18 | * ImageView that you can pinch to scale (zoom in and out) 19 | * Created by Mitch on 3/9/2018. 20 | */ 21 | class ScalingImageView : ImageView, 22 | View.OnTouchListener, 23 | GestureDetector.OnGestureListener, 24 | GestureDetector.OnDoubleTapListener 25 | { 26 | //shared constructing 27 | lateinit var imageContext: Context 28 | var scaleDetector: ScaleGestureDetector? = null 29 | var gestureDetector: GestureDetector? = null 30 | lateinit var myMatrix: Matrix 31 | lateinit var imageMatrixValues: FloatArray 32 | var mode = NONE 33 | // Scales 34 | var saveScale = 1f 35 | var minScale = 1f 36 | var maxScale = 4f 37 | // view dimensions 38 | var origWidth = 0f 39 | var origHeight = 0f 40 | var viewWidth = 0 41 | var viewHeight = 0 42 | var last = PointF() 43 | var start = PointF() 44 | 45 | constructor(context: Context?) : super(context) 46 | 47 | constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs){ 48 | context?.run { 49 | sharedConstructing(this) 50 | } 51 | } 52 | 53 | private fun sharedConstructing(context: Context) { 54 | super.setClickable(true) 55 | imageContext = context 56 | scaleDetector = ScaleGestureDetector(context, ScaleListener()) 57 | myMatrix = Matrix() 58 | imageMatrixValues = FloatArray(9) 59 | imageMatrix = myMatrix 60 | setScaleType(ImageView.ScaleType.MATRIX) 61 | this.gestureDetector = GestureDetector(context, this) 62 | setOnTouchListener(this) 63 | } 64 | 65 | private inner class ScaleListener : ScaleGestureDetector.SimpleOnScaleGestureListener() { 66 | override fun onScaleBegin(detector: ScaleGestureDetector): Boolean { 67 | mode = ZOOM 68 | return true 69 | } 70 | 71 | override fun onScale(detector: ScaleGestureDetector): Boolean { 72 | var mScaleFactor = detector.scaleFactor 73 | val prevScale = saveScale 74 | saveScale *= mScaleFactor 75 | if (saveScale > maxScale) { 76 | saveScale = maxScale 77 | mScaleFactor = maxScale / prevScale 78 | } else if (saveScale < minScale) { 79 | saveScale = minScale 80 | mScaleFactor = minScale / prevScale 81 | } 82 | if (origWidth * saveScale <= viewWidth 83 | || origHeight * saveScale <= viewHeight 84 | ) { 85 | myMatrix.postScale( 86 | mScaleFactor, mScaleFactor, viewWidth / 2.toFloat(), 87 | viewHeight / 2.toFloat() 88 | ) 89 | } else { 90 | myMatrix.postScale( 91 | mScaleFactor, mScaleFactor, 92 | detector.focusX, detector.focusY 93 | ) 94 | } 95 | fixTranslation() 96 | return true 97 | } 98 | } 99 | 100 | fun fitToScreen() { 101 | saveScale = 1f 102 | val scale: Float 103 | val drawable: Drawable? = drawable 104 | if (drawable == null || drawable.intrinsicWidth == 0 || drawable.intrinsicHeight == 0) 105 | return 106 | val imageWidth = drawable.intrinsicWidth 107 | val imageHeight = drawable.intrinsicHeight 108 | val scaleX = viewWidth.toFloat() / imageWidth.toFloat() 109 | val scaleY = viewHeight.toFloat() / imageHeight.toFloat() 110 | scale = Math.min(scaleX, scaleY) 111 | myMatrix.setScale(scale, scale) 112 | // Center the image 113 | var redundantYSpace = (viewHeight.toFloat() 114 | - scale * imageHeight.toFloat()) 115 | var redundantXSpace = (viewWidth.toFloat() 116 | - scale * imageWidth.toFloat()) 117 | redundantYSpace /= 2.toFloat() 118 | redundantXSpace /= 2.toFloat() 119 | myMatrix.postTranslate(redundantXSpace, redundantYSpace) 120 | origWidth = viewWidth - 2 * redundantXSpace 121 | origHeight = viewHeight - 2 * redundantYSpace 122 | imageMatrix = myMatrix 123 | } 124 | 125 | fun fixTranslation() { 126 | myMatrix.getValues(imageMatrixValues) //put imageMatrix values into a float array so we can analyze 127 | val transX = 128 | imageMatrixValues[Matrix.MTRANS_X] //get the most recent translation in x direction 129 | val transY = 130 | imageMatrixValues[Matrix.MTRANS_Y] //get the most recent translation in y direction 131 | val fixTransX = 132 | getFixTranslation(transX, viewWidth.toFloat(), origWidth * saveScale) 133 | val fixTransY = 134 | getFixTranslation(transY, viewHeight.toFloat(), origHeight * saveScale) 135 | if (fixTransX != 0f || fixTransY != 0f) myMatrix.postTranslate(fixTransX, fixTransY) 136 | } 137 | 138 | fun getFixTranslation( 139 | trans: Float, 140 | viewSize: Float, 141 | contentSize: Float 142 | ): Float { 143 | val minTrans: Float 144 | val maxTrans: Float 145 | if (contentSize <= viewSize) { // case: NOT ZOOMED 146 | minTrans = 0f 147 | maxTrans = viewSize - contentSize 148 | } else { //CASE: ZOOMED 149 | minTrans = viewSize - contentSize 150 | maxTrans = 0f 151 | } 152 | if (trans < minTrans) { // negative x or y translation (down or to the right) 153 | return -trans + minTrans 154 | } 155 | if (trans > maxTrans) { // positive x or y translation (up or to the left) 156 | return -trans + maxTrans 157 | } 158 | return 0f 159 | } 160 | 161 | fun getFixDragTrans( 162 | delta: Float, 163 | viewSize: Float, 164 | contentSize: Float 165 | ): Float { 166 | return if (contentSize <= viewSize) { 167 | 0f 168 | } else delta 169 | } 170 | 171 | override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { 172 | super.onMeasure(widthMeasureSpec, heightMeasureSpec) 173 | viewWidth = MeasureSpec.getSize(widthMeasureSpec) 174 | viewHeight = MeasureSpec.getSize(heightMeasureSpec) 175 | if (saveScale == 1f) { // Fit to screen. 176 | fitToScreen() 177 | } 178 | } 179 | 180 | /* 181 | Ontouch 182 | */ 183 | override fun onTouch(view: View, event: MotionEvent): Boolean { 184 | scaleDetector!!.onTouchEvent(event) 185 | gestureDetector!!.onTouchEvent(event) 186 | val currentPoint = PointF(event.x, event.y) 187 | when (event.action) { 188 | MotionEvent.ACTION_DOWN -> { 189 | last.set(currentPoint) 190 | start.set(last) 191 | mode = DRAG 192 | } 193 | MotionEvent.ACTION_MOVE -> if (mode == DRAG) { 194 | val dx = currentPoint.x - last.x 195 | val dy = currentPoint.y - last.y 196 | val fixTransX = 197 | getFixDragTrans(dx, viewWidth.toFloat(), origWidth * saveScale) 198 | val fixTransY = 199 | getFixDragTrans(dy, viewHeight.toFloat(), origHeight * saveScale) 200 | myMatrix.postTranslate(fixTransX, fixTransY) 201 | fixTranslation() 202 | last[currentPoint.x] = currentPoint.y 203 | } 204 | MotionEvent.ACTION_POINTER_UP -> mode = NONE 205 | } 206 | imageMatrix = myMatrix 207 | return false 208 | } 209 | 210 | /* 211 | GestureListener 212 | */ 213 | override fun onDown(motionEvent: MotionEvent): Boolean { 214 | return false 215 | } 216 | 217 | override fun onShowPress(motionEvent: MotionEvent) {} 218 | override fun onSingleTapUp(motionEvent: MotionEvent): Boolean { 219 | return false 220 | } 221 | 222 | override fun onScroll( 223 | motionEvent: MotionEvent, 224 | motionEvent1: MotionEvent, 225 | v: Float, 226 | v1: Float 227 | ): Boolean { 228 | return false 229 | } 230 | 231 | override fun onLongPress(motionEvent: MotionEvent) {} 232 | override fun onFling( 233 | motionEvent: MotionEvent, 234 | motionEvent1: MotionEvent, 235 | v: Float, 236 | v1: Float 237 | ): Boolean { 238 | return false 239 | } 240 | 241 | /* 242 | onDoubleTap 243 | */ 244 | override fun onSingleTapConfirmed(motionEvent: MotionEvent): Boolean { 245 | return false 246 | } 247 | 248 | override fun onDoubleTap(motionEvent: MotionEvent): Boolean { 249 | fitToScreen() 250 | return false 251 | } 252 | 253 | override fun onDoubleTapEvent(motionEvent: MotionEvent): Boolean { 254 | return false 255 | } 256 | 257 | companion object { 258 | private const val TAG = "ScalingImageView" 259 | // Image States 260 | const val NONE = 0 261 | const val DRAG = 1 262 | const val ZOOM = 2 263 | } 264 | } 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | -------------------------------------------------------------------------------- /app/src/androidTest/java/com/codingwithmitch/espressodaggerexamples/ui/ListFragmentIntegrationTests.kt: -------------------------------------------------------------------------------- 1 | package com.codingwithmitch.espressodaggerexamples.ui 2 | 3 | import androidx.appcompat.widget.Toolbar 4 | import androidx.lifecycle.Observer 5 | import androidx.test.core.app.launchActivity 6 | import androidx.test.espresso.Espresso.* 7 | import androidx.test.espresso.action.ViewActions.click 8 | import androidx.test.espresso.assertion.ViewAssertions.* 9 | import androidx.test.espresso.contrib.RecyclerViewActions 10 | import androidx.test.espresso.matcher.ViewMatchers.* 11 | import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner 12 | import androidx.test.platform.app.InstrumentationRegistry 13 | import com.codingwithmitch.espressodaggerexamples.R 14 | import com.codingwithmitch.espressodaggerexamples.TestBaseApplication 15 | import com.codingwithmitch.espressodaggerexamples.di.TestAppComponent 16 | import com.codingwithmitch.espressodaggerexamples.ui.viewmodel.state.MainViewState 17 | import com.codingwithmitch.espressodaggerexamples.util.Constants 18 | import com.codingwithmitch.espressodaggerexamples.util.EspressoIdlingResourceRule 19 | import kotlinx.coroutines.ExperimentalCoroutinesApi 20 | import kotlinx.coroutines.InternalCoroutinesApi 21 | import org.junit.FixMethodOrder 22 | import org.junit.Rule 23 | import org.junit.Test 24 | import org.junit.runner.RunWith 25 | import org.junit.runners.MethodSorters 26 | 27 | /** 28 | * ListFragment integration tests (ActivityScenario). 29 | * Launch app and check ListFragment properties (menu, recyclerview). 30 | * 31 | */ 32 | @FixMethodOrder(MethodSorters.NAME_ASCENDING) 33 | @InternalCoroutinesApi 34 | @ExperimentalCoroutinesApi 35 | @RunWith(AndroidJUnit4ClassRunner::class) 36 | class ListFragmentIntegrationTests: BaseMainActivityTests() { 37 | 38 | private val CLASS_NAME = "MainActivityIntegrationTests" 39 | 40 | @get: Rule 41 | val espressoIdlingResourceRule = EspressoIdlingResourceRule() 42 | 43 | @Test 44 | fun isBlogListEmpty() { 45 | 46 | val app = InstrumentationRegistry 47 | .getInstrumentation() 48 | .targetContext 49 | .applicationContext as TestBaseApplication 50 | 51 | val apiService = configureFakeApiService( 52 | blogsDataSource = Constants.EMPTY_LIST, // empty list of data 53 | categoriesDataSource = Constants.CATEGORIES_DATA_FILENAME, 54 | networkDelay = 0L, 55 | application = app 56 | ) 57 | 58 | configureFakeRepository(apiService, app) 59 | 60 | injectTest(app) 61 | 62 | val scenario = launchActivity() 63 | 64 | val recyclerView = onView(withId(R.id.recycler_view)) 65 | 66 | recyclerView.check(matches(isDisplayed())) 67 | 68 | onView(withId(R.id.no_data_textview)) 69 | .check(matches(withEffectiveVisibility(Visibility.VISIBLE))) 70 | } 71 | 72 | // if query for categories returns an empty list, the user should still see 73 | // the menu item "All" 74 | @Test 75 | fun isCategoryListEmpty() { 76 | 77 | val app = InstrumentationRegistry 78 | .getInstrumentation() 79 | .targetContext 80 | .applicationContext as TestBaseApplication 81 | 82 | val apiService = configureFakeApiService( 83 | blogsDataSource = Constants.BLOG_POSTS_DATA_FILENAME, 84 | categoriesDataSource = Constants.EMPTY_LIST, // empty list of data 85 | networkDelay = 0L, 86 | application = app 87 | ) 88 | 89 | configureFakeRepository(apiService, app) 90 | 91 | injectTest(app) 92 | 93 | val scenario = launchActivity().onActivity { mainActivity -> 94 | val toolbar: Toolbar = mainActivity.findViewById(R.id.tool_bar) 95 | 96 | // wait for jobs to complete to open the menu 97 | mainActivity.viewModel.viewState.observe(mainActivity, Observer { viewState -> 98 | if(viewState.activeJobCounter.size == 0){ 99 | toolbar.showOverflowMenu() 100 | } 101 | }) 102 | } 103 | 104 | onView(withSubstring("earthporn")) 105 | .check(doesNotExist()) 106 | 107 | onView(withSubstring("dogs")) 108 | .check(doesNotExist()) 109 | 110 | onView(withSubstring("fun")) 111 | .check(doesNotExist()) 112 | 113 | onView(withSubstring("All")) 114 | .check(matches(isDisplayed())) 115 | } 116 | 117 | @Test 118 | fun checkListData_testScrolling() { 119 | 120 | val app = InstrumentationRegistry 121 | .getInstrumentation() 122 | .targetContext 123 | .applicationContext as TestBaseApplication 124 | 125 | val apiService = configureFakeApiService( 126 | blogsDataSource = Constants.BLOG_POSTS_DATA_FILENAME, 127 | categoriesDataSource = Constants.CATEGORIES_DATA_FILENAME, 128 | networkDelay = 0L, 129 | application = app 130 | ) 131 | 132 | configureFakeRepository(apiService, app) 133 | 134 | injectTest(app) 135 | 136 | val scenario = launchActivity() 137 | 138 | val recyclerView = onView(withId(R.id.recycler_view)) 139 | 140 | recyclerView.check(matches(isDisplayed())) 141 | 142 | recyclerView.perform( 143 | RecyclerViewActions.scrollToPosition(5) 144 | ) 145 | onView(withText("Mountains in Washington")).check(matches(isDisplayed())) 146 | 147 | recyclerView.perform( 148 | RecyclerViewActions.scrollToPosition(8) 149 | ) 150 | onView(withText("Blake Posing for his Website")).check(matches(isDisplayed())) 151 | 152 | recyclerView.perform( 153 | RecyclerViewActions.scrollToPosition(0) 154 | ) 155 | onView(withText("Vancouver PNE 2019")).check(matches(isDisplayed())) 156 | 157 | onView(withId(R.id.no_data_textview)) 158 | .check(matches(withEffectiveVisibility(Visibility.GONE))) 159 | } 160 | 161 | @Test 162 | fun checkListData_onCategoryChange_toEarthporn(){ 163 | val app = InstrumentationRegistry 164 | .getInstrumentation() 165 | .targetContext 166 | .applicationContext as TestBaseApplication 167 | 168 | val apiService = configureFakeApiService( 169 | blogsDataSource = Constants.BLOG_POSTS_DATA_FILENAME, 170 | categoriesDataSource = Constants.CATEGORIES_DATA_FILENAME, 171 | networkDelay = 0L, 172 | application = app 173 | ) 174 | 175 | configureFakeRepository(apiService, app) 176 | 177 | injectTest(app) 178 | 179 | val scenario = launchActivity().onActivity { mainActivity -> 180 | val toolbar: Toolbar = mainActivity.findViewById(R.id.tool_bar) 181 | 182 | mainActivity.viewModel.viewState.observe(mainActivity, object: Observer{ 183 | override fun onChanged(viewState: MainViewState?) { 184 | if(viewState?.activeJobCounter?.size == 0){ 185 | toolbar.showOverflowMenu() 186 | mainActivity.viewModel.viewState.removeObserver(this) 187 | } 188 | } 189 | }) 190 | } 191 | 192 | // click "earthporn" category from menu 193 | val CATEGORY_NAME = "earthporn" 194 | onView(withText(CATEGORY_NAME)).perform(click()) 195 | 196 | onView(withText("Mountains in Washington")) 197 | .check(matches(isDisplayed())) 198 | 199 | onView(withText("France Mountain Range")) 200 | .check(matches(isDisplayed())) 201 | 202 | onView(withText("Vancouver PNE 2019")) 203 | .check(doesNotExist()) 204 | } 205 | 206 | 207 | @Test 208 | fun checkListData_onCategoryChange_toFun(){ 209 | val app = InstrumentationRegistry 210 | .getInstrumentation() 211 | .targetContext 212 | .applicationContext as TestBaseApplication 213 | 214 | val apiService = configureFakeApiService( 215 | blogsDataSource = Constants.BLOG_POSTS_DATA_FILENAME, 216 | categoriesDataSource = Constants.CATEGORIES_DATA_FILENAME, 217 | networkDelay = 0L, 218 | application = app 219 | ) 220 | 221 | configureFakeRepository(apiService, app) 222 | 223 | injectTest(app) 224 | 225 | val scenario = launchActivity().onActivity { mainActivity -> 226 | val toolbar: Toolbar = mainActivity.findViewById(R.id.tool_bar) 227 | 228 | mainActivity.viewModel.viewState.observe(mainActivity, object: Observer{ 229 | override fun onChanged(viewState: MainViewState?) { 230 | if(viewState?.activeJobCounter?.size == 0){ 231 | toolbar.showOverflowMenu() 232 | mainActivity.viewModel.viewState.removeObserver(this) 233 | } 234 | } 235 | }) 236 | } 237 | 238 | // click "fun" category from menu 239 | val CATEGORY_NAME = "fun" 240 | onView(withText(CATEGORY_NAME)).perform(click()) 241 | 242 | onView(withText("My Brother Blake")) 243 | .check(matches(isDisplayed())) 244 | 245 | onView(withText("Vancouver PNE 2019")) 246 | .check(matches(isDisplayed())) 247 | 248 | onView(withText("France Mountain Range")) 249 | .check(doesNotExist()) 250 | } 251 | 252 | @Test 253 | fun isInstanceStateSavedAndRestored_OnDestroyActivity() { 254 | 255 | val app = InstrumentationRegistry 256 | .getInstrumentation() 257 | .targetContext 258 | .applicationContext as TestBaseApplication 259 | 260 | val apiService = configureFakeApiService( 261 | blogsDataSource = Constants.BLOG_POSTS_DATA_FILENAME, 262 | categoriesDataSource = Constants.CATEGORIES_DATA_FILENAME, 263 | networkDelay = 0L, 264 | application = app 265 | ) 266 | 267 | configureFakeRepository(apiService, app) 268 | 269 | injectTest(app) 270 | 271 | val scenario = launchActivity() 272 | 273 | onView(withId(R.id.recycler_view)) 274 | .check(matches(isDisplayed())) 275 | 276 | onView(withId(R.id.recycler_view)).perform( 277 | RecyclerViewActions.scrollToPosition(8) 278 | ) 279 | 280 | onView(withText("Blake Posing for his Website")) 281 | .check(matches(isDisplayed())) 282 | 283 | scenario.recreate() 284 | 285 | onView(withText("Blake Posing for his Website")).check(matches(isDisplayed())) 286 | 287 | } 288 | 289 | override fun injectTest(application: TestBaseApplication) { 290 | (application.appComponent as TestAppComponent) 291 | .inject(this) 292 | } 293 | } 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | --------------------------------------------------------------------------------