>() {}.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 | - Dagger Components
19 | - Dagger Modules
20 | - Application class
21 | - Fragment Factory
22 | - Glide ImageLoader
23 | - Retrofit network requests
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 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
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 |
123 |
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 |
--------------------------------------------------------------------------------