├── Demo-JokesApp.gif ├── README.md ├── app ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── prieto │ │ └── fernando │ │ └── jokesapp │ │ ├── custom │ │ ├── CustomFragmentRobot.kt │ │ └── CustomFragmentTest.kt │ │ ├── dashboard │ │ ├── DashboardFragmentRobot.kt │ │ └── DashboardFragmentTest.kt │ │ ├── detail │ │ ├── DetailFragmentRobot.kt │ │ └── DetailFragmentTest.kt │ │ ├── infinite │ │ ├── InfiniteFragmentRobot.kt │ │ └── InfiniteFragmentTest.kt │ │ ├── utils │ │ ├── RecyclerViewItemCountAssertion.kt │ │ ├── RecyclerViewMatcher.kt │ │ └── TestConfigurationRule.kt │ │ └── webmock │ │ ├── AssetReaderUtil.kt │ │ ├── ErrorDispatcher.kt │ │ ├── MockTestRunner.kt │ │ ├── SuccessDispatcher.kt │ │ └── TestConfigurationBuilder.kt │ ├── debug │ └── assets │ │ └── network_files │ │ ├── custom_joke_success.json │ │ ├── joke_list_success.json │ │ └── random_joke_success.json │ └── main │ ├── AndroidManifest.xml │ ├── ic_launcher-web.png │ ├── java │ └── prieto │ │ └── fernando │ │ └── jokesapp │ │ ├── JokesApp.kt │ │ ├── di │ │ ├── ActivityScope.kt │ │ ├── AppComponent.kt │ │ ├── AppModule.kt │ │ └── MainActivityModule.kt │ │ └── view │ │ ├── MainActivity.kt │ │ ├── custom │ │ └── CustomJokeFragment.kt │ │ ├── dashboard │ │ └── DashboardFragment.kt │ │ ├── detail │ │ ├── DetailFragment.kt │ │ └── DetailViewModel.kt │ │ ├── extension │ │ └── Lifecycle.kt │ │ └── infinite │ │ ├── InfiniteJokesFragment.kt │ │ ├── adapter │ │ └── JokesAdapter.kt │ │ └── widget │ │ └── InfiniteScrollListener.kt │ └── res │ ├── drawable-v24 │ └── ic_launcher_foreground.xml │ ├── drawable │ ├── ic_launcher_background.xml │ └── ic_launcher_foreground.xml │ ├── layout │ ├── activity_main.xml │ ├── fragment_custom_joke.xml │ ├── fragment_dashboard.xml │ ├── fragment_detail.xml │ ├── fragment_infinite_jokes.xml │ └── item_joke.xml │ ├── mipmap-anydpi-v26 │ ├── ic_launcher.xml │ └── ic_launcher_round.xml │ ├── mipmap-hdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ ├── mipmap-mdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ ├── mipmap-xhdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ ├── mipmap-xxhdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ ├── mipmap-xxxhdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ └── values │ ├── colors.xml │ ├── dimens.xml │ ├── ic_launcher_background.xml │ ├── strings.xml │ └── styles.xml ├── base-android-library.gradle ├── build.gradle ├── buildsystem └── dependencies.gradle ├── core ├── .gitignore ├── build.gradle ├── consumer-rules.pro ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── prieto │ │ └── fernando │ │ └── core │ │ └── ExampleInstrumentedTest.kt │ └── main │ ├── AndroidManifest.xml │ ├── java │ └── prieto │ │ └── fernando │ │ ├── presentation │ │ ├── BaseViewModel.kt │ │ ├── SchedulerProvider.kt │ │ └── ViewModelProviderFactory.kt │ │ └── ui │ │ ├── BaseActivity.kt │ │ ├── BaseFragment.kt │ │ └── BaseView.kt │ └── res │ └── values │ └── strings.xml ├── data-cache ├── .gitignore ├── build.gradle ├── consumer-rules.pro ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── prieto │ │ └── fernando │ │ └── data_cache │ │ └── ExampleInstrumentedTest.kt │ └── main │ ├── AndroidManifest.xml │ ├── java │ └── prieto │ │ └── fernando │ │ ├── model │ │ └── RandomJokeLocalModel.kt │ │ └── source │ │ └── JokesLocalSource.kt │ └── res │ └── values │ └── strings.xml ├── data-jokesapi ├── .gitignore ├── build.gradle ├── consumer-rules.pro ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── prieto │ │ └── fernando │ │ └── data_jokesapi │ │ └── ExampleInstrumentedTest.kt │ └── main │ ├── AndroidManifest.xml │ ├── java │ └── prieto │ │ └── fernando │ │ ├── ApiService.kt │ │ ├── data │ │ └── JokesRemoteSource.kt │ │ ├── di │ │ ├── BaseUrl.kt │ │ ├── ChuckNorrisApiModule.kt │ │ └── NetworkModule.kt │ │ └── model │ │ ├── MultipleRandomJokeResponse.kt │ │ └── RandomJokeResponse.kt │ └── res │ └── values │ └── strings.xml ├── domain ├── .gitignore ├── build.gradle ├── consumer-rules.pro ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── prieto │ │ └── fernando │ │ └── domain │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── prieto │ │ │ └── fernando │ │ │ ├── data │ │ │ └── RandomJokeDomainModel.kt │ │ │ ├── di │ │ │ └── RepositoryModule.kt │ │ │ ├── mapper │ │ │ ├── MultipleRandomJokeResponseToLocalModelMapper.kt │ │ │ ├── RandomJokeLocalToDomainModelMapper.kt │ │ │ └── RandomJokeResponseToLocalModelMapper.kt │ │ │ ├── repository │ │ │ └── JokesRepositoryImpl.kt │ │ │ └── usecase │ │ │ ├── GetCustomRandomJokeUseCase.kt │ │ │ ├── GetMultipleRandomJokeUseCase.kt │ │ │ ├── GetRandomJokeUseCase.kt │ │ │ └── ResetCustomRandomJokeUseCase.kt │ └── res │ │ └── values │ │ └── strings.xml │ └── test │ └── java │ └── prieto │ └── fernando │ └── domain │ ├── mapper │ ├── MultipleRandomJokeResponseToLocalModelMapperTest.kt │ ├── RandomJokeLocalToDomainModelMapperTest.kt │ └── RandomJokeResponseToLocalModelMapperTest.kt │ ├── repository │ └── JokesRepositoryImplTest.kt │ └── usecase │ ├── GetCustomRandomJokeUseCaseTest.kt │ ├── GetMultipleRandomJokeUseCaseTest.kt │ └── GetRandomJokeUseCaseTest.kt ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── navigation ├── build.gradle ├── consumer-rules.pro ├── proguard-rules.pro └── src │ └── main │ ├── AndroidManifest.xml │ └── res │ └── navigation │ └── nav_graph.xml ├── presentation ├── .gitignore ├── build.gradle ├── consumer-rules.pro ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── prieto │ │ └── fernando │ │ └── presentation │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── prieto │ │ │ └── fernando │ │ │ └── presentation │ │ │ ├── RandomJokeUiModel.kt │ │ │ ├── custom │ │ │ ├── CustomJokeViewModel.kt │ │ │ └── NamesButtonStateEvaluator.kt │ │ │ ├── dashboard │ │ │ └── DashboardViewModel.kt │ │ │ ├── data │ │ │ └── RandomJokeAndTitleResource.kt │ │ │ ├── infinite │ │ │ └── InfiniteJokesViewModel.kt │ │ │ ├── main │ │ │ └── MainViewModel.kt │ │ │ ├── mapper │ │ │ └── RandomJokeDomainToUiModelMapper.kt │ │ │ └── model │ │ │ └── NamesData.kt │ └── res │ │ └── values │ │ └── strings.xml │ └── test │ └── java │ └── prieto │ └── fernando │ └── presentation │ ├── CustomJokeViewModelTest.kt │ ├── NamesButtonStateEvaluatorTest.kt │ ├── ViewModelSetup.kt │ ├── dashboard │ └── DashboardViewModelTest.kt │ ├── infinite │ └── InfiniteJokesViewModelTest.kt │ ├── mapper │ └── RandomJokeDomainToUiModelMapperTest.kt │ └── scheduler │ └── TestSchedulerProvider.kt └── settings.gradle /Demo-JokesApp.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ferPrieto/MVVM-Modularized/aecbd0054faa15beb29aac1c694eff9db0179432/Demo-JokesApp.gif -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # JokesApp 2 | 3 | A demo app to show random jokes, with the aim of showing Clean Architecture and Clean code principles 4 | in a MVVM setup, LiveData and Rxjava. 5 | 6 | There's a cache memory used to save and retrieve a custom joke. In order to show a data cache implementation. 7 | 8 | ## About the project 9 | 10 | For simplicity, I've chosen the ICNDb free API: 11 | http://www.icndb.com/api/ 12 | 13 | ### Initial approach 14 | 15 | The initial Clean Architecture approach was developed by me in a Bitbucket repository using: 16 | - MVVM 17 | - RXJava 18 | 19 | ### Goals 20 | 21 | In order to follow the latest Android standards, the project will include the next points (progressively): 22 | - ~~Complete the VM unit tests~~ (DONE) 23 | - ~~LiveData, instead of Rx (outputs)~~ (DONE) 24 | - ~~UI Tests using Espresso and Robot pattern~~ (reference [Adam McNeilly]) (DONE) 25 | - ~~MockWebServer for testing HTTP clients~~ (DONE) 26 | - ~~Add Navigation Module~~ (DONE) 27 | - ~~Improve UI elements~~ (DONE) 28 | - ~~Add feature Detail~~ (DONE) 29 | - Change modules for feature Module, in order to create two branches: `Feature-Modules` and `Data-Layer-Modules` 30 | 31 | 32 | [Adam McNeilly]: https://github.com/AdamMc331 33 | 34 | ## Libraries Used 35 | 36 | * [Rx][0] for reactive style programming (from VM to Data). 37 | * [LiveData][1] for reactive style programming (from VM to UI). 38 | * [Navigation][2] for in-app navigation. 39 | * [Rx][3] for cache storage. 40 | * [Dagger2][4] for dependency injection. 41 | * [Retrofit][5] for REST api communication. 42 | * [Timber][6] for logging. 43 | * [Espresso][7] for UI tests. 44 | * [Mockito-Kotlin][8] for mocking in tests. 45 | * [MockWebServer][9] for Instrumentation tests. 46 | * [AndroidX Test Library][10] for providing JUnit4 and functions as `launchActivity` in UI tests, 47 | 48 | [0]: https://github.com/ReactiveX/RxAndroid 49 | [1]: https://developer.android.com/topic/libraries/architecture/livedata 50 | [2]: https://developer.android.com/topic/libraries/architecture/navigation/ 51 | [3]: https://github.com/ReactiveX/RxAndroid 52 | [4]: https://github.com/google/dagger 53 | [5]: https://github.com/square/retrofit 54 | [6]: https://github.com/JakeWharton/timber 55 | [7]: https://developer.android.com/training/testing/espresso/ 56 | [8]: https://github.com/nhaarman/mockito-kotlin 57 | [9]: https://github.com/square/okhttp/tree/master/mockwebserver 58 | [10]: https://github.com/android/android-test 59 | 60 | ## Demo 61 | 62 |

63 | 64 |

65 | 66 | Buy Me A Coffee 67 | 68 | # License 69 | 70 | Copyright 2019 Fernando Prieto Moyano 71 | 72 | Licensed under the Apache License, Version 2.0 (the "License"); 73 | you may not use this file except in compliance with the License. 74 | You may obtain a copy of the License at 75 | 76 | http://www.apache.org/licenses/LICENSE-2.0 77 | 78 | Unless required by applicable law or agreed to in writing, software 79 | distributed under the License is distributed on an "AS IS" BASIS, 80 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 81 | See the License for the specific language governing permissions and 82 | limitations under the License. -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /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 | apply plugin: "org.jmailen.kotlinter" 10 | 11 | apply plugin: "androidx.navigation.safeargs" 12 | 13 | android { 14 | def config = rootProject.extensions.getByName("ext") 15 | 16 | compileSdkVersion config["compile_sdk"] 17 | buildToolsVersion config["build_version"] 18 | 19 | defaultConfig { 20 | applicationId config["application_id"] 21 | minSdkVersion config["min_sdk"] 22 | targetSdkVersion config["target_sdk"] 23 | versionCode config["version_code"] 24 | versionName config["version_name"] 25 | testInstrumentationRunner config["test_runner"] 26 | multiDexEnabled true 27 | } 28 | 29 | compileOptions { 30 | sourceCompatibility 1.8 31 | targetCompatibility 1.8 32 | } 33 | 34 | kotlinOptions { 35 | jvmTarget = '1.8' 36 | } 37 | 38 | buildTypes { 39 | debug { 40 | buildConfigField("Integer", 41 | "PORT", 42 | "8080") 43 | } 44 | release { 45 | minifyEnabled false 46 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 47 | } 48 | } 49 | 50 | testOptions { 51 | unitTests.returnDefaultValues = true 52 | 53 | unitTests.all { 54 | setIgnoreFailures(false) 55 | } 56 | } 57 | 58 | lintOptions { 59 | quiet true 60 | xmlReport true 61 | htmlReport true 62 | abortOnError true 63 | warningsAsErrors false 64 | } 65 | 66 | androidExtensions { 67 | experimental true 68 | } 69 | } 70 | 71 | dependencies { 72 | def applicationDependencies = rootProject.ext.mainApplication 73 | def unitTestDependencies = rootProject.ext.unitTesting 74 | def acceptanceTestDependencies = rootProject.ext.acceptanceTesting 75 | 76 | implementation project(':core') 77 | implementation project(':data-jokesapi') 78 | implementation project(':data-cache') 79 | implementation project(':domain') 80 | implementation project(':presentation') 81 | implementation project(':navigation') 82 | 83 | compileOnly applicationDependencies.jdk9Builder 84 | 85 | // Compile time dependencies 86 | annotationProcessor applicationDependencies.lifecycleCompiler 87 | kapt applicationDependencies.daggerCompiler 88 | kapt applicationDependencies.daggerAndroidProcessor 89 | 90 | // Application dependencies 91 | implementation applicationDependencies.kotlin 92 | implementation applicationDependencies.appCompat 93 | implementation applicationDependencies.coreKtx 94 | implementation applicationDependencies.constraintLayout 95 | implementation applicationDependencies.recyclerView 96 | implementation applicationDependencies.cardView 97 | implementation applicationDependencies.archViewModel 98 | implementation applicationDependencies.archComponents 99 | implementation applicationDependencies.lifecycleCompiler 100 | implementation applicationDependencies.archComponentsCompiler 101 | implementation applicationDependencies.archNavigationFragment 102 | implementation applicationDependencies.archNavigationUi 103 | implementation applicationDependencies.daggerAndroid 104 | implementation applicationDependencies.daggerAndroidSupport 105 | implementation applicationDependencies.retrofit 106 | implementation applicationDependencies.retrofitConverterGson 107 | implementation applicationDependencies.retrofitRxjava2Adapter 108 | implementation applicationDependencies.okHttpLoggingInterceptor 109 | implementation applicationDependencies.rxJava 110 | implementation applicationDependencies.rxKotlin 111 | implementation applicationDependencies.rxAndroid 112 | implementation applicationDependencies.rxBinding 113 | implementation applicationDependencies.timber 114 | 115 | // Unit/Integration tests dependencies 116 | testImplementation unitTestDependencies.junit 117 | testImplementation unitTestDependencies.mockitoCore 118 | testImplementation unitTestDependencies.mockitoInline 119 | testImplementation unitTestDependencies.mockitoKotlin 120 | testImplementation unitTestDependencies.junitPlatformRunner 121 | testImplementation unitTestDependencies.commonsCodec 122 | testImplementation unitTestDependencies.coreTesting 123 | 124 | // Acceptance tests dependencies 125 | androidTestImplementation acceptanceTestDependencies.testEspressoContrib, { 126 | exclude group: 'com.google.code.findbugs', module: 'jsr305' 127 | } 128 | androidTestImplementation acceptanceTestDependencies.testRunner 129 | androidTestImplementation acceptanceTestDependencies.testRules 130 | androidTestImplementation acceptanceTestDependencies.testExt 131 | androidTestImplementation acceptanceTestDependencies.webMockServer 132 | androidTestImplementation acceptanceTestDependencies.testCore 133 | androidTestImplementation acceptanceTestDependencies.coreKtx 134 | } -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | -------------------------------------------------------------------------------- /app/src/androidTest/java/prieto/fernando/jokesapp/custom/CustomFragmentRobot.kt: -------------------------------------------------------------------------------- 1 | package prieto.fernando.jokesapp.custom 2 | 3 | import androidx.test.espresso.Espresso.onView 4 | import androidx.test.espresso.action.ViewActions.click 5 | import androidx.test.espresso.action.ViewActions.typeText 6 | import androidx.test.espresso.assertion.ViewAssertions.matches 7 | import androidx.test.espresso.matcher.ViewMatchers.* 8 | import org.hamcrest.Matchers.not 9 | import prieto.fernando.jokesapp.R 10 | 11 | fun customFragmentRobot(func: CustomFragmentRobot.()-> Unit) = CustomFragmentRobot() 12 | .apply { func() } 13 | 14 | class CustomFragmentRobot { 15 | 16 | fun clickFirstNameEditTextView() = apply { 17 | onView(firstNameEditTextViewMatcher).perform(click()) 18 | } 19 | 20 | fun clickLastNameEditTextView() = apply { 21 | onView(lastNameEditTextViewMatcher).perform(click()) 22 | } 23 | 24 | fun inputFirstNameEditTextView(text: String) = apply { 25 | onView(firstNameEditTextViewMatcher).perform(typeText(text)) 26 | } 27 | 28 | fun inputLastNameEditTextView(text: String) = apply { 29 | onView(lastNameEditTextViewMatcher).perform(typeText(text)) 30 | } 31 | 32 | fun assertFirstNameEditTextViewDisplayed() = apply { 33 | onView(firstNameEditTextViewMatcher).check(matches(isDisplayed())) 34 | } 35 | 36 | fun assertLastNameEditTextViewDisplayed() = apply { 37 | onView(lastNameEditTextViewMatcher).check(matches(isDisplayed())) 38 | } 39 | 40 | fun clickDoneButton() = apply { 41 | onView(doneButtonMatcher).perform(click()) 42 | } 43 | 44 | fun enabledDoneButton() = apply { 45 | onView(doneButtonMatcher).check(matches(isEnabled())) 46 | } 47 | 48 | fun disabledDoneButton() = apply { 49 | onView(doneButtonMatcher).check(matches(not(isEnabled()))) 50 | } 51 | 52 | companion object { 53 | private val firstNameEditTextViewMatcher = withId(R.id.first_name_text) 54 | private val lastNameEditTextViewMatcher = withId(R.id.last_name_text) 55 | private val doneButtonMatcher = withId(R.id.button_done) 56 | } 57 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/prieto/fernando/jokesapp/custom/CustomFragmentTest.kt: -------------------------------------------------------------------------------- 1 | package prieto.fernando.jokesapp.custom 2 | 3 | import androidx.test.ext.junit.runners.AndroidJUnit4 4 | import okhttp3.mockwebserver.MockWebServer 5 | import org.junit.After 6 | import org.junit.Before 7 | import org.junit.Rule 8 | import org.junit.Test 9 | import org.junit.runner.RunWith 10 | import prieto.fernando.jokesapp.BuildConfig 11 | import prieto.fernando.jokesapp.dashboard.dashboardFragmentRobot 12 | import prieto.fernando.jokesapp.utils.TestConfigurationRule 13 | import prieto.fernando.jokesapp.webmock.SuccessDispatcher 14 | 15 | @RunWith(AndroidJUnit4::class) 16 | class CustomFragmentTest { 17 | 18 | @get:Rule 19 | val espressoRule = TestConfigurationRule() 20 | 21 | private val mockWebServer = MockWebServer() 22 | 23 | @Before 24 | fun setup() { 25 | mockWebServer.start(BuildConfig.PORT) 26 | } 27 | 28 | @After 29 | fun teardown() { 30 | mockWebServer.shutdown() 31 | } 32 | 33 | @Test 34 | fun textInputsNotPassingCriteria() { 35 | dashboardFragmentRobot { 36 | assertButtonCustomJokeDisplayed() 37 | clickButtonCustomJoke() 38 | } 39 | 40 | customFragmentRobot { 41 | assertFirstNameEditTextViewDisplayed() 42 | clickFirstNameEditTextView() 43 | inputFirstNameEditTextView("a.08483") 44 | assertLastNameEditTextViewDisplayed() 45 | clickLastNameEditTextView() 46 | inputLastNameEditTextView("a") 47 | disabledDoneButton() 48 | } 49 | } 50 | 51 | @Test 52 | fun textInputsPassingCriteria() { 53 | dashboardFragmentRobot { 54 | assertButtonCustomJokeDisplayed() 55 | clickButtonCustomJoke() 56 | } 57 | 58 | customFragmentRobot { 59 | assertFirstNameEditTextViewDisplayed() 60 | clickFirstNameEditTextView() 61 | inputFirstNameEditTextView("Fernando") 62 | assertLastNameEditTextViewDisplayed() 63 | clickLastNameEditTextView() 64 | inputLastNameEditTextView("Prieto") 65 | enabledDoneButton() 66 | } 67 | } 68 | 69 | @Test 70 | fun setCustomMessageAndDialogViewPrompted() { 71 | dashboardFragmentRobot { 72 | assertButtonCustomJokeDisplayed() 73 | clickButtonCustomJoke() 74 | } 75 | 76 | mockWebServer.dispatcher = SuccessDispatcher() 77 | 78 | customFragmentRobot { 79 | assertFirstNameEditTextViewDisplayed() 80 | clickFirstNameEditTextView() 81 | inputFirstNameEditTextView("Fernando") 82 | assertLastNameEditTextViewDisplayed() 83 | clickLastNameEditTextView() 84 | inputLastNameEditTextView("Prieto") 85 | enabledDoneButton() 86 | clickDoneButton() 87 | } 88 | 89 | dashboardFragmentRobot { 90 | assertDialogViewCustomJokeDisplayed() 91 | } 92 | } 93 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/prieto/fernando/jokesapp/dashboard/DashboardFragmentRobot.kt: -------------------------------------------------------------------------------- 1 | package prieto.fernando.jokesapp.dashboard 2 | 3 | import androidx.test.espresso.Espresso.onView 4 | import androidx.test.espresso.action.ViewActions.click 5 | import androidx.test.espresso.assertion.ViewAssertions.matches 6 | import androidx.test.espresso.matcher.RootMatchers.isDialog 7 | import androidx.test.espresso.matcher.ViewMatchers.* 8 | import prieto.fernando.jokesapp.R 9 | 10 | fun dashboardFragmentRobot(func: DashboardFragmentRobot.() -> Unit) = 11 | DashboardFragmentRobot().apply { func() } 12 | 13 | class DashboardFragmentRobot { 14 | 15 | fun assertButtonRandomJokeDisplayed() = apply { 16 | onView(buttonRandomJokeMatcher).check(matches(isDisplayed())) 17 | } 18 | 19 | fun clickButtonRandomJoke() = apply { 20 | onView(buttonRandomJokeMatcher).perform(click()) 21 | } 22 | 23 | fun assertDialogViewRandomJokeDisplayed() = apply { 24 | onView(dialogRandomTitleViewMatcher) 25 | .inRoot(isDialog()) 26 | .check(matches(isDisplayed())) 27 | } 28 | 29 | fun assertDialogViewCustomJokeDisplayed() = apply { 30 | onView(dialogCustomTitleViewMatcher) 31 | .inRoot(isDialog()) 32 | .check(matches(isDisplayed())) 33 | } 34 | 35 | fun clickDismissButtonDialog() = apply { 36 | onView(dialogButtonViewMatcher).perform(click()) 37 | } 38 | 39 | fun assertButtonCustomJokeDisplayed() = apply { 40 | onView(buttonCustomJokeMatcher).check(matches(isDisplayed())) 41 | } 42 | 43 | fun clickButtonCustomJoke() = apply { 44 | onView(buttonCustomJokeMatcher).perform(click()) 45 | } 46 | 47 | fun assertButtonInfiniteJokesDisplayed() = apply { 48 | onView(buttonInfiniteJokesMatcher).check(matches(isDisplayed())) 49 | } 50 | 51 | fun clickButtonInfiniteJokes() = apply { 52 | onView(buttonInfiniteJokesMatcher).perform(click()) 53 | } 54 | 55 | companion object { 56 | private val buttonRandomJokeMatcher = withId(R.id.button_random_joke) 57 | private val dialogRandomTitleViewMatcher = withText("Random Joke") 58 | private val dialogCustomTitleViewMatcher = withText("Custom Joke") 59 | private val dialogButtonViewMatcher = withText("DISMISS") 60 | private val buttonCustomJokeMatcher = withId(R.id.button_custom_joke) 61 | private val buttonInfiniteJokesMatcher = withId(R.id.button_multiple_jokes) 62 | } 63 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/prieto/fernando/jokesapp/dashboard/DashboardFragmentTest.kt: -------------------------------------------------------------------------------- 1 | package prieto.fernando.jokesapp.dashboard 2 | 3 | import androidx.test.ext.junit.runners.AndroidJUnit4 4 | import okhttp3.mockwebserver.MockWebServer 5 | import org.junit.After 6 | import org.junit.Before 7 | import org.junit.Rule 8 | import org.junit.Test 9 | import org.junit.runner.RunWith 10 | import prieto.fernando.jokesapp.BuildConfig 11 | import prieto.fernando.jokesapp.custom.customFragmentRobot 12 | import prieto.fernando.jokesapp.infinite.infiniteFragmentRobot 13 | import prieto.fernando.jokesapp.utils.TestConfigurationRule 14 | import prieto.fernando.jokesapp.webmock.SuccessDispatcher 15 | 16 | @RunWith(AndroidJUnit4::class) 17 | class DashboardFragmentTest { 18 | 19 | @get:Rule 20 | val espressoRule = TestConfigurationRule() 21 | 22 | private val mockWebServer = MockWebServer() 23 | 24 | @Before 25 | fun setup() { 26 | mockWebServer.start(BuildConfig.PORT) 27 | } 28 | 29 | @After 30 | fun teardown() { 31 | mockWebServer.shutdown() 32 | } 33 | 34 | @Test 35 | fun openRandomJokeDialog() { 36 | mockWebServer.dispatcher = SuccessDispatcher() 37 | 38 | dashboardFragmentRobot { 39 | assertButtonRandomJokeDisplayed() 40 | clickButtonRandomJoke() 41 | assertDialogViewRandomJokeDisplayed() 42 | } 43 | } 44 | 45 | @Test 46 | fun dismissesRandomJokeDialogAfterOk() { 47 | mockWebServer.dispatcher = SuccessDispatcher() 48 | 49 | dashboardFragmentRobot { 50 | assertButtonRandomJokeDisplayed() 51 | clickButtonRandomJoke() 52 | assertDialogViewRandomJokeDisplayed() 53 | clickDismissButtonDialog() 54 | } 55 | } 56 | 57 | @Test 58 | fun openCustomJokeScreen() { 59 | dashboardFragmentRobot { 60 | assertButtonCustomJokeDisplayed() 61 | clickButtonCustomJoke() 62 | } 63 | 64 | customFragmentRobot { 65 | assertFirstNameEditTextViewDisplayed() 66 | } 67 | } 68 | 69 | @Test 70 | fun openInfiniteJokesScreen() { 71 | mockWebServer.dispatcher = SuccessDispatcher() 72 | 73 | dashboardFragmentRobot { 74 | assertButtonInfiniteJokesDisplayed() 75 | clickButtonInfiniteJokes() 76 | } 77 | 78 | infiniteFragmentRobot { 79 | assertRecyclerViewDisplayed() 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /app/src/androidTest/java/prieto/fernando/jokesapp/detail/DetailFragmentRobot.kt: -------------------------------------------------------------------------------- 1 | package prieto.fernando.jokesapp.detail 2 | 3 | import androidx.test.espresso.Espresso 4 | import androidx.test.espresso.assertion.ViewAssertions 5 | import androidx.test.espresso.matcher.ViewMatchers 6 | import androidx.test.espresso.matcher.ViewMatchers.withId 7 | import prieto.fernando.jokesapp.R 8 | 9 | fun detailFragmentRobot(func: DetailFragmentRobot.() -> Unit) = 10 | DetailFragmentRobot().apply { func() } 11 | 12 | class DetailFragmentRobot { 13 | fun assertJokeTextViewDisplayed() = apply { 14 | Espresso.onView(DetailFragmentRobot.selectedJokeTextViewMatcher) 15 | .check(ViewAssertions.matches(ViewMatchers.isDisplayed())) 16 | } 17 | 18 | fun assertJokeTex(joke: String) = apply { 19 | Espresso.onView(selectedJokeTextViewMatcher) 20 | .check(ViewAssertions.matches(ViewMatchers.withText(joke))) 21 | } 22 | 23 | companion object { 24 | private val selectedJokeTextViewMatcher = withId(R.id.selected_joke) 25 | } 26 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/prieto/fernando/jokesapp/detail/DetailFragmentTest.kt: -------------------------------------------------------------------------------- 1 | package prieto.fernando.jokesapp.detail 2 | 3 | import androidx.test.ext.junit.runners.AndroidJUnit4 4 | import okhttp3.mockwebserver.MockWebServer 5 | import org.junit.After 6 | import org.junit.Before 7 | import org.junit.Rule 8 | import org.junit.Test 9 | import org.junit.runner.RunWith 10 | import prieto.fernando.jokesapp.BuildConfig 11 | import prieto.fernando.jokesapp.dashboard.dashboardFragmentRobot 12 | import prieto.fernando.jokesapp.infinite.infiniteFragmentRobot 13 | import prieto.fernando.jokesapp.utils.TestConfigurationRule 14 | import prieto.fernando.jokesapp.webmock.SuccessDispatcher 15 | 16 | @RunWith(AndroidJUnit4::class) 17 | class DetailFragmentTest { 18 | 19 | @get:Rule 20 | val espressoRule = TestConfigurationRule() 21 | 22 | private val mockWebServer = MockWebServer() 23 | 24 | @Before 25 | fun setup() { 26 | mockWebServer.start(BuildConfig.PORT) 27 | } 28 | 29 | @After 30 | fun teardown() { 31 | mockWebServer.shutdown() 32 | } 33 | 34 | @Test 35 | fun textFieldShown() { 36 | mockWebServer.dispatcher = SuccessDispatcher() 37 | 38 | dashboardFragmentRobot { 39 | assertButtonInfiniteJokesDisplayed() 40 | clickButtonInfiniteJokes() 41 | } 42 | 43 | infiniteFragmentRobot { 44 | assertRecyclerViewDisplayed() 45 | clickItem(1) 46 | } 47 | 48 | 49 | detailFragmentRobot { 50 | assertJokeTextViewDisplayed() 51 | assertJokeTex("Divide Chuck Norris by zero and you will in fact get one........one bad-ass that is.") 52 | } 53 | } 54 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/prieto/fernando/jokesapp/infinite/InfiniteFragmentRobot.kt: -------------------------------------------------------------------------------- 1 | package prieto.fernando.jokesapp.infinite 2 | 3 | import androidx.test.espresso.Espresso.onView 4 | import androidx.test.espresso.action.ViewActions 5 | import androidx.test.espresso.assertion.ViewAssertions.matches 6 | import androidx.test.espresso.matcher.ViewMatchers.isDisplayed 7 | import androidx.test.espresso.matcher.ViewMatchers.withId 8 | import prieto.fernando.jokesapp.R 9 | import prieto.fernando.jokesapp.utils.RecyclerViewItemCountAssertion 10 | import prieto.fernando.jokesapp.utils.RecyclerViewMatcher 11 | 12 | fun infiniteFragmentRobot(func: InfiniteFragmentRobot.() -> Unit) = 13 | InfiniteFragmentRobot().apply { func() } 14 | 15 | class InfiniteFragmentRobot { 16 | 17 | fun assertRecyclerViewDisplayed() = apply { 18 | onView(recyclerViewMatcher).check(matches(isDisplayed())) 19 | } 20 | 21 | fun assertFirstItemsGroup() = apply { 22 | onView(recyclerViewMatcher).check( 23 | RecyclerViewItemCountAssertion(12) 24 | ) 25 | } 26 | 27 | fun clickItem(position: Int) = apply { 28 | val itemMatcher = RecyclerViewMatcher(recyclerViewId).atPosition(position) 29 | onView(itemMatcher).perform(ViewActions.click()) 30 | } 31 | 32 | companion object { 33 | private const val recyclerViewId = R.id.infinite_jokes_recycler 34 | 35 | private val recyclerViewMatcher = withId(R.id.infinite_jokes_recycler) 36 | } 37 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/prieto/fernando/jokesapp/infinite/InfiniteFragmentTest.kt: -------------------------------------------------------------------------------- 1 | package prieto.fernando.jokesapp.infinite 2 | 3 | import androidx.test.ext.junit.runners.AndroidJUnit4 4 | import okhttp3.mockwebserver.MockWebServer 5 | import org.junit.After 6 | import org.junit.Before 7 | import org.junit.Rule 8 | import org.junit.Test 9 | import org.junit.runner.RunWith 10 | import prieto.fernando.jokesapp.BuildConfig 11 | import prieto.fernando.jokesapp.dashboard.dashboardFragmentRobot 12 | import prieto.fernando.jokesapp.utils.TestConfigurationRule 13 | import prieto.fernando.jokesapp.webmock.SuccessDispatcher 14 | 15 | @RunWith(AndroidJUnit4::class) 16 | class InfiniteFragmentTest { 17 | 18 | @get:Rule 19 | val espressoRule = TestConfigurationRule() 20 | 21 | private val mockWebServer = MockWebServer() 22 | 23 | @Before 24 | fun setup() { 25 | mockWebServer.start(BuildConfig.PORT) 26 | } 27 | 28 | @After 29 | fun teardown() { 30 | mockWebServer.shutdown() 31 | } 32 | 33 | @Test 34 | fun justTwelveItemsListed() { 35 | mockWebServer.dispatcher = SuccessDispatcher() 36 | 37 | dashboardFragmentRobot { 38 | assertButtonInfiniteJokesDisplayed() 39 | clickButtonInfiniteJokes() 40 | } 41 | 42 | infiniteFragmentRobot { 43 | assertRecyclerViewDisplayed() 44 | assertFirstItemsGroup() 45 | } 46 | } 47 | 48 | @Test 49 | fun clickItem() { 50 | mockWebServer.dispatcher = SuccessDispatcher() 51 | 52 | dashboardFragmentRobot { 53 | assertButtonInfiniteJokesDisplayed() 54 | clickButtonInfiniteJokes() 55 | } 56 | 57 | infiniteFragmentRobot { 58 | assertRecyclerViewDisplayed() 59 | clickItem(1) 60 | } 61 | } 62 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/prieto/fernando/jokesapp/utils/RecyclerViewItemCountAssertion.kt: -------------------------------------------------------------------------------- 1 | package prieto.fernando.jokesapp.utils 2 | 3 | import android.view.View 4 | import androidx.recyclerview.widget.RecyclerView 5 | import androidx.test.espresso.NoMatchingViewException 6 | import androidx.test.espresso.ViewAssertion 7 | import androidx.test.espresso.matcher.ViewMatchers.assertThat 8 | import org.hamcrest.Matchers 9 | 10 | class RecyclerViewItemCountAssertion(private val expectedCount: Int) : ViewAssertion { 11 | 12 | override fun check(view: View?, noViewFoundException: NoMatchingViewException?) { 13 | noViewFoundException?.let { 14 | throw noViewFoundException 15 | } 16 | 17 | val recyclerView = view as RecyclerView 18 | val adapter = recyclerView.adapter 19 | assertThat(adapter?.itemCount, Matchers.`is`(expectedCount)) 20 | } 21 | 22 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/prieto/fernando/jokesapp/utils/RecyclerViewMatcher.kt: -------------------------------------------------------------------------------- 1 | package prieto.fernando.jokesapp.utils 2 | 3 | import android.content.res.Resources 4 | import android.view.View 5 | import androidx.recyclerview.widget.RecyclerView 6 | import org.hamcrest.Description 7 | import org.hamcrest.Matcher 8 | import org.hamcrest.TypeSafeMatcher 9 | 10 | class RecyclerViewMatcher (private val recyclerViewId: Int) { 11 | 12 | fun atPosition(position: Int): Matcher { 13 | return atPositionOnView(position, -1) 14 | } 15 | 16 | fun atPositionOnView(position: Int, targetViewId: Int): Matcher { 17 | return object : TypeSafeMatcher() { 18 | var resources: Resources? = null 19 | var childView: View? = null 20 | 21 | override fun describeTo(description: Description) { 22 | var idDescription = Integer.toString(recyclerViewId) 23 | if (this.resources != null) { 24 | idDescription = try { 25 | this.resources?.getResourceName(recyclerViewId).orEmpty() 26 | } catch (var4: Resources.NotFoundException) { 27 | String.format( 28 | "%s (resource name not found)", 29 | Integer.valueOf(recyclerViewId) 30 | ) 31 | } 32 | } 33 | 34 | description.appendText("with id: $idDescription") 35 | } 36 | 37 | public override fun matchesSafely(view: View): Boolean { 38 | this.resources = view.resources 39 | 40 | if (childView == null) { 41 | val recyclerView = 42 | view.rootView.findViewById(recyclerViewId) as RecyclerView 43 | if (recyclerView.id == recyclerViewId) { 44 | childView = 45 | recyclerView.findViewHolderForAdapterPosition(position)?.itemView 46 | } else { 47 | return false 48 | } 49 | } 50 | 51 | return if (targetViewId == -1) { 52 | view === childView 53 | } else { 54 | val targetView = childView?.findViewById(targetViewId) 55 | view === targetView 56 | } 57 | } 58 | } 59 | } 60 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/prieto/fernando/jokesapp/utils/TestConfigurationRule.kt: -------------------------------------------------------------------------------- 1 | package prieto.fernando.jokesapp.utils 2 | 3 | import androidx.test.core.app.launchActivity 4 | import org.junit.rules.TestWatcher 5 | import org.junit.runner.Description 6 | import prieto.fernando.jokesapp.view.MainActivity 7 | import prieto.fernando.jokesapp.webmock.injectTestConfiguration 8 | 9 | class TestConfigurationRule : TestWatcher() { 10 | override fun starting(description: Description?) { 11 | super.starting(description) 12 | 13 | injectTestConfiguration {} 14 | launchActivity() 15 | } 16 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/prieto/fernando/jokesapp/webmock/AssetReaderUtil.kt: -------------------------------------------------------------------------------- 1 | package prieto.fernando.jokesapp.webmock 2 | 3 | import android.content.Context 4 | import java.io.IOException 5 | import java.io.InputStream 6 | import java.io.InputStreamReader 7 | 8 | object AssetReaderUtil { 9 | fun asset(context: Context, assetPath: String): String { 10 | try { 11 | val inputStream = context.assets.open("network_files/$assetPath") 12 | return inputStreamToString(inputStream, "UTF-8") 13 | } catch (e: IOException) { 14 | throw RuntimeException(e) 15 | } 16 | } 17 | 18 | private fun inputStreamToString(inputStream: InputStream, charsetName: String): String { 19 | val builder = StringBuilder() 20 | val reader = InputStreamReader(inputStream, charsetName) 21 | reader.readLines().forEach { 22 | builder.append(it) 23 | } 24 | return builder.toString() 25 | } 26 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/prieto/fernando/jokesapp/webmock/ErrorDispatcher.kt: -------------------------------------------------------------------------------- 1 | package prieto.fernando.jokesapp.webmock 2 | 3 | import okhttp3.mockwebserver.Dispatcher 4 | import okhttp3.mockwebserver.MockResponse 5 | import okhttp3.mockwebserver.RecordedRequest 6 | 7 | class ErrorDispatcher : Dispatcher() { 8 | override fun dispatch(request: RecordedRequest): MockResponse { 9 | return MockResponse().setResponseCode(404) 10 | } 11 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/prieto/fernando/jokesapp/webmock/MockTestRunner.kt: -------------------------------------------------------------------------------- 1 | package prieto.fernando.jokesapp.webmock 2 | 3 | import android.app.Application 4 | import android.content.Context 5 | import android.os.Bundle 6 | import android.os.StrictMode 7 | import androidx.test.runner.AndroidJUnitRunner 8 | import prieto.fernando.jokesapp.JokesApp 9 | 10 | class MockTestRunner : AndroidJUnitRunner() { 11 | override fun onCreate(arguments: Bundle?) { 12 | StrictMode.setThreadPolicy(StrictMode.ThreadPolicy.Builder().permitAll().build()) 13 | super.onCreate(arguments) 14 | } 15 | 16 | override fun newApplication( 17 | cl: ClassLoader?, 18 | className: String?, 19 | context: Context? 20 | ): Application { 21 | return super.newApplication(cl, JokesApp::class.java.name, context) 22 | } 23 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/prieto/fernando/jokesapp/webmock/SuccessDispatcher.kt: -------------------------------------------------------------------------------- 1 | package prieto.fernando.jokesapp.webmock 2 | 3 | import android.content.Context 4 | import android.net.Uri 5 | import androidx.test.platform.app.InstrumentationRegistry 6 | import okhttp3.mockwebserver.Dispatcher 7 | import okhttp3.mockwebserver.MockResponse 8 | import okhttp3.mockwebserver.RecordedRequest 9 | import prieto.fernando.jokesapp.webmock.AssetReaderUtil.asset 10 | 11 | const val RANDOM_JOKE = "/jokes/random" 12 | const val CUSTOM_JOKE = "/jokes/random?firstName=Fernando&lastName=Prieto" 13 | const val INFINITE_JOKES = "/jokes/random/12" 14 | const val RANDOM_JOKE_SUCCESS = "random_joke_success.json" 15 | const val CUSTOM_JOKE_SUCCESS = "custom_joke_success.json" 16 | const val INFINITE_JOKES_SUCCESS = "joke_list_success.json" 17 | 18 | class SuccessDispatcher( 19 | private val context: Context = InstrumentationRegistry.getInstrumentation().targetContext 20 | ) : Dispatcher() { 21 | private val responseFilesByPath: Map = mapOf( 22 | RANDOM_JOKE to RANDOM_JOKE_SUCCESS, 23 | CUSTOM_JOKE to CUSTOM_JOKE_SUCCESS, 24 | INFINITE_JOKES to INFINITE_JOKES_SUCCESS 25 | ) 26 | 27 | override fun dispatch(request: RecordedRequest): MockResponse { 28 | val errorResponse = MockResponse().setResponseCode(404) 29 | 30 | val pathWithoutQueryParams = Uri.parse(request.path).path ?: return errorResponse 31 | val responseFile = responseFilesByPath[pathWithoutQueryParams] 32 | 33 | return if (responseFile != null) { 34 | val responseBody = asset(context, responseFile) 35 | MockResponse().setResponseCode(200).setBody(responseBody) 36 | } else { 37 | errorResponse 38 | } 39 | } 40 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/prieto/fernando/jokesapp/webmock/TestConfigurationBuilder.kt: -------------------------------------------------------------------------------- 1 | package prieto.fernando.jokesapp.webmock 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import prieto.fernando.di.NetworkModule 5 | import prieto.fernando.jokesapp.BuildConfig 6 | import prieto.fernando.jokesapp.JokesApp 7 | import prieto.fernando.jokesapp.di.AppComponent 8 | import prieto.fernando.jokesapp.di.DaggerAppComponent 9 | 10 | 11 | class TestConfigurationBuilder { 12 | private val baseUrl: String ="http://127.0.0.1:${BuildConfig.PORT}" 13 | 14 | fun inject() { 15 | appComponent { 16 | networkModule(NetworkModule(baseUrl)) 17 | }.inject(requireTestedApplication()) 18 | } 19 | } 20 | 21 | fun injectTestConfiguration(block: TestConfigurationBuilder.() -> Unit) { 22 | TestConfigurationBuilder().apply(block).inject() 23 | } 24 | 25 | private fun appComponent(block: DaggerAppComponent.Builder.() -> Unit = {}): AppComponent = 26 | DaggerAppComponent.builder().apply(block).build() 27 | 28 | private fun requireTestedApplication() = 29 | (InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as JokesApp) -------------------------------------------------------------------------------- /app/src/debug/assets/network_files/custom_joke_success.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "success", 3 | "value": { 4 | "id": 5, 5 | "joke": "Chuck Norris lost his virginity before his dad did.", 6 | "categories": [ 7 | "explicit" 8 | ] 9 | } 10 | } -------------------------------------------------------------------------------- /app/src/debug/assets/network_files/joke_list_success.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "success", 3 | "value": [ 4 | { 5 | "id": 44, 6 | "joke": "Chuck Norris is the only man to ever defeat a brick wall in a game of tennis.", 7 | "categories": [] 8 | }, 9 | { 10 | "id": 390, 11 | "joke": "Divide Chuck Norris by zero and you will in fact get one........one bad-ass that is.", 12 | "categories": [] 13 | }, 14 | { 15 | "id": 290, 16 | "joke": "There are two types of people in the world... people that suck, and Chuck Norris.", 17 | "categories": [] 18 | }, 19 | { 20 | "id": 450, 21 | "joke": "Chuck Norris doesn't have disk latency because the hard drive knows to hurry the hell up.", 22 | "categories": [ 23 | "nerdy" 24 | ] 25 | }, 26 | { 27 | "id": 306, 28 | "joke": "Scientifically speaking, it is impossible to charge Chuck Norris with "obstruction of justice." This is because even Chuck Norris cannot be in two places at the same time.", 29 | "categories": [] 30 | }, 31 | { 32 | "id": 95, 33 | "joke": "On his birthday, Chuck Norris randomly selects one lucky child to be thrown into the sun.", 34 | "categories": [] 35 | }, 36 | { 37 | "id": 98, 38 | "joke": "In the beginning there was nothing...then Chuck Norris Roundhouse kicked that nothing in the face and said "Get a job". That is the story of the universe.", 39 | "categories": [] 40 | }, 41 | { 42 | "id": 45, 43 | "joke": "What was going through the minds of all of Chuck Norris' victims before they died? His shoe.", 44 | "categories": [] 45 | }, 46 | { 47 | "id": 13, 48 | "joke": "Chuck Norris once challenged Lance Armstrong in a "Who has more testicles?" contest. Chuck Norris won by 5.", 49 | "categories": [ 50 | "explicit" 51 | ] 52 | }, 53 | { 54 | "id": 135, 55 | "joke": "Chuck Norris roundhouse kicks don't really kill people. They wipe out their entire existence from the space-time continuum.", 56 | "categories": [] 57 | }, 58 | { 59 | "id": 313, 60 | "joke": "All roads lead to Chuck Norris. And by the transitive property, a roundhouse kick to the face.", 61 | "categories": [] 62 | }, 63 | { 64 | "id": 465, 65 | "joke": "Whiteboards are white because Chuck Norris scared them that way.", 66 | "categories": [] 67 | } 68 | ] 69 | } -------------------------------------------------------------------------------- /app/src/debug/assets/network_files/random_joke_success.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "success", 3 | "value": { 4 | "id": 5, 5 | "joke": "Chuck Norris lost his virginity before his dad did.", 6 | "categories": [ 7 | "explicit" 8 | ] 9 | } 10 | } -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | 8 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /app/src/main/ic_launcher-web.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ferPrieto/MVVM-Modularized/aecbd0054faa15beb29aac1c694eff9db0179432/app/src/main/ic_launcher-web.png -------------------------------------------------------------------------------- /app/src/main/java/prieto/fernando/jokesapp/JokesApp.kt: -------------------------------------------------------------------------------- 1 | package prieto.fernando.jokesapp 2 | 3 | import dagger.android.AndroidInjector 4 | import dagger.android.DaggerApplication 5 | import prieto.fernando.jokesapp.di.DaggerAppComponent 6 | 7 | open class JokesApp : DaggerApplication() { 8 | override fun applicationInjector(): AndroidInjector = 9 | DaggerAppComponent.builder() 10 | .build() 11 | } 12 | -------------------------------------------------------------------------------- /app/src/main/java/prieto/fernando/jokesapp/di/ActivityScope.kt: -------------------------------------------------------------------------------- 1 | package prieto.fernando.jokesapp.di 2 | 3 | import javax.inject.Scope 4 | import kotlin.annotation.Retention 5 | 6 | @Scope 7 | @Retention(AnnotationRetention.RUNTIME) 8 | internal annotation class ActivityScope 9 | -------------------------------------------------------------------------------- /app/src/main/java/prieto/fernando/jokesapp/di/AppComponent.kt: -------------------------------------------------------------------------------- 1 | package prieto.fernando.jokesapp.di 2 | 3 | import dagger.Component 4 | import dagger.android.AndroidInjector 5 | import dagger.android.support.AndroidSupportInjectionModule 6 | import di.RepositoryModule 7 | import prieto.fernando.di.ChuckNorrisApiModule 8 | import prieto.fernando.di.NetworkModule 9 | import prieto.fernando.jokesapp.JokesApp 10 | import javax.inject.Singleton 11 | 12 | @Component( 13 | modules = [ 14 | AndroidSupportInjectionModule::class, 15 | AppModule::class, 16 | ChuckNorrisApiModule::class, 17 | NetworkModule::class, 18 | RepositoryModule::class, 19 | MainActivityModule::class] 20 | ) 21 | @Singleton 22 | interface AppComponent : AndroidInjector 23 | -------------------------------------------------------------------------------- /app/src/main/java/prieto/fernando/jokesapp/di/AppModule.kt: -------------------------------------------------------------------------------- 1 | package prieto.fernando.jokesapp.di 2 | 3 | import android.app.Application 4 | import android.content.Context 5 | import android.content.res.Resources 6 | import dagger.Module 7 | import dagger.Provides 8 | import prieto.fernando.jokesapp.JokesApp 9 | import javax.inject.Singleton 10 | import prieto.fernando.presentation.AppSchedulerProvider 11 | import prieto.fernando.presentation.SchedulerProvider 12 | import javax.inject.Named 13 | 14 | @Module 15 | open class AppModule { 16 | 17 | @Provides 18 | fun provideContext(app: Application): Context = app.applicationContext 19 | 20 | @Provides 21 | fun provideResources(app: Application): Resources = app.resources 22 | 23 | @Provides 24 | @Singleton 25 | fun provideSchedulerProvider(): SchedulerProvider = AppSchedulerProvider() 26 | 27 | } 28 | -------------------------------------------------------------------------------- /app/src/main/java/prieto/fernando/jokesapp/di/MainActivityModule.kt: -------------------------------------------------------------------------------- 1 | package prieto.fernando.jokesapp.di 2 | 3 | import dagger.Module 4 | import dagger.Provides 5 | import dagger.android.ContributesAndroidInjector 6 | import prieto.fernando.jokesapp.view.MainActivity 7 | import prieto.fernando.jokesapp.view.custom.CustomJokeFragment 8 | import prieto.fernando.jokesapp.view.dashboard.DashboardFragment 9 | import prieto.fernando.jokesapp.view.detail.DetailFragment 10 | import prieto.fernando.jokesapp.view.detail.DetailViewModel 11 | import prieto.fernando.jokesapp.view.infinite.InfiniteJokesFragment 12 | import prieto.fernando.presentation.ViewModelProviderFactory 13 | import prieto.fernando.presentation.custom.CustomJokeViewModel 14 | import prieto.fernando.presentation.dashboard.DashboardViewModel 15 | import prieto.fernando.presentation.infinite.InfiniteJokesViewModel 16 | import prieto.fernando.presentation.main.MainViewModel 17 | 18 | @Module 19 | internal abstract class MainActivityModule { 20 | @ActivityScope 21 | @ContributesAndroidInjector 22 | internal abstract fun bindMainActivity(): MainActivity 23 | 24 | @ContributesAndroidInjector 25 | internal abstract fun bindDashboardFragment(): DashboardFragment 26 | 27 | @ContributesAndroidInjector 28 | internal abstract fun bindCustomJokeFragment(): CustomJokeFragment 29 | 30 | @ContributesAndroidInjector 31 | internal abstract fun bindInfiniteJokesFragment(): InfiniteJokesFragment 32 | 33 | @ContributesAndroidInjector 34 | internal abstract fun bindDetailFragment(): DetailFragment 35 | 36 | @Module 37 | companion object { 38 | @Provides 39 | @JvmStatic 40 | internal fun provideMainViewModelFactory(viewModel: MainViewModel): ViewModelProviderFactory { 41 | return ViewModelProviderFactory(viewModel) 42 | } 43 | 44 | @Provides 45 | @JvmStatic 46 | internal fun provideDashboardViewModelFactory(dashboardViewModel: DashboardViewModel): ViewModelProviderFactory { 47 | return ViewModelProviderFactory(dashboardViewModel) 48 | } 49 | 50 | @Provides 51 | @JvmStatic 52 | internal fun provideCustomJokeViewModelFactory(customJokeViewModel: CustomJokeViewModel): ViewModelProviderFactory { 53 | return ViewModelProviderFactory(customJokeViewModel) 54 | } 55 | 56 | @Provides 57 | @JvmStatic 58 | internal fun provideInfiniteJokesViewModelFactory(infiniteJokesViewModel: InfiniteJokesViewModel): ViewModelProviderFactory { 59 | return ViewModelProviderFactory(infiniteJokesViewModel) 60 | } 61 | 62 | @Provides 63 | @JvmStatic 64 | internal fun provideDetailViewModelFactory(detailViewModel: DetailViewModel): ViewModelProviderFactory { 65 | return ViewModelProviderFactory(detailViewModel) 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /app/src/main/java/prieto/fernando/jokesapp/view/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package prieto.fernando.jokesapp.view 2 | 3 | import android.os.Bundle 4 | import androidx.lifecycle.ViewModelProviders 5 | import androidx.navigation.Navigation 6 | import androidx.navigation.ui.NavigationUI 7 | import prieto.fernando.jokesapp.R 8 | import prieto.fernando.presentation.main.MainViewModel 9 | 10 | class MainActivity : prieto.fernando.ui.BaseActivity() { 11 | override fun onCreate(savedInstanceState: Bundle?) { 12 | super.onCreate(savedInstanceState) 13 | setContentView(R.layout.activity_main) 14 | setupNavigation() 15 | } 16 | 17 | private fun setupNavigation() { 18 | val navController = Navigation.findNavController(this, R.id.mainNavigationFragment) 19 | NavigationUI.setupActionBarWithNavController(this, navController) 20 | } 21 | 22 | override fun onSupportNavigateUp() = 23 | Navigation.findNavController(this, R.id.mainNavigationFragment).navigateUp() 24 | 25 | override val viewModel: MainViewModel by lazy { 26 | ViewModelProviders.of(this, vmFactory).get(MainViewModel::class.java) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /app/src/main/java/prieto/fernando/jokesapp/view/custom/CustomJokeFragment.kt: -------------------------------------------------------------------------------- 1 | package prieto.fernando.jokesapp.view.custom 2 | 3 | import android.os.Bundle 4 | import android.text.Editable 5 | import android.text.TextWatcher 6 | import android.view.LayoutInflater 7 | import android.view.ViewGroup 8 | import androidx.lifecycle.ViewModelProviders 9 | import androidx.navigation.fragment.findNavController 10 | import prieto.fernando.jokesapp.R 11 | import prieto.fernando.presentation.custom.CustomJokeViewModel 12 | import prieto.fernando.presentation.custom.model.NamesData 13 | import prieto.fernando.jokesapp.view.extension.observe 14 | import prieto.fernando.ui.BaseFragment 15 | import kotlinx.android.synthetic.main.fragment_custom_joke.button_done as doneButton 16 | import kotlinx.android.synthetic.main.fragment_custom_joke.first_name_text as firstName 17 | import kotlinx.android.synthetic.main.fragment_custom_joke.last_name_text as lastName 18 | 19 | class CustomJokeFragment : BaseFragment() { 20 | 21 | override fun onCreateView( 22 | inflater: LayoutInflater, 23 | container: ViewGroup?, 24 | savedInstanceState: Bundle? 25 | ) = inflater.inflate(R.layout.fragment_custom_joke, container, false)!! 26 | 27 | override fun onResume() { 28 | super.onResume() 29 | setupInputListeners() 30 | } 31 | 32 | private val namesData: NamesData 33 | get() = NamesData( 34 | firstName.text.toString(), 35 | lastName.text.toString() 36 | ) 37 | 38 | private val formTextWatcher = object : TextWatcher { 39 | override fun afterTextChanged(s: Editable?) { 40 | } 41 | 42 | override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) { 43 | } 44 | 45 | override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { 46 | viewModel.inputs.onNamesChanged(namesData) 47 | } 48 | } 49 | 50 | private fun setupInputListeners() { 51 | bindClickAction(doneButton) { 52 | viewModel.inputs.customRandomJoke(firstName.text.toString(), lastName.text.toString()) 53 | } 54 | firstName.addTextChangedListener(formTextWatcher) 55 | lastName.addTextChangedListener(formTextWatcher) 56 | } 57 | 58 | override val viewModel: CustomJokeViewModel by lazy { 59 | ViewModelProviders.of(this, vmFactory).get(CustomJokeViewModel::class.java).apply { 60 | observe(doneButtonEnabled(), ::changeDoneButtonState) 61 | observe(customRandomJokeRetrieved(), ::goBackToDashboard) 62 | observe(errorResource(), ::showErrorToast) 63 | } 64 | } 65 | 66 | private fun goBackToDashboard(unit: Unit?) { 67 | findNavController().popBackStack(R.id.dashboardFragment, false) 68 | } 69 | 70 | private fun changeDoneButtonState(enabled: Boolean?) { 71 | doneButton.isEnabled = enabled ?: false 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /app/src/main/java/prieto/fernando/jokesapp/view/dashboard/DashboardFragment.kt: -------------------------------------------------------------------------------- 1 | package prieto.fernando.jokesapp.view.dashboard 2 | 3 | import android.os.Bundle 4 | import android.view.LayoutInflater 5 | import android.view.ViewGroup 6 | import androidx.lifecycle.ViewModelProviders 7 | import androidx.navigation.fragment.findNavController 8 | import prieto.fernando.jokesapp.R 9 | import prieto.fernando.presentation.dashboard.DashboardViewModel 10 | import prieto.fernando.presentation.data.RandomJokeAndTitleResource 11 | import prieto.fernando.jokesapp.view.extension.observe 12 | import prieto.fernando.ui.BaseFragment 13 | import kotlinx.android.synthetic.main.fragment_dashboard.button_custom_joke as buttonCustomJoke 14 | import kotlinx.android.synthetic.main.fragment_dashboard.button_multiple_jokes as buttonMultipleJoke 15 | import kotlinx.android.synthetic.main.fragment_dashboard.button_random_joke as buttonRandomJoke 16 | 17 | class DashboardFragment : BaseFragment() { 18 | 19 | override fun onCreateView( 20 | inflater: LayoutInflater, 21 | container: ViewGroup?, 22 | savedInstanceState: Bundle? 23 | ) = inflater.inflate(R.layout.fragment_dashboard, container, false)!! 24 | 25 | override fun onResume() { 26 | super.onResume() 27 | setupInputListeners() 28 | viewModel.inputs.customRandomJokeForDialog() 29 | } 30 | 31 | private fun setupInputListeners() { 32 | bindClickAction(buttonRandomJoke) { 33 | viewModel.inputs.randomJoke() 34 | } 35 | bindClickAction(buttonCustomJoke) { 36 | viewModel.inputs.onCustomRandomJokeClicked() 37 | } 38 | bindClickAction(buttonMultipleJoke) { 39 | viewModel.inputs.onMultipleJokesClicked() 40 | } 41 | } 42 | 43 | override val viewModel: DashboardViewModel by lazy { 44 | ViewModelProviders.of(this, vmFactory).get(DashboardViewModel::class.java).apply { 45 | observe(navigateToCustomJoke(), ::navigateToCustomJokeFragment) 46 | observe(navigateToInfiniteJokes(), ::navigateToInfiniteJokesFragment) 47 | observe(customRandomJokeRetrieved(), ::resetCacheAndShowDialog) 48 | observe(randomJokeRetrieved(), ::showRandomJokeDialog) 49 | observe(errorResource(), ::showErrorToast) 50 | } 51 | } 52 | 53 | private fun navigateToCustomJokeFragment(unit: Unit?) { 54 | findNavController().navigate(R.id.goToCustomJokeFragment) 55 | } 56 | 57 | private fun navigateToInfiniteJokesFragment(unit: Unit?) { 58 | findNavController().navigate(R.id.goToInfiniteJokesFragment) 59 | } 60 | 61 | private fun resetCacheAndShowDialog(customRandomJoke: RandomJokeAndTitleResource?) { 62 | customRandomJoke?.let { 63 | viewModel.inputs.resetCustomJokeCache() 64 | showDialog( 65 | customRandomJoke.titleResource, 66 | customRandomJoke.randomJokeUiModel.joke 67 | ) 68 | } 69 | } 70 | 71 | private fun showRandomJokeDialog(randomJokeAndTitle: RandomJokeAndTitleResource?) { 72 | randomJokeAndTitle?.let { 73 | showDialog( 74 | randomJokeAndTitle.titleResource, 75 | randomJokeAndTitle.randomJokeUiModel.joke 76 | ) 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /app/src/main/java/prieto/fernando/jokesapp/view/detail/DetailFragment.kt: -------------------------------------------------------------------------------- 1 | package prieto.fernando.jokesapp.view.detail 2 | 3 | import android.os.Bundle 4 | import android.view.LayoutInflater 5 | import android.view.ViewGroup 6 | import androidx.lifecycle.ViewModelProviders 7 | import kotlinx.android.synthetic.main.fragment_detail.* 8 | import prieto.fernando.jokesapp.R 9 | import prieto.fernando.ui.BaseFragment 10 | 11 | class DetailFragment : BaseFragment() { 12 | 13 | override fun onCreateView( 14 | inflater: LayoutInflater, 15 | container: ViewGroup?, 16 | savedInstanceState: Bundle? 17 | ) = inflater.inflate(R.layout.fragment_detail, container, false)!! 18 | 19 | override fun onResume() { 20 | super.onResume() 21 | getBundledContents() 22 | } 23 | 24 | private fun getBundledContents() { 25 | arguments?.let { bundle -> 26 | val joke = bundle.getString(DETAIL_FRAGMENT_TYPE_ARG) 27 | ?: throw IllegalStateException("Joke should be provided") 28 | selected_joke.text = joke 29 | } 30 | } 31 | 32 | override val viewModel: DetailViewModel by lazy { 33 | ViewModelProviders.of(this, vmFactory).get(DetailViewModel::class.java) 34 | } 35 | 36 | companion object { 37 | const val DETAIL_FRAGMENT_TYPE_ARG = "joke" 38 | } 39 | } -------------------------------------------------------------------------------- /app/src/main/java/prieto/fernando/jokesapp/view/detail/DetailViewModel.kt: -------------------------------------------------------------------------------- 1 | package prieto.fernando.jokesapp.view.detail 2 | 3 | import prieto.fernando.presentation.BaseViewModel 4 | import javax.inject.Inject 5 | 6 | class DetailViewModel @Inject constructor() : BaseViewModel(){ 7 | 8 | } -------------------------------------------------------------------------------- /app/src/main/java/prieto/fernando/jokesapp/view/extension/Lifecycle.kt: -------------------------------------------------------------------------------- 1 | package prieto.fernando.jokesapp.view.extension 2 | 3 | import androidx.lifecycle.LifecycleOwner 4 | import androidx.lifecycle.LiveData 5 | import androidx.lifecycle.Observer 6 | 7 | fun > LifecycleOwner.observe(liveData: L, body: (T?) -> Unit) = 8 | liveData.observe(this, Observer(body)) -------------------------------------------------------------------------------- /app/src/main/java/prieto/fernando/jokesapp/view/infinite/InfiniteJokesFragment.kt: -------------------------------------------------------------------------------- 1 | package prieto.fernando.jokesapp.view.infinite 2 | 3 | import android.os.Bundle 4 | import android.view.LayoutInflater 5 | import android.view.View 6 | import android.view.ViewGroup 7 | import androidx.lifecycle.ViewModelProviders 8 | import androidx.navigation.fragment.findNavController 9 | import androidx.recyclerview.widget.LinearLayoutManager 10 | import kotlinx.android.synthetic.main.fragment_infinite_jokes.* 11 | import prieto.fernando.jokesapp.R 12 | import prieto.fernando.jokesapp.view.detail.DetailFragment 13 | import prieto.fernando.jokesapp.view.extension.observe 14 | import prieto.fernando.jokesapp.view.infinite.adapter.ClickListener 15 | import prieto.fernando.jokesapp.view.infinite.adapter.JokesAdapter 16 | import prieto.fernando.jokesapp.view.infinite.widget.InfiniteScrollListener 17 | import prieto.fernando.presentation.RandomJokeUiModel 18 | import prieto.fernando.presentation.infinite.InfiniteJokesViewModel 19 | import prieto.fernando.ui.BaseFragment 20 | import kotlinx.android.synthetic.main.fragment_infinite_jokes.infinite_jokes_recycler as infiniteRecyclerView 21 | 22 | class InfiniteJokesFragment : BaseFragment(), ClickListener { 23 | 24 | private var jokesAdapter: JokesAdapter? = null 25 | 26 | override fun onItemClicked(joke: String) { 27 | viewModel.onJokeSelected(joke) 28 | } 29 | 30 | override fun onCreateView( 31 | inflater: LayoutInflater, 32 | container: ViewGroup?, 33 | savedInstanceState: Bundle? 34 | ) = inflater.inflate(R.layout.fragment_infinite_jokes, container, false)!! 35 | 36 | private var isLoading = false 37 | 38 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 39 | super.onViewCreated(view, savedInstanceState) 40 | setupRecyclerView() 41 | } 42 | 43 | private fun setupRecyclerView() { 44 | jokesAdapter = JokesAdapter(this) 45 | infiniteRecyclerView.adapter = jokesAdapter 46 | val linearLayoutManager = LinearLayoutManager(context) 47 | infiniteRecyclerView.layoutManager = linearLayoutManager 48 | val endlessScrollListener = object : InfiniteScrollListener(linearLayoutManager) { 49 | override fun onLoadMore() { 50 | isLoading = true 51 | viewModel.multipleRandomJokes() 52 | } 53 | 54 | override fun isLoading(): Boolean { 55 | return isLoading 56 | } 57 | } 58 | infiniteRecyclerView.addOnScrollListener(endlessScrollListener) 59 | 60 | } 61 | 62 | override fun onResume() { 63 | super.onResume() 64 | viewModel.inputs.multipleRandomJokes() 65 | } 66 | 67 | override val viewModel: InfiniteJokesViewModel by lazy { 68 | ViewModelProviders.of(this, vmFactory).get(InfiniteJokesViewModel::class.java).apply { 69 | observe(multipleRandomJokesRetrieved(), ::addJokesToAdapter) 70 | observe(errorResource(), ::showErrorToast) 71 | observe(loading(), ::showLoading) 72 | observe(jokeSelected(), ::navigateToDetailFragment) 73 | } 74 | } 75 | 76 | private fun addJokesToAdapter(randomJokes: List?) { 77 | randomJokes?.let { 78 | isLoading = false 79 | jokesAdapter?.let { adapter -> 80 | adapter.setData(randomJokes) 81 | infiniteRecyclerView.adapter = adapter 82 | } 83 | } 84 | } 85 | 86 | private fun showLoading(isLoading: Boolean?) { 87 | isLoading?.let { 88 | progressBar.visibility = if (isLoading) { 89 | View.VISIBLE 90 | } else { 91 | View.GONE 92 | } 93 | } 94 | } 95 | 96 | private fun navigateToDetailFragment(joke: String?) { 97 | val args = Bundle().apply { 98 | putString(DetailFragment.DETAIL_FRAGMENT_TYPE_ARG, joke) 99 | } 100 | findNavController().navigate(R.id.goToDetailFragment, args) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /app/src/main/java/prieto/fernando/jokesapp/view/infinite/adapter/JokesAdapter.kt: -------------------------------------------------------------------------------- 1 | package prieto.fernando.jokesapp.view.infinite.adapter 2 | 3 | import android.view.LayoutInflater 4 | import android.view.View 5 | import android.view.ViewGroup 6 | import androidx.recyclerview.widget.RecyclerView 7 | import kotlinx.android.synthetic.main.item_joke.view.* 8 | import prieto.fernando.jokesapp.R 9 | import prieto.fernando.presentation.RandomJokeUiModel 10 | 11 | interface BindableAdapter { 12 | fun setData(data: T) 13 | } 14 | 15 | interface ClickListener { 16 | fun onItemClicked(joke: String) 17 | } 18 | 19 | class JokesAdapter (private val clickListener: ClickListener): RecyclerView.Adapter(), 20 | BindableAdapter> { 21 | private val jokes = mutableListOf() 22 | 23 | override fun setData(data: List) { 24 | jokes.addAll(data) 25 | notifyDataSetChanged() 26 | } 27 | 28 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): JokeHolder { 29 | val inflater = LayoutInflater.from(parent.context) 30 | return JokeHolder(inflater.inflate(R.layout.item_joke, parent, false)) 31 | } 32 | 33 | override fun getItemCount() = jokes.size 34 | 35 | override fun onBindViewHolder(holder: JokeHolder, position: Int) { 36 | holder.bind(jokes[position], clickListener) 37 | } 38 | 39 | class JokeHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { 40 | fun bind(randomJokeUiModel: RandomJokeUiModel, clickListener: ClickListener) { 41 | itemView.item_joke.text = randomJokeUiModel.joke 42 | itemView.setOnClickListener { 43 | clickListener.onItemClicked(randomJokeUiModel.joke) 44 | } 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /app/src/main/java/prieto/fernando/jokesapp/view/infinite/widget/InfiniteScrollListener.kt: -------------------------------------------------------------------------------- 1 | package prieto.fernando.jokesapp.view.infinite.widget 2 | 3 | import androidx.recyclerview.widget.LinearLayoutManager 4 | import androidx.recyclerview.widget.RecyclerView 5 | 6 | abstract class InfiniteScrollListener( 7 | private val linearLayoutManager: LinearLayoutManager 8 | ) : RecyclerView.OnScrollListener() { 9 | 10 | override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { 11 | if (dy > 0) { 12 | val visibleItemCount = linearLayoutManager.childCount 13 | val totalItemCount = linearLayoutManager.itemCount 14 | val firstVisibleItemPosition = linearLayoutManager.findFirstVisibleItemPosition() 15 | if (!isLoading()) { 16 | if ((visibleItemCount + firstVisibleItemPosition) >= totalItemCount 17 | && firstVisibleItemPosition >= 0 18 | ) { 19 | onLoadMore() 20 | } 21 | } 22 | } 23 | } 24 | 25 | abstract fun onLoadMore() 26 | abstract fun isLoading(): Boolean 27 | 28 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 12 | 13 | 19 | 22 | 25 | 26 | 27 | 28 | 34 | 35 | -------------------------------------------------------------------------------- /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/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 24 | 25 | -------------------------------------------------------------------------------- /app/src/main/res/layout/fragment_custom_joke.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 17 | 18 | 26 | 27 | 36 | 37 | 38 | 39 | 47 | 48 | 56 | 57 | 66 | 67 | 68 | 69 | 70 |