├── .editorconfig ├── .github └── workflows │ └── actions.yml ├── .gitignore ├── LICENSE ├── README.md ├── app ├── .gitignore ├── build.gradle.kts ├── proguard-rules.pro └── src │ └── main │ ├── AndroidManifest.xml │ ├── java │ └── dev │ │ └── programadorthi │ │ └── app │ │ ├── SplashActivity.kt │ │ └── SuperApplication.kt │ └── res │ ├── drawable-v24 │ └── ic_launcher_foreground.xml │ ├── drawable │ ├── ic_baseline_emoji_emotions_24.xml │ └── ic_launcher_background.xml │ ├── layout │ └── activity_splash.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-night │ └── themes.xml │ ├── values │ ├── colors.xml │ ├── strings.xml │ └── themes.xml │ └── xml │ └── backup_rules.xml ├── build.gradle.kts ├── buildSrc ├── .gitignore ├── build.gradle.kts ├── settings.gradle.kts └── src │ └── main │ └── kotlin │ ├── android-project.gradle.kts │ └── jvm-project.gradle.kts ├── detekt-config.yml ├── features └── norris-facts │ ├── di │ ├── .gitignore │ ├── build.gradle.kts │ └── src │ │ └── main │ │ └── kotlin │ │ └── dev │ │ └── programadorthi │ │ └── norris │ │ └── di │ │ └── NorrisModule.kt │ ├── domain-fake │ ├── .gitignore │ ├── build.gradle.kts │ └── src │ │ └── main │ │ └── kotlin │ │ └── dev │ │ └── programadorthi │ │ └── norris │ │ └── domain │ │ └── fake │ │ ├── data │ │ ├── FactsRepositoryFake.kt │ │ ├── FactsServiceFake.kt │ │ ├── LocalFactsRepositoryFake.kt │ │ └── RemoteFactsRepositoryFake.kt │ │ ├── provider │ │ ├── FactsStyleProviderFake.kt │ │ └── FactsTextProviderFake.kt │ │ ├── usecase │ │ └── FactsUseCaseFake.kt │ │ └── viewmodel │ │ ├── FactsViewModelFake.kt │ │ └── SearchFactsViewModelFake.kt │ ├── domain-impl │ ├── .gitignore │ ├── build.gradle.kts │ └── src │ │ └── main │ │ └── kotlin │ │ └── dev │ │ └── programadorthi │ │ └── norris │ │ └── domain │ │ ├── data │ │ ├── FactsRepositoryFactory.kt │ │ ├── FactsRepositoryImpl.kt │ │ ├── local │ │ │ ├── LocalFactsRepositoryFactory.kt │ │ │ └── LocalFactsRepositoryImpl.kt │ │ └── remote │ │ │ ├── FactsService.kt │ │ │ ├── mapper │ │ │ └── FactsMapper.kt │ │ │ ├── raw │ │ │ ├── FactRaw.kt │ │ │ └── FactsResponseRaw.kt │ │ │ └── repository │ │ │ ├── RemoteFactsRepositoryFactory.kt │ │ │ └── RemoteFactsRepositoryImpl.kt │ │ ├── usecase │ │ ├── FactsUseCaseFactory.kt │ │ └── FactsUseCaseImpl.kt │ │ └── viewmodel │ │ ├── FactsViewModelFactory.kt │ │ ├── FactsViewModelImpl.kt │ │ ├── SearchFactsViewModelFactory.kt │ │ └── SearchFactsViewModelImpl.kt │ ├── domain-test │ ├── .gitignore │ ├── build.gradle.kts │ └── src │ │ └── test │ │ └── kotlin │ │ └── dev │ │ └── programadorthi │ │ └── norris │ │ └── domain │ │ └── test │ │ ├── FactsMapperTest.kt │ │ ├── FactsRepositoryTest.kt │ │ ├── FactsUseCaseTest.kt │ │ ├── FactsViewModelTest.kt │ │ ├── LocalFactsRepositoryTest.kt │ │ ├── RemoteFactsRepositoryTest.kt │ │ └── SearchFactsViewModelTest.kt │ ├── domain │ ├── .gitignore │ ├── build.gradle.kts │ └── src │ │ └── main │ │ └── kotlin │ │ └── dev │ │ └── programadorthi │ │ └── norris │ │ └── domain │ │ ├── FactsBusiness.kt │ │ ├── data │ │ ├── LocalFactsRepository.kt │ │ └── RemoteFactsRepository.kt │ │ ├── model │ │ ├── Category.kt │ │ ├── Fact.kt │ │ ├── LastSearch.kt │ │ └── presentation │ │ │ └── FactViewData.kt │ │ ├── provider │ │ ├── FactsStyleProvider.kt │ │ └── FactsTextProvider.kt │ │ ├── repository │ │ └── FactsRepository.kt │ │ ├── usecase │ │ └── FactsUseCase.kt │ │ └── viewmodel │ │ ├── FactsViewModel.kt │ │ └── SearchFactsViewModel.kt │ ├── ui-fake │ ├── .gitignore │ ├── build.gradle.kts │ ├── consumer-rules.pro │ ├── proguard-rules.pro │ └── src │ │ └── main │ │ ├── AndroidManifest.xml │ │ ├── kotlin │ │ └── dev │ │ │ └── programadorthi │ │ │ └── norris │ │ │ └── ui │ │ │ └── fake │ │ │ ├── EmptyActivityFake.kt │ │ │ ├── NorrisApplicationFake.kt │ │ │ └── component │ │ │ ├── ChipsComponentActionsFake.kt │ │ │ ├── SearchEditTextComponentActionsFake.kt │ │ │ └── SuccessComponentActionsFake.kt │ │ └── res │ │ └── layout │ │ └── activity_empty_fake.xml │ ├── ui-test │ ├── .gitignore │ ├── build.gradle.kts │ └── src │ │ ├── main │ │ └── AndroidManifest.xml │ │ └── test │ │ ├── kotlin │ │ └── dev │ │ │ └── programadorthi │ │ │ └── norris │ │ │ └── ui │ │ │ └── test │ │ │ └── component │ │ │ ├── ChipsComponentTest.kt │ │ │ ├── ErrorComponentTest.kt │ │ │ ├── LoadingComponentTest.kt │ │ │ ├── SearchEditTextComponentTest.kt │ │ │ └── SuccessComponentTest.kt │ │ └── resources │ │ └── robolectric.properties │ └── ui │ ├── .gitignore │ ├── build.gradle.kts │ └── src │ └── main │ ├── AndroidManifest.xml │ ├── kotlin │ └── dev │ │ └── programadorthi │ │ └── norris │ │ └── ui │ │ ├── activity │ │ ├── FactsActivity.kt │ │ └── SearchFactsActivity.kt │ │ ├── adapter │ │ ├── FactsAdapter.kt │ │ └── FactsViewHolder.kt │ │ ├── component │ │ ├── ChipsComponent.kt │ │ ├── ErrorComponent.kt │ │ ├── LoadingComponent.kt │ │ ├── SearchEditTextComponent.kt │ │ └── SuccessComponent.kt │ │ ├── di │ │ └── NorrisUIModule.kt │ │ └── provider │ │ ├── FactsStyleProviderImpl.kt │ │ └── FactsTextProviderImpl.kt │ └── res │ ├── drawable │ ├── ic_search_white_24.xml │ ├── ic_share_black_24.xml │ └── shape_rectangle_solid_blue.xml │ ├── layout │ ├── activity_facts.xml │ ├── activity_search_facts.xml │ ├── item_fact.xml │ └── item_search_fact_category.xml │ ├── menu │ └── chuck_norris_menu.xml │ └── values │ └── strings.xml ├── generate_modules.sh ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── module_graph.png ├── proguard └── kotlinxserialization.pro ├── settings.gradle.kts ├── shared-database-di-android ├── .gitignore ├── build.gradle.kts ├── consumer-rules.pro ├── proguard-rules.pro └── src │ └── main │ ├── AndroidManifest.xml │ └── kotlin │ └── dev │ └── programadorthi │ └── shared │ └── database │ └── android │ └── SharedDatabaseAndroidModule.kt ├── shared-database-di ├── .gitignore ├── build.gradle.kts └── src │ └── main │ └── kotlin │ └── dev │ └── programadorthi │ └── shared │ └── database │ └── di │ └── SharedDatabaseModule.kt ├── shared-database-fake ├── .gitignore ├── build.gradle.kts └── src │ └── main │ └── kotlin │ └── dev │ └── programadorthi │ └── shared │ └── database │ └── fake │ └── SuperAppFake.kt ├── shared-database-test ├── .gitignore ├── build.gradle.kts └── src │ └── test │ └── kotlin │ └── dev │ └── programadorthi │ └── shared │ └── database │ └── test │ ├── CategoriesTest.kt │ ├── FactsTest.kt │ └── LastSearchTest.kt ├── shared-database ├── .gitignore ├── build.gradle.kts └── src │ └── main │ ├── kotlin │ └── dev │ │ └── programadorthi │ │ └── shared │ │ └── database │ │ └── DatabaseInjectionTags.kt │ └── sqldelight │ └── dev │ └── programadorthi │ └── shared │ └── database │ └── Norris.sq ├── shared-domain-di ├── .gitignore ├── build.gradle.kts └── src │ └── main │ └── kotlin │ └── dev │ └── programadorthi │ └── shared │ └── domain │ └── di │ └── SharedDomainModule.kt ├── shared-domain-fake ├── .gitignore ├── build.gradle.kts └── src │ └── main │ └── kotlin │ └── dev │ └── programadorthi │ └── shared │ └── domain │ └── fake │ ├── ConnectionCheckFake.kt │ ├── CrashReportFake.kt │ └── SharedTextProviderFake.kt ├── shared-domain-test ├── .gitignore ├── build.gradle.kts └── src │ └── test │ └── kotlin │ └── dev │ └── programadorthi │ └── shared │ └── domain │ └── exception │ └── NetworkingErrorMapperTest.kt ├── shared-domain ├── .gitignore ├── build.gradle.kts └── src │ └── main │ └── kotlin │ └── dev │ └── programadorthi │ └── shared │ └── domain │ ├── DomainInjectionTags.kt │ ├── Result.kt │ ├── UIState.kt │ ├── exception │ ├── NetworkingError.kt │ ├── NetworkingErrorMapper.kt │ └── NetworkingErrorMapperImpl.kt │ ├── ext │ └── ResultExt.kt │ ├── flow │ └── PropertyUIStateFlow.kt │ ├── network │ └── ConnectionCheck.kt │ ├── persist │ └── PreferencesManager.kt │ ├── provider │ └── SharedTextProvider.kt │ ├── report │ └── CrashReport.kt │ └── viewmodel │ ├── ViewModel.kt │ └── ViewModelScope.kt ├── shared-network-di ├── .gitignore ├── build.gradle.kts └── src │ └── main │ └── kotlin │ └── dev │ └── programadorthi │ └── shared │ └── network │ └── di │ └── SharedNetworkModule.kt ├── shared-network-fake ├── .gitignore ├── build.gradle.kts └── src │ └── main │ └── kotlin │ └── dev │ └── programadorthi │ └── shared │ └── network │ └── fake │ ├── NetworkManagerFake.kt │ └── RemoteMapperFake.kt ├── shared-network-test ├── .gitignore ├── build.gradle.kts └── src │ └── test │ └── kotlin │ └── dev │ └── programadorthi │ └── shared │ └── network │ └── manager │ └── NetworkManagerTest.kt ├── shared-network ├── .gitignore ├── build.gradle.kts └── src │ └── main │ └── kotlin │ └── dev │ └── programadorthi │ └── shared │ └── network │ ├── JsonParser.kt │ ├── NetworkInjectionTags.kt │ ├── manager │ ├── DefaultNetworkManager.kt │ └── NetworkManager.kt │ └── mapper │ └── RemoteMapper.kt ├── shared-retrofit-di ├── .gitignore ├── build.gradle.kts └── src │ └── main │ └── java │ └── dev │ └── programadorthi │ └── shared │ └── retrofit │ └── di │ └── SharedRetrofitModule.kt ├── shared-retrofit ├── .gitignore ├── build.gradle.kts └── src │ └── main │ └── kotlin │ └── dev │ └── programadorthi │ └── shared │ └── retrofit │ └── RetrofitBuilder.kt ├── shared-ui-di ├── .gitignore ├── build.gradle.kts ├── consumer-rules.pro ├── proguard-rules.pro └── src │ └── main │ ├── AndroidManifest.xml │ └── kotlin │ └── dev │ └── programadorthi │ └── shared │ └── ui │ └── di │ ├── SharedUIModule.kt │ └── ext │ └── ViewModelExt.kt └── shared-ui ├── .gitignore ├── build.gradle.kts ├── consumer-rules.pro ├── proguard-rules.pro └── src └── main ├── AndroidManifest.xml ├── kotlin └── dev │ └── programadorthi │ └── shared │ └── ui │ ├── ext │ └── ViewExt.kt │ ├── network │ ├── ConnectionCheckFactory.kt │ └── ConnectionCheckImpl.kt │ ├── provider │ ├── SharedTextProviderFactory.kt │ └── SharedTextProviderImpl.kt │ ├── report │ ├── CrashReportFactory.kt │ └── CrashReportImpl.kt │ └── viewmodel │ ├── ViewModelContainer.kt │ ├── ViewModelFactory.kt │ └── ViewModelLazy.kt └── res └── values └── strings.xml /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | [*.{kt,kts}] 3 | # possible values: number (e.g. 2), "unset" (makes ktlint ignore indentation completely) 4 | indent_size=4 5 | # true (recommended) / false 6 | insert_final_newline=true 7 | # possible values: number (e.g. 120) (package name, imports & comments are ignored), "off" 8 | # it's automatically set to 100 on `ktlint --android ...` (per Android Kotlin Style Guide) 9 | max_line_length=100 -------------------------------------------------------------------------------- /.github/workflows/actions.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: actions 4 | 5 | # Controls when the action will run. 6 | on: 7 | # Triggers the workflow on push or pull request events but only for the main branch 8 | push: 9 | branches: [ main, 'main-hilt' ] 10 | pull_request: 11 | branches: [ main, 'main-hilt' ] 12 | 13 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 14 | jobs: 15 | # This workflow contains a single job called "build" 16 | build: 17 | # The type of runner that the job will run on 18 | runs-on: ubuntu-latest 19 | 20 | # Steps represent a sequence of tasks that will be executed as part of the job 21 | steps: 22 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 23 | - uses: actions/checkout@v2.3.4 24 | 25 | - name: Setup Java JDK 26 | uses: actions/setup-java@v2.0.0 27 | with: 28 | java-version: '8' 29 | distribution: 'adopt' 30 | 31 | # WARNING: avoid run check function in large projects. It's an expansive task. I'll change this in the future. 32 | - name: Run all check tasks 33 | run: ./gradlew check 34 | 35 | # WARNING: building one feature only. Update in the future 36 | - name: Build debug APK on development flavor 37 | run: ./gradlew :app:assembleDevDebug 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Built application files 2 | *.apk 3 | *.ap_ 4 | *.aab 5 | 6 | # Files for the ART/Dalvik VM 7 | *.dex 8 | 9 | # Java class files 10 | *.class 11 | 12 | # Generated files 13 | bin/ 14 | gen/ 15 | out/ 16 | # Uncomment the following line in case you need and you don't have the release build type files in your app 17 | # release/ 18 | 19 | # Gradle files 20 | .gradle/ 21 | build/ 22 | 23 | # Local configuration file (sdk path, etc) 24 | local.properties 25 | 26 | # Proguard folder generated by Eclipse 27 | # proguard/ 28 | 29 | # Log Files 30 | *.log 31 | 32 | # Android Studio Navigation editor temp files 33 | .navigation/ 34 | 35 | # Android Studio captures folder 36 | captures/ 37 | 38 | # IntelliJ 39 | *.iml 40 | .idea 41 | 42 | # Keystore files 43 | # Uncomment the following lines if you do not want to check your keystore files in. 44 | #*.jks 45 | #*.keystore 46 | 47 | # External native build folder generated in Android Studio 2.2 and later 48 | .externalNativeBuild 49 | 50 | # Google Services (e.g. APIs or Firebase) 51 | # google-services.json 52 | 53 | # Freeline 54 | freeline.py 55 | freeline/ 56 | freeline_project_description.json 57 | 58 | # fastlane 59 | fastlane/report.xml 60 | fastlane/Preview.html 61 | fastlane/screenshots 62 | fastlane/test_output 63 | fastlane/readme.md 64 | 65 | # Version control 66 | vcs.xml 67 | 68 | # lint 69 | lint/intermediates/ 70 | lint/generated/ 71 | lint/outputs/ 72 | lint/tmp/ 73 | # lint/reports/ 74 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Thiago Santos 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # android-super-app 2 | [![Actions Status](https://github.com/programadorthi/android-super-app/workflows/actions/badge.svg)](https://github.com/programadorthi/android-super-app/actions) 3 | [![Kotlin](https://img.shields.io/badge/kotlin-1.4.30-blue.svg?logo=kotlin)](http://kotlinlang.org) 4 | [![Coroutines](https://img.shields.io/badge/Kotlin-Coroutines-orange)](https://kotlinlang.org/docs/coroutines-guide.html) 5 |
6 |
7 | An Android app with many challenge modules and SOLID at all. 8 | 9 | ## Features 10 | * [Kotlin Coroutines](https://github.com/Kotlin/kotlinx.coroutines) with Flow (State Flow) 11 | * [Kotlin Serialization](https://github.com/Kotlin/kotlinx.serialization) 12 | * [SQLDelight](https://github.com/cashapp/sqldelight) for local persistence 13 | * [Kodein](https://github.com/Kodein-Framework/Kodein-DI) as dependency injection framework 14 | * [Retrofit](https://github.com/square/retrofit) a type-safe HTTP client for Android and the JVM 15 | * Clean Architecture with MVVM 16 | * Kotlin Gradle DSL 17 | * Build system customized combining Kotlin DSL and Gradle [Composing Builds](https://docs.gradle.org/current/userguide/composite_builds.html) 18 | 19 | ## Build system 20 | The project is using two gradle [includeBuild](https://docs.gradle.org/current/userguide/composite_builds.html) projects to manage dependencies and projects setup instead of [buildSrc](https://docs.gradle.org/current/userguide/organizing_gradle_projects.html#sec:build_sources). From gradle docs: 21 | > A change in buildSrc causes the whole project to become out-of-date. Thus, when making small incremental changes, the --no-rebuild command-line option is often helpful to get faster feedback. Remember to run a full build regularly or at least when you’re done, though. 22 | - **dependencies**: As name says the project manage all dependencies definitions to set as implementation in other projects. Build plugin dependencies are here too. 23 | - **configurations**: It has plugins and configurations that must be applied to other projects. Apply build plugins, apply common plugins to any project type, setup JVM target and compatibilities and Android application or library default configs. 24 | 25 | ## Project structures 26 | 27 |
28 | 29 | - **shared-module**: are modules that are base project to other feature concrete layers. 30 | - **domain**: are modules with abstraction and contracts to be implemented. 31 | - **module-di**: are modules that contains dependency injection setup only. 32 | - **module-fake**: are modules with fake implementations to be used in test modules. 33 | - **module-impl**: are modules with implementations to abstractions defined by domain. 34 | - **module-test**: are modules to do unit tests with JUnit or Robolectric. 35 | - **module-test-android**: are modules to do UI tests with Espresso. 36 | - **ui**: are modules with platform specific implementations. In our case Android code only. 37 | 38 | ## Prerequisite 39 | To build this project, you require: 40 | - Android Studio 4.1.3 41 | - Gradle 7.0 42 | 43 | ## Credits 44 | - [Reaktive team](https://github.com/badoo/Reaktive) and they build system structure. 45 | - [@vRallev](https://github.com/vRallev) for [Android at Scale @Square](https://www.droidcon.com/media-detail?video=380843878) 46 | - All team/people behind frameworks in the Features section above 47 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /app/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("com.android.application") 3 | id("android-project") 4 | } 5 | 6 | android { 7 | defaultConfig { 8 | // TODO: should be this fields module scoped? 9 | buildConfigField("String", "BASE_URL", "\"https://api.chucknorris.io/jokes/\"") 10 | buildConfigField("String", "DATABASE_NAME", "\"super_app.db\"") 11 | } 12 | } 13 | 14 | dependencies { 15 | implementation(projects.sharedDomainDi) 16 | implementation(projects.sharedNetworkDi) 17 | implementation(projects.sharedRetrofitDi) 18 | implementation(projects.sharedDatabaseDiAndroid) 19 | implementation(projects.features.norrisFacts.ui) 20 | implementation(libs.kodein.di) 21 | implementation(libs.kodein.android) 22 | implementation(libs.bundles.android.common) 23 | } 24 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /app/src/main/java/dev/programadorthi/app/SplashActivity.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.app 2 | 3 | import android.content.Intent 4 | import android.os.Bundle 5 | import android.os.Handler 6 | import android.os.Looper 7 | import androidx.appcompat.app.AppCompatActivity 8 | import dev.programadorthi.norris.ui.activity.FactsActivity 9 | 10 | class SplashActivity : AppCompatActivity() { 11 | override fun onCreate(savedInstanceState: Bundle?) { 12 | super.onCreate(savedInstanceState) 13 | setContentView(R.layout.activity_splash) 14 | // Simulating a splash screen. Don't judge me! :P 15 | Handler(Looper.getMainLooper()).postDelayed( 16 | { 17 | startActivity(Intent(this@SplashActivity, FactsActivity::class.java)) 18 | }, 19 | DELAY_IN_MILLIS 20 | ) 21 | } 22 | 23 | private companion object { 24 | private const val DELAY_IN_MILLIS = 500L 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /app/src/main/java/dev/programadorthi/app/SuperApplication.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.app 2 | 3 | import android.app.Application 4 | import android.content.Context 5 | import dev.programadorthi.norris.di.NorrisModule 6 | import dev.programadorthi.norris.ui.di.NorrisUIModule 7 | import dev.programadorthi.shared.database.DatabaseInjectionTags 8 | import dev.programadorthi.shared.database.android.SharedDatabaseAndroidModule 9 | import dev.programadorthi.shared.database.di.SharedDatabaseModule 10 | import dev.programadorthi.shared.domain.di.SharedDomainModule 11 | import dev.programadorthi.shared.network.NetworkInjectionTags 12 | import dev.programadorthi.shared.network.di.SharedNetworkModule 13 | import dev.programadorthi.shared.retrofit.di.SharedRetrofitModule 14 | import dev.programadorthi.shared.ui.di.SharedUIModule 15 | import org.kodein.di.DI 16 | import org.kodein.di.DIAware 17 | import org.kodein.di.bindProvider 18 | 19 | class SuperApplication : Application(), DIAware { 20 | override val di: DI by DI.lazy { 21 | import(SharedDatabaseModule()) 22 | import(SharedDatabaseAndroidModule()) 23 | import(SharedDomainModule()) 24 | import(SharedRetrofitModule()) 25 | import(SharedNetworkModule()) 26 | import(SharedUIModule()) 27 | // Importing global to avoid having imports on Activities 28 | // Importing on activities create a couple that is hard to test using Espresso ;D 29 | // TODO: But should other modules know norris modules? :thinking 30 | import(NorrisModule()) 31 | import(NorrisUIModule()) 32 | 33 | bindProvider { this@SuperApplication } 34 | bindProvider(tag = DatabaseInjectionTags.DATABASE_NAME) { BuildConfig.DATABASE_NAME } 35 | bindProvider(tag = NetworkInjectionTags.BASE_URL) { BuildConfig.BASE_URL } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 15 | 18 | 21 | 22 | 23 | 24 | 30 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_baseline_emoji_emotions_24.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_splash.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 19 | 20 | 29 | 30 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/programadorthi/android-super-app/f62cb1811621ec328c567bbe6bd61b02890ad46a/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/programadorthi/android-super-app/f62cb1811621ec328c567bbe6bd61b02890ad46a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/programadorthi/android-super-app/f62cb1811621ec328c567bbe6bd61b02890ad46a/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/programadorthi/android-super-app/f62cb1811621ec328c567bbe6bd61b02890ad46a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/programadorthi/android-super-app/f62cb1811621ec328c567bbe6bd61b02890ad46a/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/programadorthi/android-super-app/f62cb1811621ec328c567bbe6bd61b02890ad46a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/programadorthi/android-super-app/f62cb1811621ec328c567bbe6bd61b02890ad46a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/programadorthi/android-super-app/f62cb1811621ec328c567bbe6bd61b02890ad46a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/programadorthi/android-super-app/f62cb1811621ec328c567bbe6bd61b02890ad46a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/programadorthi/android-super-app/f62cb1811621ec328c567bbe6bd61b02890ad46a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/values-night/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 16 | -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFBB86FC 4 | #FF6200EE 5 | #FF3700B3 6 | #FF03DAC5 7 | #FF018786 8 | #FF000000 9 | #FFFFFFFF 10 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Super App 3 | Splash Screen 4 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 16 | -------------------------------------------------------------------------------- /app/src/main/res/xml/backup_rules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | // Top-level build file where you can add configuration options common to all sub-projects/modules. 2 | plugins { 3 | id("com.savvasdalkitsis.module-dependency-graph") version "0.9" 4 | } 5 | 6 | allprojects { 7 | repositories { 8 | google() 9 | jcenter() 10 | mavenCentral() 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /buildSrc/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /buildSrc/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | `kotlin-dsl` 3 | } 4 | 5 | repositories { 6 | google() 7 | gradlePluginPortal() 8 | mavenCentral() 9 | } 10 | 11 | dependencies { 12 | val kotlinVersion = "1.4.32" 13 | implementation(kotlin("gradle-plugin", version = kotlinVersion)) 14 | implementation(kotlin("serialization", version = kotlinVersion)) 15 | implementation("com.android.tools.build:gradle:4.1.3") 16 | implementation("com.squareup.sqldelight:gradle-plugin:1.4.4") 17 | implementation("org.jmailen.gradle:kotlinter-gradle:3.4.4") 18 | implementation("com.adarshr:gradle-test-logger-plugin:3.0.0") 19 | implementation("io.gitlab.arturbosch.detekt:detekt-gradle-plugin:1.16.0") 20 | } 21 | -------------------------------------------------------------------------------- /buildSrc/settings.gradle.kts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/programadorthi/android-super-app/f62cb1811621ec328c567bbe6bd61b02890ad46a/buildSrc/settings.gradle.kts -------------------------------------------------------------------------------- /buildSrc/src/main/kotlin/android-project.gradle.kts: -------------------------------------------------------------------------------- 1 | import com.adarshr.gradle.testlogger.TestLoggerExtension 2 | import com.adarshr.gradle.testlogger.theme.ThemeType 3 | import com.android.build.gradle.AppExtension 4 | import com.android.build.gradle.BaseExtension 5 | import io.gitlab.arturbosch.detekt.extensions.DetektExtension 6 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompile 7 | import org.jmailen.gradle.kotlinter.tasks.LintTask 8 | 9 | plugins { 10 | kotlin("android") 11 | id("org.jmailen.kotlinter") 12 | id("com.adarshr.test-logger") 13 | id("io.gitlab.arturbosch.detekt") 14 | } 15 | 16 | configure { 17 | val isApplication = this is AppExtension 18 | 19 | compileSdkVersion(30) 20 | buildToolsVersion("30.0.3") 21 | 22 | buildFeatures.viewBinding = true 23 | 24 | defaultConfig { 25 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 26 | versionCode = 1 27 | versionName = "1.0.0" 28 | minSdkVersion(23) 29 | targetSdkVersion(30) 30 | vectorDrawables { 31 | useSupportLibrary = true 32 | setGeneratedDensities(emptyList()) 33 | } 34 | } 35 | 36 | buildTypes { 37 | getByName("debug") { 38 | isCrunchPngs = false 39 | 40 | // https://stackoverflow.com/a/55745719 41 | (this@getByName as ExtensionAware).apply { 42 | extra["alwaysUpdateBuildId"] = false 43 | extra["enableCrashlytics"] = false 44 | } 45 | } 46 | 47 | getByName("release") { 48 | isMinifyEnabled = true 49 | isShrinkResources = isApplication 50 | 51 | proguardFiles( 52 | getDefaultProguardFile("proguard-android-optimize.txt"), 53 | getDefaultProguardFile("proguard-android.txt"), 54 | "proguard-rules.pro" 55 | ) 56 | proguardFiles(*(File("$rootDir/proguard").listFiles() ?: emptyArray())) 57 | 58 | // Will be null if has not a release signing 59 | signingConfig = signingConfigs.findByName("release") 60 | } 61 | } 62 | 63 | flavorDimensions("default") 64 | 65 | productFlavors { 66 | create("dev") { 67 | dimension = "default" 68 | applicationIdSuffix = if (isApplication) ".dev" else null 69 | resConfigs( 70 | "en-rUS", // Language and region 71 | "ldltr", // Layout Direction 72 | "port", // Screen orientation 73 | "xxxhdpi" // Screen pixel density (dpi) 74 | ) 75 | } 76 | 77 | create("prd") { 78 | dimension = "default" 79 | } 80 | } 81 | 82 | compileOptions { 83 | sourceCompatibility = JavaVersion.VERSION_1_8 84 | targetCompatibility = JavaVersion.VERSION_1_8 85 | } 86 | 87 | packagingOptions { 88 | exclude("LICENSE.txt") 89 | exclude("META-INF/DEPENDENCIES") 90 | exclude("META-INF/ASL2.0") 91 | exclude("META-INF/NOTICE") 92 | exclude("META-INF/LICENSE") 93 | exclude("META-INF/main.kotlin_module") 94 | } 95 | 96 | lintOptions { 97 | isAbortOnError = false 98 | isIgnoreWarnings = true 99 | isQuiet = true 100 | disable("InvalidPackage", "OldTargetApi") 101 | } 102 | 103 | testOptions { 104 | unitTests { 105 | isIncludeAndroidResources = true 106 | isReturnDefaultValues = true 107 | } 108 | } 109 | 110 | sourceSets { 111 | map { source -> 112 | source.java.srcDir("src/${source.name}/kotlin") 113 | } 114 | } 115 | } 116 | 117 | tasks.withType().configureEach { 118 | kotlinOptions { 119 | jvmTarget = "${JavaVersion.VERSION_1_8}" 120 | } 121 | } 122 | 123 | tasks { 124 | "lintKotlinMain"(LintTask::class) { 125 | exclude { 126 | // Ignoring build at all 127 | it.file.path.startsWith(buildDir.path) 128 | } 129 | } 130 | } 131 | 132 | configure { 133 | theme = ThemeType.MOCHA_PARALLEL 134 | } 135 | 136 | configure { 137 | config = rootProject.files("detekt-config.yml") 138 | ignoredBuildTypes = listOf("release") 139 | ignoredFlavors = listOf("prd") 140 | ignoredVariants = listOf("prdRelease") 141 | parallel = true 142 | } 143 | -------------------------------------------------------------------------------- /buildSrc/src/main/kotlin/jvm-project.gradle.kts: -------------------------------------------------------------------------------- 1 | import com.adarshr.gradle.testlogger.TestLoggerExtension 2 | import com.adarshr.gradle.testlogger.theme.ThemeType 3 | import io.gitlab.arturbosch.detekt.extensions.DetektExtension 4 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompile 5 | import org.jmailen.gradle.kotlinter.tasks.LintTask 6 | 7 | plugins { 8 | kotlin("jvm") 9 | id("org.jmailen.kotlinter") 10 | id("com.adarshr.test-logger") 11 | id("io.gitlab.arturbosch.detekt") 12 | } 13 | 14 | configure { 15 | sourceCompatibility = JavaVersion.VERSION_1_8 16 | targetCompatibility = JavaVersion.VERSION_1_8 17 | } 18 | 19 | tasks.withType().configureEach { 20 | kotlinOptions { 21 | jvmTarget = "${JavaVersion.VERSION_1_8}" 22 | } 23 | } 24 | 25 | sourceSets.main { 26 | java.srcDirs("src/main/kotlin") 27 | } 28 | 29 | tasks { 30 | "lintKotlinMain"(LintTask::class) { 31 | exclude { 32 | // Ignoring build at all 33 | it.file.path.startsWith(buildDir.path) 34 | } 35 | } 36 | } 37 | 38 | configure { 39 | theme = ThemeType.MOCHA_PARALLEL 40 | } 41 | 42 | configure { 43 | config = rootProject.files("detekt-config.yml") 44 | parallel = true 45 | } 46 | -------------------------------------------------------------------------------- /features/norris-facts/di/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /features/norris-facts/di/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("jvm-project") 3 | } 4 | 5 | dependencies { 6 | api(projects.features.norrisFacts.domainImpl) 7 | implementation(libs.kotlinx.coroutines.core) 8 | implementation(libs.kodein.di) 9 | implementation(libs.retrofit) 10 | } 11 | -------------------------------------------------------------------------------- /features/norris-facts/di/src/main/kotlin/dev/programadorthi/norris/di/NorrisModule.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.norris.di 2 | 3 | import dev.programadorthi.norris.domain.data.FactsRepositoryFactory 4 | import dev.programadorthi.norris.domain.data.local.LocalFactsRepositoryFactory 5 | import dev.programadorthi.norris.domain.data.remote.FactsService 6 | import dev.programadorthi.norris.domain.data.remote.mapper.FactsMapper 7 | import dev.programadorthi.norris.domain.data.remote.repository.RemoteFactsRepositoryFactory 8 | import dev.programadorthi.norris.domain.usecase.FactsUseCaseFactory 9 | import dev.programadorthi.norris.domain.viewmodel.FactsViewModelFactory 10 | import dev.programadorthi.norris.domain.viewmodel.SearchFactsViewModelFactory 11 | import dev.programadorthi.shared.domain.DomainInjectionTags 12 | import org.kodein.di.DI 13 | import org.kodein.di.bindProvider 14 | import org.kodein.di.bindSingleton 15 | import org.kodein.di.instance 16 | import retrofit2.Retrofit 17 | 18 | object NorrisModule { 19 | operator fun invoke() = DI.Module(name = "norris-di") { 20 | bindSingleton { 21 | val retrofit = instance() 22 | retrofit.create(FactsService::class.java) 23 | } 24 | bindProvider { 25 | LocalFactsRepositoryFactory( 26 | database = instance() 27 | ) 28 | } 29 | bindProvider { 30 | RemoteFactsRepositoryFactory( 31 | factsMapper = FactsMapper, 32 | factsService = instance(), 33 | networkManager = instance() 34 | ) 35 | } 36 | bindProvider { 37 | FactsRepositoryFactory( 38 | localFactsRepository = instance(), 39 | remoteFactsRepository = instance(), 40 | ioDispatcher = instance(DomainInjectionTags.IO_DISPATCHER) 41 | ) 42 | } 43 | bindProvider { 44 | FactsUseCaseFactory(factsRepository = instance()) 45 | } 46 | bindProvider { 47 | FactsViewModelFactory( 48 | factsUseCase = instance(), 49 | factsTextProvider = instance(), 50 | factsStyleProvider = instance(), 51 | viewModelScope = instance(DomainInjectionTags.VIEW_MODEL_SCOPE) 52 | ) 53 | } 54 | bindProvider { 55 | SearchFactsViewModelFactory( 56 | factsUseCase = instance(), 57 | sharedTextProvider = instance(), 58 | viewModelScope = instance(DomainInjectionTags.VIEW_MODEL_SCOPE) 59 | ) 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /features/norris-facts/domain-fake/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /features/norris-facts/domain-fake/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("jvm-project") 3 | } 4 | 5 | dependencies { 6 | // TODO: fake project has impl dependency is WRONG. Still here because FactsService is a retrofit interface inside impl module only 7 | api(projects.features.norrisFacts.domainImpl) 8 | implementation(libs.kotlinx.coroutines.core) 9 | } 10 | -------------------------------------------------------------------------------- /features/norris-facts/domain-fake/src/main/kotlin/dev/programadorthi/norris/domain/fake/data/FactsRepositoryFake.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.norris.domain.fake.data 2 | 3 | import dev.programadorthi.norris.domain.model.Category 4 | import dev.programadorthi.norris.domain.model.Fact 5 | import dev.programadorthi.norris.domain.model.LastSearch 6 | import dev.programadorthi.norris.domain.repository.FactsRepository 7 | import dev.programadorthi.shared.domain.Result 8 | 9 | class FactsRepositoryFake : FactsRepository { 10 | private val categories = mutableListOf() 11 | private val facts = mutableListOf() 12 | private val lastSearches = mutableListOf() 13 | 14 | var exceptionResult: Throwable? = null 15 | 16 | override suspend fun fetchCategories( 17 | limit: Int, 18 | shuffle: Boolean 19 | ): Result> { 20 | val result = categories.take(limit) 21 | val shuffled = if (shuffle) result.shuffled() else result 22 | return mapResult(shuffled) 23 | } 24 | 25 | override suspend fun getLastSearches(): Result> = 26 | mapResult(lastSearches) 27 | 28 | override suspend fun doSearch(text: String): Result> = 29 | mapResult(facts) 30 | 31 | fun addCategory(vararg categories: Category) { 32 | this.categories += resultOrThrow(categories) 33 | } 34 | 35 | fun addFact(vararg facts: Fact) { 36 | this.facts += resultOrThrow(facts) 37 | } 38 | 39 | fun addLastSearch(search: LastSearch) { 40 | lastSearches += resultOrThrow(search) 41 | } 42 | 43 | private fun mapResult(data: T) = when { 44 | exceptionResult != null -> Result.failure(exceptionResult!!) 45 | else -> Result.success(data) 46 | } 47 | 48 | private fun resultOrThrow(data: T) = when { 49 | exceptionResult != null -> throw exceptionResult!! 50 | else -> data 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /features/norris-facts/domain-fake/src/main/kotlin/dev/programadorthi/norris/domain/fake/data/FactsServiceFake.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.norris.domain.fake.data 2 | 3 | import dev.programadorthi.norris.domain.data.remote.FactsService 4 | import dev.programadorthi.norris.domain.data.remote.raw.FactsResponseRaw 5 | 6 | // FIXME: how to avoid Service and Raws be public? 7 | class FactsServiceFake : FactsService { 8 | private val categories = mutableListOf() 9 | 10 | lateinit var raw: FactsResponseRaw 11 | var exceptionToThrow: Throwable? = null 12 | 13 | override suspend fun fetchCategories(): List = 14 | resultOrThrow(categories) 15 | 16 | override suspend fun search(query: String): FactsResponseRaw = 17 | resultOrThrow(raw) 18 | 19 | fun addCategories(vararg categories: String) { 20 | this.categories += resultOrThrow(categories) 21 | } 22 | 23 | private fun resultOrThrow(data: T) = when { 24 | exceptionToThrow != null -> throw exceptionToThrow!! 25 | else -> data 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /features/norris-facts/domain-fake/src/main/kotlin/dev/programadorthi/norris/domain/fake/data/LocalFactsRepositoryFake.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.norris.domain.fake.data 2 | 3 | import dev.programadorthi.norris.domain.data.LocalFactsRepository 4 | import dev.programadorthi.norris.domain.model.Category 5 | import dev.programadorthi.norris.domain.model.Fact 6 | import dev.programadorthi.norris.domain.model.LastSearch 7 | 8 | class LocalFactsRepositoryFake : LocalFactsRepository { 9 | private val categories = mutableListOf() 10 | private val facts = mutableListOf() 11 | private val lastSearches = mutableListOf() 12 | 13 | var exceptionToThrow: Throwable? = null 14 | 15 | override suspend fun getCategories(): List = 16 | resultOrThrow(categories.map { category -> category.name }) 17 | 18 | override suspend fun getFacts(): List = 19 | resultOrThrow(facts) 20 | 21 | override suspend fun getLastSearches(): List = 22 | resultOrThrow(lastSearches.map { search -> search.term }) 23 | 24 | override suspend fun saveCategories(categories: List) { 25 | this.categories += resultOrThrow(categories) 26 | } 27 | 28 | override suspend fun saveFacts(facts: List) { 29 | this.facts += resultOrThrow(facts) 30 | } 31 | 32 | override suspend fun saveNewSearch(lastSearch: LastSearch) { 33 | lastSearches += resultOrThrow(lastSearch) 34 | } 35 | 36 | private fun resultOrThrow(data: T) = when { 37 | exceptionToThrow != null -> throw exceptionToThrow!! 38 | else -> data 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /features/norris-facts/domain-fake/src/main/kotlin/dev/programadorthi/norris/domain/fake/data/RemoteFactsRepositoryFake.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.norris.domain.fake.data 2 | 3 | import dev.programadorthi.norris.domain.data.RemoteFactsRepository 4 | import dev.programadorthi.norris.domain.model.Fact 5 | 6 | class RemoteFactsRepositoryFake : RemoteFactsRepository { 7 | private val categories = mutableListOf() 8 | private val facts = mutableListOf() 9 | 10 | var exceptionToThrow: Throwable? = null 11 | 12 | override suspend fun fetchCategories(): List = 13 | resultOrThrow(categories) 14 | 15 | override suspend fun search(text: String): List = 16 | resultOrThrow(facts) 17 | 18 | fun addCategory(vararg categories: String) { 19 | this.categories += resultOrThrow(categories) 20 | } 21 | 22 | fun addFact(vararg facts: Fact) { 23 | this.facts += resultOrThrow(facts) 24 | } 25 | 26 | private fun resultOrThrow(data: T) = when { 27 | exceptionToThrow != null -> throw exceptionToThrow!! 28 | else -> data 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /features/norris-facts/domain-fake/src/main/kotlin/dev/programadorthi/norris/domain/fake/provider/FactsStyleProviderFake.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.norris.domain.fake.provider 2 | 3 | import dev.programadorthi.norris.domain.provider.FactsStyleProvider 4 | 5 | class FactsStyleProviderFake : FactsStyleProvider { 6 | var headlineStyle: Int = -1 7 | var subtitleStyle: Int = -1 8 | 9 | override fun providerHeadline(): Int = headlineStyle 10 | 11 | override fun providerSubtitle(): Int = subtitleStyle 12 | 13 | override fun provideHeadlineOrSubtitle(predicate: () -> Boolean): Int = 14 | if (headlineStyle > -1) headlineStyle else subtitleStyle 15 | } 16 | -------------------------------------------------------------------------------- /features/norris-facts/domain-fake/src/main/kotlin/dev/programadorthi/norris/domain/fake/provider/FactsTextProviderFake.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.norris.domain.fake.provider 2 | 3 | import dev.programadorthi.norris.domain.provider.FactsTextProvider 4 | 5 | class FactsTextProviderFake : FactsTextProvider { 6 | var emptySearchTermText: String = "" 7 | var generalFailureText: String = "" 8 | var withoutCategoryText: String = "" 9 | 10 | override fun emptySearchTerm(): String = emptySearchTermText 11 | 12 | override fun generalFailure(): String = generalFailureText 13 | 14 | override fun withoutCategory(): String = withoutCategoryText 15 | } 16 | -------------------------------------------------------------------------------- /features/norris-facts/domain-fake/src/main/kotlin/dev/programadorthi/norris/domain/fake/usecase/FactsUseCaseFake.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.norris.domain.fake.usecase 2 | 3 | import dev.programadorthi.norris.domain.model.Category 4 | import dev.programadorthi.norris.domain.model.Fact 5 | import dev.programadorthi.norris.domain.model.LastSearch 6 | import dev.programadorthi.norris.domain.usecase.FactsUseCase 7 | import dev.programadorthi.shared.domain.Result 8 | 9 | class FactsUseCaseFake : FactsUseCase { 10 | private val categories = mutableListOf() 11 | private val facts = mutableListOf() 12 | private val lastSearches = mutableListOf() 13 | 14 | var exceptionResult: Throwable? = null 15 | var businessToResult: Result.Business? = null 16 | 17 | override suspend fun categories( 18 | limit: Int, 19 | shuffle: Boolean 20 | ): Result> { 21 | if (limit <= 0) return mapResult(emptyList()) 22 | val shuffled = if (shuffle) categories.shuffled() else categories 23 | return mapResult(shuffled) 24 | } 25 | 26 | override suspend fun lastSearches(): Result> = 27 | mapResult(lastSearches) 28 | 29 | override suspend fun search(text: String): Result> = 30 | mapResult(facts) 31 | 32 | fun addCategories(vararg categories: Category) { 33 | this.categories += resultOrThrow(categories) 34 | } 35 | 36 | fun addFacts(vararg facts: Fact) { 37 | this.facts += resultOrThrow(facts) 38 | } 39 | 40 | fun addLastSearches(vararg search: LastSearch) { 41 | lastSearches += resultOrThrow(search) 42 | } 43 | 44 | private fun mapResult(data: T) = when { 45 | businessToResult != null -> Result.business(businessToResult!!) 46 | exceptionResult != null -> Result.failure(exceptionResult!!) 47 | else -> Result.success(data) 48 | } 49 | 50 | private fun resultOrThrow(data: T) = when { 51 | exceptionResult != null -> throw exceptionResult!! 52 | else -> data 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /features/norris-facts/domain-fake/src/main/kotlin/dev/programadorthi/norris/domain/fake/viewmodel/FactsViewModelFake.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.norris.domain.fake.viewmodel 2 | 3 | import dev.programadorthi.norris.domain.model.presentation.FactViewData 4 | import dev.programadorthi.norris.domain.viewmodel.FactsViewModel 5 | import dev.programadorthi.shared.domain.UIState 6 | import dev.programadorthi.shared.domain.flow.PropertyUIStateFlow 7 | import kotlinx.coroutines.flow.StateFlow 8 | 9 | class FactsViewModelFake : FactsViewModel { 10 | private val mutableFacts = PropertyUIStateFlow>() 11 | override val facts: StateFlow>> 12 | get() = mutableFacts.stateFlow 13 | 14 | override fun search(text: String) { 15 | // no-op by default 16 | } 17 | 18 | override fun dispose() { 19 | // no-op by default 20 | } 21 | 22 | fun addFactState(state: UIState>) { 23 | mutableFacts.update(state) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /features/norris-facts/domain-fake/src/main/kotlin/dev/programadorthi/norris/domain/fake/viewmodel/SearchFactsViewModelFake.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.norris.domain.fake.viewmodel 2 | 3 | import dev.programadorthi.norris.domain.viewmodel.SearchFactsViewModel 4 | import dev.programadorthi.shared.domain.UIState 5 | import dev.programadorthi.shared.domain.flow.PropertyUIStateFlow 6 | import kotlinx.coroutines.flow.StateFlow 7 | 8 | class SearchFactsViewModelFake : SearchFactsViewModel { 9 | private val mutableCategories = PropertyUIStateFlow>() 10 | private val mutableLastSearches = PropertyUIStateFlow>() 11 | 12 | override val categories: StateFlow>> 13 | get() = mutableCategories.stateFlow 14 | 15 | override val lastSearches: StateFlow>> 16 | get() = mutableLastSearches.stateFlow 17 | 18 | override fun fetchCategories() { 19 | // no-op 20 | } 21 | 22 | override fun fetchLastSearches() { 23 | // no-op 24 | } 25 | 26 | override fun dispose() { 27 | // no-op 28 | } 29 | 30 | fun addCategoryState(state: UIState>) { 31 | mutableCategories.update(state) 32 | } 33 | 34 | fun addLastSearchesState(state: UIState>) { 35 | mutableLastSearches.update(state) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /features/norris-facts/domain-impl/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /features/norris-facts/domain-impl/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("jvm-project") 3 | // We need set using complete classpath instead of import the class Version 4 | kotlin("plugin.serialization") 5 | } 6 | 7 | dependencies { 8 | api(projects.sharedDatabase) 9 | api(projects.sharedNetwork) 10 | api(projects.features.norrisFacts.domain) 11 | implementation(libs.kotlinx.coroutines.core) 12 | implementation(libs.kotlinx.serialization) 13 | implementation(libs.retrofit) 14 | } 15 | -------------------------------------------------------------------------------- /features/norris-facts/domain-impl/src/main/kotlin/dev/programadorthi/norris/domain/data/FactsRepositoryFactory.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.norris.domain.data 2 | 3 | import dev.programadorthi.norris.domain.repository.FactsRepository 4 | import kotlinx.coroutines.CoroutineDispatcher 5 | 6 | object FactsRepositoryFactory { 7 | operator fun invoke( 8 | localFactsRepository: LocalFactsRepository, 9 | remoteFactsRepository: RemoteFactsRepository, 10 | ioDispatcher: CoroutineDispatcher 11 | ): FactsRepository = FactsRepositoryImpl( 12 | localFactsRepository = localFactsRepository, 13 | remoteFactsRepository = remoteFactsRepository, 14 | ioDispatcher = ioDispatcher 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /features/norris-facts/domain-impl/src/main/kotlin/dev/programadorthi/norris/domain/data/FactsRepositoryImpl.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.norris.domain.data 2 | 3 | import dev.programadorthi.norris.domain.model.Category 4 | import dev.programadorthi.norris.domain.model.Fact 5 | import dev.programadorthi.norris.domain.model.LastSearch 6 | import dev.programadorthi.norris.domain.repository.FactsRepository 7 | import dev.programadorthi.shared.domain.Result 8 | import kotlinx.coroutines.CoroutineDispatcher 9 | import kotlinx.coroutines.withContext 10 | 11 | internal class FactsRepositoryImpl( 12 | private val localFactsRepository: LocalFactsRepository, 13 | private val remoteFactsRepository: RemoteFactsRepository, 14 | private val ioDispatcher: CoroutineDispatcher 15 | ) : FactsRepository { 16 | 17 | override suspend fun fetchCategories( 18 | limit: Int, 19 | shuffle: Boolean 20 | ): Result> = withContext(ioDispatcher) { 21 | val categories = mutableListOf() 22 | categories += try { 23 | localFactsRepository 24 | .getCategories() 25 | .map { category -> Category(name = category) } 26 | } catch (ex: Throwable) { 27 | return@withContext Result.failure(ex) 28 | } 29 | if (categories.isEmpty()) { 30 | categories += try { 31 | remoteFactsRepository 32 | .fetchCategories() 33 | .map { name -> Category(name = name) } 34 | } catch (ex: Throwable) { 35 | return@withContext Result.failure(ex) 36 | } 37 | } 38 | val result = (if (shuffle) categories.shuffled() else categories).take(limit) 39 | return@withContext Result.success(result) 40 | } 41 | 42 | override suspend fun getLastSearches(): Result> = 43 | withContext(ioDispatcher) { 44 | try { 45 | Result.success( 46 | localFactsRepository 47 | .getLastSearches() 48 | .map { term -> LastSearch(term = term) } 49 | ) 50 | } catch (ex: Throwable) { 51 | Result.failure(ex) 52 | } 53 | } 54 | 55 | override suspend fun doSearch(text: String): Result> = 56 | withContext(ioDispatcher) { 57 | try { 58 | val result = remoteFactsRepository.search(text) 59 | if (result.isNotEmpty()) { 60 | localFactsRepository.saveNewSearch(LastSearch(term = text)) 61 | } 62 | Result.success(result) 63 | } catch (ex: Throwable) { 64 | Result.failure(ex) 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /features/norris-facts/domain-impl/src/main/kotlin/dev/programadorthi/norris/domain/data/local/LocalFactsRepositoryFactory.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.norris.domain.data.local 2 | 3 | import dev.programadorthi.norris.domain.data.LocalFactsRepository 4 | import dev.programadorthi.shared.database.NorrisQueries 5 | 6 | object LocalFactsRepositoryFactory { 7 | operator fun invoke( 8 | database: NorrisQueries 9 | ): LocalFactsRepository = LocalFactsRepositoryImpl(database) 10 | } 11 | -------------------------------------------------------------------------------- /features/norris-facts/domain-impl/src/main/kotlin/dev/programadorthi/norris/domain/data/local/LocalFactsRepositoryImpl.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.norris.domain.data.local 2 | 3 | import dev.programadorthi.norris.domain.data.LocalFactsRepository 4 | import dev.programadorthi.norris.domain.model.Category 5 | import dev.programadorthi.norris.domain.model.Fact 6 | import dev.programadorthi.norris.domain.model.LastSearch 7 | import dev.programadorthi.shared.database.Categories 8 | import dev.programadorthi.shared.database.Facts 9 | import dev.programadorthi.shared.database.NorrisQueries 10 | import dev.programadorthi.shared.database.LastSearch as DBLastSearch 11 | 12 | internal class LocalFactsRepositoryImpl( 13 | private val database: NorrisQueries 14 | ) : LocalFactsRepository { 15 | 16 | override suspend fun getCategories(): List = 17 | database.selectCategories().executeAsList() 18 | 19 | override suspend fun getFacts(): List = 20 | database.selectFacts().executeAsList() 21 | .map { fact -> 22 | Fact( 23 | id = fact.id, 24 | url = fact.url, 25 | value = fact.text, 26 | categories = emptyList() // TODO: get category from INNER JOIN 27 | ) 28 | } 29 | 30 | override suspend fun getLastSearches(): List = 31 | database.selectLastSearches().executeAsList() 32 | 33 | override suspend fun saveCategories(categories: List) { 34 | database.transaction { 35 | categories 36 | .map { category -> Categories(name = category.name) } 37 | .forEach(database::insertCategory) 38 | } 39 | } 40 | 41 | override suspend fun saveFacts(facts: List) { 42 | database.transaction { 43 | facts 44 | .map { fact -> 45 | Facts( 46 | id = fact.id, 47 | text = fact.value, 48 | url = fact.url 49 | ) 50 | } 51 | .forEach(database::insertFacts) 52 | } 53 | } 54 | 55 | override suspend fun saveNewSearch(lastSearch: LastSearch) { 56 | database.insertLastSearch(DBLastSearch(term = lastSearch.term)) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /features/norris-facts/domain-impl/src/main/kotlin/dev/programadorthi/norris/domain/data/remote/FactsService.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.norris.domain.data.remote 2 | 3 | import dev.programadorthi.norris.domain.data.remote.raw.FactsResponseRaw 4 | import retrofit2.http.GET 5 | import retrofit2.http.Query 6 | 7 | interface FactsService { 8 | @GET("categories") 9 | suspend fun fetchCategories(): List 10 | 11 | @GET("search") 12 | suspend fun search( 13 | @Query("query") query: String 14 | ): FactsResponseRaw 15 | } 16 | -------------------------------------------------------------------------------- /features/norris-facts/domain-impl/src/main/kotlin/dev/programadorthi/norris/domain/data/remote/mapper/FactsMapper.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.norris.domain.data.remote.mapper 2 | 3 | import dev.programadorthi.norris.domain.data.remote.raw.FactRaw 4 | import dev.programadorthi.norris.domain.data.remote.raw.FactsResponseRaw 5 | import dev.programadorthi.norris.domain.model.Fact 6 | import dev.programadorthi.shared.network.mapper.RemoteMapper 7 | 8 | object FactsMapper : RemoteMapper>() { 9 | 10 | override fun checkEssentialParams(missingFields: MutableSet, raw: FactsResponseRaw) { 11 | if (raw.result == null) { 12 | missingFields += FactsResponseRaw.RESULT_FIELD 13 | } 14 | val facts = raw.result ?: return 15 | for (fact in facts) { 16 | val missingField = when { 17 | fact.id == null -> FactRaw.ID_FIELD 18 | fact.url == null -> FactRaw.URL_FIELD 19 | fact.value == null -> FactRaw.VALUE_FIELD 20 | else -> continue 21 | } 22 | missingFields += missingField 23 | } 24 | } 25 | 26 | override fun mapRawToModel(raw: FactsResponseRaw): List { 27 | val items = raw.result ?: return emptyList() 28 | return items.map { 29 | Fact( 30 | id = it.id ?: "", 31 | url = it.url ?: "", 32 | value = it.value ?: "", 33 | categories = it.categories ?: emptyList() 34 | ) 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /features/norris-facts/domain-impl/src/main/kotlin/dev/programadorthi/norris/domain/data/remote/raw/FactRaw.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.norris.domain.data.remote.raw 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | data class FactRaw( 8 | @SerialName(ID_FIELD) 9 | val id: String? = null, 10 | @SerialName(URL_FIELD) 11 | val url: String? = null, 12 | @SerialName(VALUE_FIELD) 13 | val value: String? = null, 14 | @SerialName("categories") 15 | val categories: List? = null 16 | ) { 17 | companion object { 18 | const val ID_FIELD = "id" 19 | const val URL_FIELD = "url" 20 | const val VALUE_FIELD = "value" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /features/norris-facts/domain-impl/src/main/kotlin/dev/programadorthi/norris/domain/data/remote/raw/FactsResponseRaw.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.norris.domain.data.remote.raw 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | data class FactsResponseRaw( 8 | @SerialName(RESULT_FIELD) 9 | val result: List? = null 10 | ) { 11 | companion object { 12 | const val RESULT_FIELD = "result" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /features/norris-facts/domain-impl/src/main/kotlin/dev/programadorthi/norris/domain/data/remote/repository/RemoteFactsRepositoryFactory.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.norris.domain.data.remote.repository 2 | 3 | import dev.programadorthi.norris.domain.data.RemoteFactsRepository 4 | import dev.programadorthi.norris.domain.data.remote.FactsService 5 | import dev.programadorthi.norris.domain.data.remote.raw.FactsResponseRaw 6 | import dev.programadorthi.norris.domain.model.Fact 7 | import dev.programadorthi.shared.network.manager.NetworkManager 8 | import dev.programadorthi.shared.network.mapper.RemoteMapper 9 | 10 | object RemoteFactsRepositoryFactory { 11 | operator fun invoke( 12 | factsMapper: RemoteMapper>, 13 | factsService: FactsService, 14 | networkManager: NetworkManager 15 | ): RemoteFactsRepository = RemoteFactsRepositoryImpl( 16 | factsMapper = factsMapper, 17 | factsService = factsService, 18 | networkManager = networkManager 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /features/norris-facts/domain-impl/src/main/kotlin/dev/programadorthi/norris/domain/data/remote/repository/RemoteFactsRepositoryImpl.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.norris.domain.data.remote.repository 2 | 3 | import dev.programadorthi.norris.domain.data.RemoteFactsRepository 4 | import dev.programadorthi.norris.domain.data.remote.FactsService 5 | import dev.programadorthi.norris.domain.data.remote.raw.FactsResponseRaw 6 | import dev.programadorthi.norris.domain.model.Fact 7 | import dev.programadorthi.shared.network.manager.NetworkManager 8 | import dev.programadorthi.shared.network.mapper.RemoteMapper 9 | 10 | internal class RemoteFactsRepositoryImpl( 11 | private val factsMapper: RemoteMapper>, 12 | private val factsService: FactsService, 13 | private val networkManager: NetworkManager 14 | ) : RemoteFactsRepository { 15 | 16 | override suspend fun fetchCategories(): List = 17 | networkManager.execute { 18 | return@execute factsService.fetchCategories() 19 | } 20 | 21 | override suspend fun search(text: String): List = 22 | networkManager.execute { 23 | val result = factsService.search(query = text) 24 | return@execute factsMapper.invoke(result) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /features/norris-facts/domain-impl/src/main/kotlin/dev/programadorthi/norris/domain/usecase/FactsUseCaseFactory.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.norris.domain.usecase 2 | 3 | import dev.programadorthi.norris.domain.repository.FactsRepository 4 | 5 | object FactsUseCaseFactory { 6 | operator fun invoke( 7 | factsRepository: FactsRepository 8 | ): FactsUseCase = FactsUseCaseImpl(factsRepository) 9 | } 10 | -------------------------------------------------------------------------------- /features/norris-facts/domain-impl/src/main/kotlin/dev/programadorthi/norris/domain/usecase/FactsUseCaseImpl.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.norris.domain.usecase 2 | 3 | import dev.programadorthi.norris.domain.FactsBusiness 4 | import dev.programadorthi.norris.domain.model.Category 5 | import dev.programadorthi.norris.domain.model.Fact 6 | import dev.programadorthi.norris.domain.model.LastSearch 7 | import dev.programadorthi.norris.domain.repository.FactsRepository 8 | import dev.programadorthi.shared.domain.Result 9 | 10 | internal class FactsUseCaseImpl( 11 | private val factsRepository: FactsRepository 12 | ) : FactsUseCase { 13 | 14 | override suspend fun categories( 15 | limit: Int, 16 | shuffle: Boolean 17 | ): Result> = when { 18 | limit <= MIN_OFFSET -> Result.success(emptyList()) 19 | else -> factsRepository.fetchCategories(limit, shuffle) 20 | } 21 | 22 | override suspend fun lastSearches(): Result> = 23 | factsRepository.getLastSearches() 24 | 25 | override suspend fun search(text: String): Result> = 26 | when { 27 | text.isBlank() -> Result.business(FactsBusiness.EmptySearch) 28 | else -> factsRepository.doSearch(text) 29 | } 30 | 31 | private companion object { 32 | private const val MIN_OFFSET = 0 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /features/norris-facts/domain-impl/src/main/kotlin/dev/programadorthi/norris/domain/viewmodel/FactsViewModelFactory.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.norris.domain.viewmodel 2 | 3 | import dev.programadorthi.norris.domain.provider.FactsStyleProvider 4 | import dev.programadorthi.norris.domain.provider.FactsTextProvider 5 | import dev.programadorthi.norris.domain.usecase.FactsUseCase 6 | import dev.programadorthi.shared.domain.viewmodel.ViewModelScope 7 | 8 | object FactsViewModelFactory { 9 | operator fun invoke( 10 | factsUseCase: FactsUseCase, 11 | factsTextProvider: FactsTextProvider, 12 | factsStyleProvider: FactsStyleProvider, 13 | viewModelScope: ViewModelScope 14 | ): FactsViewModel = FactsViewModelImpl( 15 | factsUseCase = factsUseCase, 16 | factsTextProvider = factsTextProvider, 17 | factsStyleProvider = factsStyleProvider, 18 | viewModelScope = viewModelScope 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /features/norris-facts/domain-impl/src/main/kotlin/dev/programadorthi/norris/domain/viewmodel/FactsViewModelImpl.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.norris.domain.viewmodel 2 | 3 | import dev.programadorthi.norris.domain.FactsBusiness 4 | import dev.programadorthi.norris.domain.model.Fact 5 | import dev.programadorthi.norris.domain.model.presentation.FactViewData 6 | import dev.programadorthi.norris.domain.provider.FactsStyleProvider 7 | import dev.programadorthi.norris.domain.provider.FactsTextProvider 8 | import dev.programadorthi.norris.domain.usecase.FactsUseCase 9 | import dev.programadorthi.shared.domain.Result 10 | import dev.programadorthi.shared.domain.UIState 11 | import dev.programadorthi.shared.domain.ext.toUIState 12 | import dev.programadorthi.shared.domain.flow.PropertyUIStateFlow 13 | import dev.programadorthi.shared.domain.viewmodel.ViewModel 14 | import dev.programadorthi.shared.domain.viewmodel.ViewModelScope 15 | import kotlinx.coroutines.flow.StateFlow 16 | import kotlinx.coroutines.launch 17 | 18 | internal class FactsViewModelImpl( 19 | private val factsUseCase: FactsUseCase, 20 | private val factsTextProvider: FactsTextProvider, 21 | private val factsStyleProvider: FactsStyleProvider, 22 | private val viewModelScope: ViewModelScope 23 | ) : FactsViewModel, ViewModel by viewModelScope { 24 | private val mutableFacts = PropertyUIStateFlow>() 25 | override val facts: StateFlow>> 26 | get() = mutableFacts.stateFlow 27 | 28 | override fun search(text: String) { 29 | viewModelScope.launch { 30 | mutableFacts.loading() 31 | val result = factsUseCase.search(text) 32 | val nextState = result.toUIState( 33 | businessMessage = result.businessToTextMessage(), 34 | failureMessage = factsTextProvider.generalFailure(), 35 | successMapper = ::successMapper 36 | ) 37 | mutableFacts.update(nextState) 38 | } 39 | } 40 | 41 | private fun successMapper( 42 | facts: List? 43 | ): UIState.Success> { 44 | val content = (facts ?: emptyList()).map(::mapFact) 45 | return UIState.Success(content) 46 | } 47 | 48 | private fun mapFact(fact: Fact) = FactViewData( 49 | category = fact.categories.firstOrNull() 50 | ?: factsTextProvider.withoutCategory(), 51 | url = fact.url, 52 | value = fact.value, 53 | style = factsStyleProvider.provideHeadlineOrSubtitle { 54 | fact.value.length < HIGH_FONT_CHARACTERS_LIMIT 55 | } 56 | ) 57 | 58 | private fun Result.businessToTextMessage(): String = 59 | when (this.businessOrNull()) { 60 | is FactsBusiness.EmptySearch -> factsTextProvider.emptySearchTerm() 61 | else -> factsTextProvider.generalFailure() 62 | } 63 | 64 | private companion object { 65 | private const val HIGH_FONT_CHARACTERS_LIMIT = 80 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /features/norris-facts/domain-impl/src/main/kotlin/dev/programadorthi/norris/domain/viewmodel/SearchFactsViewModelFactory.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.norris.domain.viewmodel 2 | 3 | import dev.programadorthi.norris.domain.usecase.FactsUseCase 4 | import dev.programadorthi.shared.domain.provider.SharedTextProvider 5 | import dev.programadorthi.shared.domain.viewmodel.ViewModelScope 6 | 7 | object SearchFactsViewModelFactory { 8 | operator fun invoke( 9 | factsUseCase: FactsUseCase, 10 | sharedTextProvider: SharedTextProvider, 11 | viewModelScope: ViewModelScope 12 | ): SearchFactsViewModel = SearchFactsViewModelImpl( 13 | factsUseCase = factsUseCase, 14 | sharedTextProvider = sharedTextProvider, 15 | viewModelScope = viewModelScope 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /features/norris-facts/domain-impl/src/main/kotlin/dev/programadorthi/norris/domain/viewmodel/SearchFactsViewModelImpl.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.norris.domain.viewmodel 2 | 3 | import dev.programadorthi.norris.domain.usecase.FactsUseCase 4 | import dev.programadorthi.shared.domain.UIState 5 | import dev.programadorthi.shared.domain.exception.NetworkingError 6 | import dev.programadorthi.shared.domain.flow.PropertyUIStateFlow 7 | import dev.programadorthi.shared.domain.getOrDefault 8 | import dev.programadorthi.shared.domain.provider.SharedTextProvider 9 | import dev.programadorthi.shared.domain.viewmodel.ViewModel 10 | import dev.programadorthi.shared.domain.viewmodel.ViewModelScope 11 | import kotlinx.coroutines.flow.StateFlow 12 | import kotlinx.coroutines.launch 13 | 14 | internal class SearchFactsViewModelImpl( 15 | private val factsUseCase: FactsUseCase, 16 | private val sharedTextProvider: SharedTextProvider, 17 | private val viewModelScope: ViewModelScope 18 | ) : SearchFactsViewModel, ViewModel by viewModelScope { 19 | private val mutableCategories = PropertyUIStateFlow>() 20 | private val mutableLastSearches = PropertyUIStateFlow>() 21 | 22 | override val categories: StateFlow>> 23 | get() = mutableCategories.stateFlow 24 | 25 | override val lastSearches: StateFlow>> 26 | get() = mutableLastSearches.stateFlow 27 | 28 | override fun fetchCategories() { 29 | viewModelScope.launch { 30 | val result = factsUseCase.categories(limit = MAX_VISIBLE_CATEGORIES, shuffle = true) 31 | val nextState = when { 32 | result.isFailure -> UIState.Error( 33 | cause = result.exceptionOrNull(), 34 | message = result.exceptionOrNull().toTextMessage() 35 | ) 36 | else -> 37 | result 38 | .getOrDefault(emptyList()) 39 | .map { category -> category.name } 40 | .let { categories -> UIState.Success(categories) } 41 | } 42 | mutableCategories.update(nextState) 43 | } 44 | } 45 | 46 | override fun fetchLastSearches() { 47 | viewModelScope.launch { 48 | val result = factsUseCase.lastSearches() 49 | val nextState = when { 50 | result.isFailure -> UIState.Error( 51 | cause = result.exceptionOrNull(), 52 | message = result.exceptionOrNull().toTextMessage() 53 | ) 54 | else -> 55 | result 56 | .getOrDefault(emptyList()) 57 | .map { search -> search.term } 58 | .let { searches -> UIState.Success(searches) } 59 | } 60 | mutableLastSearches.update(nextState) 61 | } 62 | } 63 | 64 | private fun Throwable?.toTextMessage() = 65 | when (this) { 66 | is NetworkingError.NoInternetConnection -> sharedTextProvider.noInternetConnection() 67 | else -> sharedTextProvider.somethingWrong() 68 | } 69 | 70 | private companion object { 71 | private const val MAX_VISIBLE_CATEGORIES = 8 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /features/norris-facts/domain-test/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /features/norris-facts/domain-test/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("jvm-project") 3 | } 4 | 5 | dependencies { 6 | testImplementation(projects.sharedDatabaseFake) 7 | testImplementation(projects.sharedDomainFake) 8 | testImplementation(projects.sharedNetworkFake) 9 | testImplementation(projects.features.norrisFacts.domainFake) 10 | testImplementation(libs.bundles.unit.test) 11 | } 12 | -------------------------------------------------------------------------------- /features/norris-facts/domain-test/src/test/kotlin/dev/programadorthi/norris/domain/test/FactsMapperTest.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.norris.domain.test 2 | 3 | import dev.programadorthi.norris.domain.data.remote.mapper.FactsMapper 4 | import dev.programadorthi.norris.domain.data.remote.raw.FactRaw 5 | import dev.programadorthi.norris.domain.data.remote.raw.FactsResponseRaw 6 | import dev.programadorthi.norris.domain.model.Fact 7 | import dev.programadorthi.shared.domain.exception.NetworkingError 8 | import org.assertj.core.api.Assertions.assertThat 9 | import org.assertj.core.api.Assertions.assertThatThrownBy 10 | import org.junit.Test 11 | 12 | class FactsMapperTest { 13 | 14 | private val mapper = FactsMapper 15 | private val missingFieldsList = listOf( 16 | FactRaw.ID_FIELD to FactRaw( 17 | url = "url", 18 | value = "value", 19 | categories = emptyList(), 20 | ), 21 | FactRaw.URL_FIELD to FactRaw( 22 | id = "id", 23 | value = "value", 24 | categories = emptyList(), 25 | ), 26 | FactRaw.VALUE_FIELD to FactRaw( 27 | id = "id", 28 | url = "url", 29 | categories = emptyList(), 30 | ) 31 | ) 32 | 33 | @Test 34 | fun `should have missing result field`() { 35 | val missingFields = setOf(FactsResponseRaw.RESULT_FIELD) 36 | val raw = FactsResponseRaw() 37 | val expected = NetworkingError.EssentialParamMissing( 38 | missingParams = missingFields.toString(), 39 | rawObject = raw 40 | ) 41 | assertThatThrownBy { 42 | mapper.invoke(raw) 43 | }.isInstanceOf(expected::class.java) 44 | .hasMessage(expected.message) 45 | } 46 | 47 | @Test 48 | fun `should have missing any fact field`() { 49 | val (field, factRaw) = missingFieldsList.shuffled().first() 50 | val missingFields = setOf(field) 51 | val raw = FactsResponseRaw( 52 | result = listOf(factRaw) 53 | ) 54 | val expected = NetworkingError.EssentialParamMissing( 55 | missingParams = missingFields.toString(), 56 | rawObject = raw 57 | ) 58 | assertThatThrownBy { 59 | mapper.invoke(raw) 60 | }.isInstanceOf(expected::class.java) 61 | .hasMessage(expected.message) 62 | } 63 | 64 | @Test 65 | fun `should map raw to model successfully`() { 66 | val factRaw = FactRaw( 67 | id = "id", 68 | url = "url", 69 | value = "value", 70 | categories = emptyList(), 71 | ) 72 | val expected = listOf( 73 | Fact( 74 | id = factRaw.id ?: "", 75 | url = factRaw.url ?: "", 76 | value = factRaw.value ?: "", 77 | categories = factRaw.categories ?: emptyList() 78 | ) 79 | ) 80 | val raw = FactsResponseRaw(result = listOf(factRaw)) 81 | val result = mapper.invoke(raw) 82 | assertThat(result).isEqualTo(expected) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /features/norris-facts/domain-test/src/test/kotlin/dev/programadorthi/norris/domain/test/FactsUseCaseTest.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.norris.domain.test 2 | 3 | import dev.programadorthi.norris.domain.FactsBusiness 4 | import dev.programadorthi.norris.domain.fake.data.FactsRepositoryFake 5 | import dev.programadorthi.norris.domain.model.Category 6 | import dev.programadorthi.norris.domain.model.Fact 7 | import dev.programadorthi.norris.domain.model.LastSearch 8 | import dev.programadorthi.norris.domain.usecase.FactsUseCase 9 | import dev.programadorthi.norris.domain.usecase.FactsUseCaseFactory 10 | import kotlinx.coroutines.test.runBlockingTest 11 | import org.assertj.core.api.Assertions.assertThat 12 | import org.junit.Before 13 | import org.junit.Test 14 | 15 | class FactsUseCaseTest { 16 | 17 | private val factsRepositoryFake = FactsRepositoryFake() 18 | private lateinit var factsUseCase: FactsUseCase 19 | 20 | @Before 21 | fun `before each test`() { 22 | factsUseCase = FactsUseCaseFactory( 23 | factsRepository = factsRepositoryFake 24 | ) 25 | } 26 | 27 | @Test 28 | fun `should get empty categories when limit is less than or equals to zero`() = 29 | runBlockingTest { 30 | // Given 31 | val expected = emptyList() 32 | // When 33 | val result = factsUseCase.categories(limit = 0, shuffle = false) 34 | val compare = result.getOrNull() 35 | // Then 36 | assertThat(compare).isEqualTo(expected) 37 | } 38 | 39 | @Test 40 | fun `should get categories when limit is greater than zero`() = runBlockingTest { 41 | // Given 42 | val listSize = 5 43 | val expected = List(listSize) { Category(name = "cat$it") } 44 | factsRepositoryFake.addCategory(*expected.toTypedArray()) 45 | // When 46 | val result = factsUseCase.categories(limit = listSize, shuffle = false) 47 | val compare = result.getOrNull() 48 | // Then 49 | assertThat(compare).isEqualTo(expected) 50 | } 51 | 52 | @Test 53 | fun `should get shuffled categories when limit is greater than zero`() = runBlockingTest { 54 | // Given 55 | val listSize = 5 56 | val expected = List(listSize) { Category(name = "cat$it") } 57 | factsRepositoryFake.addCategory(*expected.toTypedArray()) 58 | // When 59 | val result = factsUseCase.categories(limit = listSize, shuffle = true) 60 | val compare = result.getOrNull() 61 | // Then 62 | assertThat(compare) 63 | .hasSize(listSize) 64 | .isNotEqualTo(expected) 65 | } 66 | 67 | @Test 68 | fun `should get empty last searches when no one search was made`() = runBlockingTest { 69 | // Given 70 | val expected = emptyList() 71 | // When 72 | val result = factsUseCase.lastSearches() 73 | val compare = result.getOrNull() 74 | // Then 75 | assertThat(compare).isEqualTo(expected) 76 | } 77 | 78 | @Test 79 | fun `should get last searches when at least one search was made`() = runBlockingTest { 80 | // Given 81 | val expected = List(5) { LastSearch(term = "term$it") } 82 | for (search in expected) { 83 | factsRepositoryFake.addLastSearch(search) 84 | } 85 | // When 86 | val result = factsUseCase.lastSearches() 87 | val compare = result.getOrNull() 88 | // Then 89 | assertThat(compare).isEqualTo(expected) 90 | } 91 | 92 | @Test 93 | fun `should get business validation when search term is blank`() = runBlockingTest { 94 | // Given 95 | val expected = FactsBusiness.EmptySearch 96 | // When 97 | val result = factsUseCase.search(text = " ") 98 | val compare = result.businessOrNull() 99 | // Then 100 | assertThat(compare).isEqualTo(expected) 101 | } 102 | 103 | @Test 104 | fun `should get facts when search term is valid`() = runBlockingTest { 105 | // Given 106 | val expected = List(5) { 107 | Fact( 108 | id = "$it", 109 | value = "value$it", 110 | url = "url$it", 111 | categories = emptyList() 112 | ) 113 | } 114 | factsRepositoryFake.addFact(*expected.toTypedArray()) 115 | // When 116 | val result = factsUseCase.search(text = "a b c d") 117 | val compare = result.getOrNull() 118 | // Then 119 | assertThat(compare).isEqualTo(expected) 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /features/norris-facts/domain-test/src/test/kotlin/dev/programadorthi/norris/domain/test/LocalFactsRepositoryTest.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.norris.domain.test 2 | 3 | import dev.programadorthi.norris.domain.data.LocalFactsRepository 4 | import dev.programadorthi.norris.domain.data.local.LocalFactsRepositoryFactory 5 | import dev.programadorthi.norris.domain.model.Category 6 | import dev.programadorthi.norris.domain.model.Fact 7 | import dev.programadorthi.norris.domain.model.LastSearch 8 | import dev.programadorthi.shared.database.fake.SuperAppFake 9 | import kotlinx.coroutines.test.runBlockingTest 10 | import org.assertj.core.api.Assertions.assertThat 11 | import org.junit.After 12 | import org.junit.Before 13 | import org.junit.Test 14 | 15 | class LocalFactsRepositoryTest { 16 | private val fakeSuperApp = SuperAppFake() 17 | 18 | private lateinit var repository: LocalFactsRepository 19 | 20 | @Before 21 | fun `before each test`() { 22 | fakeSuperApp.open() 23 | repository = LocalFactsRepositoryFactory( 24 | database = fakeSuperApp.database.norrisQueries 25 | ) 26 | } 27 | 28 | @After 29 | fun `after each test`() { 30 | fakeSuperApp.close() 31 | } 32 | 33 | @Test 34 | fun `should have no categories when database is created`() = runBlockingTest { 35 | val expected = emptyList() 36 | val result = repository.getCategories() 37 | assertThat(result).isEqualTo(expected) 38 | } 39 | 40 | @Test 41 | fun `should have no facts when database is created`() = runBlockingTest { 42 | val expected = emptyList() 43 | val result = repository.getFacts() 44 | assertThat(result).isEqualTo(expected) 45 | } 46 | 47 | @Test 48 | fun `should have no last searches when database is created`() = runBlockingTest { 49 | val expected = emptyList() 50 | val result = repository.getLastSearches() 51 | assertThat(result).isEqualTo(expected) 52 | } 53 | 54 | @Test 55 | fun `should persist categories when ask to save any`() = runBlockingTest { 56 | val expected = List(5) { index -> "cat$index" } 57 | val categories = expected.map { cat -> Category(name = cat) } 58 | repository.saveCategories(categories) 59 | val result = repository.getCategories() 60 | assertThat(result).isEqualTo(expected) 61 | } 62 | 63 | @Test 64 | fun `should persist facts when ask to save any`() = runBlockingTest { 65 | val expected = List(5) { index -> 66 | Fact( 67 | id = "id$index", 68 | url = "url$index", 69 | value = "value$index", 70 | categories = emptyList() 71 | ) 72 | } 73 | repository.saveFacts(expected) 74 | val result = repository.getFacts() 75 | assertThat(result).isEqualTo(expected) 76 | } 77 | 78 | @Test 79 | fun `should persist last search when ask to save any`() = runBlockingTest { 80 | val expected = listOf("animal") 81 | repository.saveNewSearch(LastSearch(term = expected.first())) 82 | val result = repository.getLastSearches() 83 | assertThat(result).isEqualTo(expected) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /features/norris-facts/domain-test/src/test/kotlin/dev/programadorthi/norris/domain/test/RemoteFactsRepositoryTest.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.norris.domain.test 2 | 3 | import dev.programadorthi.norris.domain.data.RemoteFactsRepository 4 | import dev.programadorthi.norris.domain.data.remote.raw.FactRaw 5 | import dev.programadorthi.norris.domain.data.remote.raw.FactsResponseRaw 6 | import dev.programadorthi.norris.domain.data.remote.repository.RemoteFactsRepositoryFactory 7 | import dev.programadorthi.norris.domain.fake.data.FactsServiceFake 8 | import dev.programadorthi.norris.domain.model.Fact 9 | import dev.programadorthi.shared.network.fake.NetworkManagerFake 10 | import dev.programadorthi.shared.network.fake.RemoteMapperFake 11 | import kotlinx.coroutines.test.TestCoroutineScope 12 | import kotlinx.coroutines.test.runBlockingTest 13 | import org.assertj.core.api.Assertions.assertThat 14 | import org.junit.After 15 | import org.junit.Before 16 | import org.junit.Test 17 | 18 | class RemoteFactsRepositoryTest { 19 | 20 | private val scope = TestCoroutineScope() 21 | private lateinit var factsMapper: RemoteMapperFake> 22 | private lateinit var service: FactsServiceFake 23 | private lateinit var repository: RemoteFactsRepository 24 | 25 | @Before 26 | fun `before each test`() { 27 | factsMapper = RemoteMapperFake() 28 | service = FactsServiceFake() 29 | repository = RemoteFactsRepositoryFactory( 30 | factsMapper = factsMapper, 31 | factsService = service, 32 | networkManager = NetworkManagerFake(scope) 33 | ) 34 | } 35 | 36 | @After 37 | fun `after each test`() { 38 | scope.cleanupTestCoroutines() 39 | } 40 | 41 | @Test 42 | fun `should get empty categories from server`() = scope.runBlockingTest { 43 | val expected = emptyList() 44 | val result = repository.fetchCategories() 45 | assertThat(result).isEqualTo(expected) 46 | } 47 | 48 | @Test 49 | fun `should get empty facts from server when it found nothing`() = scope.runBlockingTest { 50 | service.raw = FactsResponseRaw() 51 | factsMapper.mapper = { emptyList() } 52 | val expected = emptyList() 53 | val result = repository.search(text = "alkjdj fajlkdflk ajsldfjlas d") 54 | assertThat(result).isEqualTo(expected) 55 | } 56 | 57 | @Test 58 | fun `should get categories from server`() = scope.runBlockingTest { 59 | val categories = Array(5) { index -> 60 | "Cat$index" 61 | } 62 | val expected = categories.toList() 63 | service.addCategories(*categories) 64 | val result = repository.fetchCategories() 65 | assertThat(result).isEqualTo(expected) 66 | } 67 | 68 | @Test 69 | fun `should get facts from server when it found any`() = scope.runBlockingTest { 70 | val expected = listOf( 71 | Fact( 72 | id = "id", 73 | url = "url", 74 | value = "value", 75 | categories = emptyList() 76 | ) 77 | ) 78 | val factsRaw = expected.map { fact -> 79 | FactRaw( 80 | id = fact.id, 81 | url = fact.url, 82 | value = fact.value, 83 | categories = fact.categories 84 | ) 85 | } 86 | service.raw = FactsResponseRaw(result = factsRaw) 87 | factsMapper.mapper = { expected } 88 | val result = repository.search(text = "alkjdjfajlkdflkajsldfjlasd") 89 | assertThat(result).isEqualTo(expected) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /features/norris-facts/domain/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /features/norris-facts/domain/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("jvm-project") 3 | } 4 | 5 | dependencies { 6 | api(projects.sharedDomain) 7 | implementation(libs.kotlinx.coroutines.core) 8 | } 9 | -------------------------------------------------------------------------------- /features/norris-facts/domain/src/main/kotlin/dev/programadorthi/norris/domain/FactsBusiness.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.norris.domain 2 | 3 | import dev.programadorthi.shared.domain.Result 4 | 5 | sealed class FactsBusiness : Result.Business { 6 | object EmptySearch : FactsBusiness() 7 | } 8 | -------------------------------------------------------------------------------- /features/norris-facts/domain/src/main/kotlin/dev/programadorthi/norris/domain/data/LocalFactsRepository.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.norris.domain.data 2 | 3 | import dev.programadorthi.norris.domain.model.Category 4 | import dev.programadorthi.norris.domain.model.Fact 5 | import dev.programadorthi.norris.domain.model.LastSearch 6 | 7 | interface LocalFactsRepository { 8 | suspend fun getCategories(): List 9 | suspend fun getFacts(): List 10 | suspend fun getLastSearches(): List 11 | suspend fun saveCategories(categories: List) 12 | suspend fun saveFacts(facts: List) 13 | suspend fun saveNewSearch(lastSearch: LastSearch) 14 | } 15 | -------------------------------------------------------------------------------- /features/norris-facts/domain/src/main/kotlin/dev/programadorthi/norris/domain/data/RemoteFactsRepository.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.norris.domain.data 2 | 3 | import dev.programadorthi.norris.domain.model.Fact 4 | 5 | interface RemoteFactsRepository { 6 | suspend fun fetchCategories(): List 7 | suspend fun search(text: String): List 8 | } 9 | -------------------------------------------------------------------------------- /features/norris-facts/domain/src/main/kotlin/dev/programadorthi/norris/domain/model/Category.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.norris.domain.model 2 | 3 | data class Category(val name: String) 4 | -------------------------------------------------------------------------------- /features/norris-facts/domain/src/main/kotlin/dev/programadorthi/norris/domain/model/Fact.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.norris.domain.model 2 | 3 | data class Fact( 4 | val id: String, 5 | val url: String, 6 | val value: String, 7 | val categories: List 8 | ) 9 | -------------------------------------------------------------------------------- /features/norris-facts/domain/src/main/kotlin/dev/programadorthi/norris/domain/model/LastSearch.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.norris.domain.model 2 | 3 | data class LastSearch(val term: String) 4 | -------------------------------------------------------------------------------- /features/norris-facts/domain/src/main/kotlin/dev/programadorthi/norris/domain/model/presentation/FactViewData.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.norris.domain.model.presentation 2 | 3 | data class FactViewData( 4 | val category: String, 5 | val style: Int, 6 | val url: String, 7 | val value: String 8 | ) 9 | -------------------------------------------------------------------------------- /features/norris-facts/domain/src/main/kotlin/dev/programadorthi/norris/domain/provider/FactsStyleProvider.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.norris.domain.provider 2 | 3 | interface FactsStyleProvider { 4 | fun providerHeadline(): Int 5 | fun providerSubtitle(): Int 6 | 7 | /** 8 | * Provide a style based on predicate result 9 | * 10 | * @return Headline style if predicate returns true. Subtitle style otherwise 11 | */ 12 | fun provideHeadlineOrSubtitle(predicate: () -> Boolean): Int = 13 | if (predicate.invoke()) providerHeadline() else providerSubtitle() 14 | } 15 | -------------------------------------------------------------------------------- /features/norris-facts/domain/src/main/kotlin/dev/programadorthi/norris/domain/provider/FactsTextProvider.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.norris.domain.provider 2 | 3 | interface FactsTextProvider { 4 | fun emptySearchTerm(): String 5 | fun generalFailure(): String 6 | fun withoutCategory(): String 7 | } 8 | -------------------------------------------------------------------------------- /features/norris-facts/domain/src/main/kotlin/dev/programadorthi/norris/domain/repository/FactsRepository.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.norris.domain.repository 2 | 3 | import dev.programadorthi.norris.domain.model.Category 4 | import dev.programadorthi.norris.domain.model.Fact 5 | import dev.programadorthi.norris.domain.model.LastSearch 6 | import dev.programadorthi.shared.domain.Result 7 | 8 | interface FactsRepository { 9 | suspend fun fetchCategories(limit: Int, shuffle: Boolean): Result> 10 | suspend fun getLastSearches(): Result> 11 | suspend fun doSearch(text: String): Result> 12 | } 13 | -------------------------------------------------------------------------------- /features/norris-facts/domain/src/main/kotlin/dev/programadorthi/norris/domain/usecase/FactsUseCase.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.norris.domain.usecase 2 | 3 | import dev.programadorthi.norris.domain.model.Category 4 | import dev.programadorthi.norris.domain.model.Fact 5 | import dev.programadorthi.norris.domain.model.LastSearch 6 | import dev.programadorthi.shared.domain.Result 7 | 8 | interface FactsUseCase { 9 | suspend fun categories(limit: Int, shuffle: Boolean): Result> 10 | suspend fun lastSearches(): Result> 11 | suspend fun search(text: String): Result> 12 | } 13 | -------------------------------------------------------------------------------- /features/norris-facts/domain/src/main/kotlin/dev/programadorthi/norris/domain/viewmodel/FactsViewModel.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.norris.domain.viewmodel 2 | 3 | import dev.programadorthi.norris.domain.model.presentation.FactViewData 4 | import dev.programadorthi.shared.domain.UIState 5 | import dev.programadorthi.shared.domain.viewmodel.ViewModel 6 | import kotlinx.coroutines.flow.StateFlow 7 | 8 | interface FactsViewModel : ViewModel { 9 | val facts: StateFlow>> 10 | 11 | fun search(text: String) 12 | } 13 | -------------------------------------------------------------------------------- /features/norris-facts/domain/src/main/kotlin/dev/programadorthi/norris/domain/viewmodel/SearchFactsViewModel.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.norris.domain.viewmodel 2 | 3 | import dev.programadorthi.shared.domain.UIState 4 | import dev.programadorthi.shared.domain.viewmodel.ViewModel 5 | import kotlinx.coroutines.flow.StateFlow 6 | 7 | interface SearchFactsViewModel : ViewModel { 8 | val categories: StateFlow>> 9 | val lastSearches: StateFlow>> 10 | 11 | fun fetchCategories() 12 | fun fetchLastSearches() 13 | } 14 | -------------------------------------------------------------------------------- /features/norris-facts/ui-fake/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /features/norris-facts/ui-fake/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("com.android.library") 3 | id("android-project") 4 | } 5 | 6 | dependencies { 7 | implementation(projects.features.norrisFacts.domain) 8 | implementation(projects.features.norrisFacts.ui) 9 | implementation(libs.androidx.lifecycle.runtime.ktx) 10 | implementation(libs.androidx.lifecycle.viewmodel.ktx) 11 | implementation(libs.androidx.recyclerview) 12 | implementation(libs.bundles.android.common) 13 | } 14 | -------------------------------------------------------------------------------- /features/norris-facts/ui-fake/consumer-rules.pro: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/programadorthi/android-super-app/f62cb1811621ec328c567bbe6bd61b02890ad46a/features/norris-facts/ui-fake/consumer-rules.pro -------------------------------------------------------------------------------- /features/norris-facts/ui-fake/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.kts. 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 -------------------------------------------------------------------------------- /features/norris-facts/ui-fake/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /features/norris-facts/ui-fake/src/main/kotlin/dev/programadorthi/norris/ui/fake/EmptyActivityFake.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.norris.ui.fake 2 | 3 | import android.os.Bundle 4 | import androidx.appcompat.app.AppCompatActivity 5 | import dev.programadorthi.norris.ui.fake.databinding.ActivityEmptyFakeBinding 6 | 7 | class EmptyActivityFake : AppCompatActivity() { 8 | private val binding by lazy { 9 | ActivityEmptyFakeBinding.inflate(layoutInflater) 10 | } 11 | 12 | val root by lazy { binding.root } 13 | 14 | override fun onCreate(savedInstanceState: Bundle?) { 15 | super.onCreate(savedInstanceState) 16 | setContentView(binding.root) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /features/norris-facts/ui-fake/src/main/kotlin/dev/programadorthi/norris/ui/fake/NorrisApplicationFake.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.norris.ui.fake 2 | 3 | import android.app.Application 4 | 5 | class NorrisApplicationFake : Application() 6 | -------------------------------------------------------------------------------- /features/norris-facts/ui-fake/src/main/kotlin/dev/programadorthi/norris/ui/fake/component/ChipsComponentActionsFake.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.norris.ui.fake.component 2 | 3 | class ChipsComponentActionsFake { 4 | private var chip: String? = null 5 | private var hasChips: Boolean = false 6 | 7 | fun chip(): String? = chip 8 | fun hasChips(): Boolean = hasChips 9 | 10 | fun onChipClicked(chip: String) { 11 | this.chip = chip 12 | } 13 | 14 | fun hasChipsListener(hasChips: Boolean) { 15 | this.hasChips = hasChips 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /features/norris-facts/ui-fake/src/main/kotlin/dev/programadorthi/norris/ui/fake/component/SearchEditTextComponentActionsFake.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.norris.ui.fake.component 2 | 3 | class SearchEditTextComponentActionsFake { 4 | private var term: String? = null 5 | 6 | fun term(): String? = term 7 | 8 | fun onSearch(term: String) { 9 | this.term = term 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /features/norris-facts/ui-fake/src/main/kotlin/dev/programadorthi/norris/ui/fake/component/SuccessComponentActionsFake.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.norris.ui.fake.component 2 | 3 | import dev.programadorthi.norris.domain.model.presentation.FactViewData 4 | 5 | class SuccessComponentActionsFake { 6 | private var emptyDataSet: Boolean = false 7 | private var shared: FactViewData? = null 8 | 9 | fun emptyDataSet() = emptyDataSet 10 | fun shared() = shared 11 | 12 | fun onEmptyDataSet() { 13 | emptyDataSet = true 14 | } 15 | 16 | fun share(fact: FactViewData) { 17 | shared = fact 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /features/norris-facts/ui-fake/src/main/res/layout/activity_empty_fake.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | -------------------------------------------------------------------------------- /features/norris-facts/ui-test/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /features/norris-facts/ui-test/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("com.android.library") 3 | id("android-project") 4 | } 5 | 6 | dependencies { 7 | testImplementation(projects.features.norrisFacts.domainFake) 8 | testImplementation(projects.features.norrisFacts.ui) 9 | testImplementation(projects.features.norrisFacts.uiFake) 10 | testImplementation(libs.androidx.lifecycle.runtime.ktx) 11 | testImplementation(libs.robolectric) 12 | testImplementation(libs.bundles.android.common) 13 | testImplementation(libs.bundles.unit.test) 14 | testImplementation(libs.bundles.android.test) 15 | } 16 | -------------------------------------------------------------------------------- /features/norris-facts/ui-test/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | -------------------------------------------------------------------------------- /features/norris-facts/ui-test/src/test/kotlin/dev/programadorthi/norris/ui/test/component/ChipsComponentTest.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.norris.ui.test.component 2 | 3 | import androidx.core.view.get 4 | import androidx.test.core.app.launchActivity 5 | import com.google.android.material.chip.ChipGroup 6 | import dev.programadorthi.norris.ui.component.ChipsComponent 7 | import dev.programadorthi.norris.ui.fake.EmptyActivityFake 8 | import dev.programadorthi.norris.ui.fake.component.ChipsComponentActionsFake 9 | import dev.programadorthi.shared.domain.UIState 10 | import dev.programadorthi.shared.domain.flow.PropertyUIStateFlow 11 | import org.assertj.core.api.Assertions.assertThat 12 | import org.junit.Before 13 | import org.junit.Test 14 | import org.junit.runner.RunWith 15 | import org.robolectric.RobolectricTestRunner 16 | import kotlin.random.Random 17 | 18 | @RunWith(RobolectricTestRunner::class) 19 | class ChipsComponentTest { 20 | private val random = Random.Default 21 | private val uiState = PropertyUIStateFlow>() 22 | private lateinit var action: ChipsComponentActionsFake 23 | 24 | @Before 25 | fun `before each test`() { 26 | action = ChipsComponentActionsFake() 27 | } 28 | 29 | @Test 30 | fun `should hide all chips when state is not success`() { 31 | launchActivity().onActivity { activity -> 32 | // Given 33 | val chipGroup = ChipGroup(activity) 34 | ChipsComponent( 35 | uiState = uiState.stateFlow, 36 | view = chipGroup, 37 | onChipClicked = action::onChipClicked, 38 | hasChipsListener = action::hasChipsListener 39 | ) 40 | // When 41 | uiState.update(UIState.Loading) 42 | // Then 43 | assertThat(chipGroup.childCount).isZero 44 | assertThat(action.hasChips()).isFalse 45 | } 46 | } 47 | 48 | @Test 49 | fun `should hide all chips when success with empty list`() { 50 | launchActivity().onActivity { activity -> 51 | // Given 52 | val chipGroup = ChipGroup(activity) 53 | ChipsComponent( 54 | uiState = uiState.stateFlow, 55 | view = chipGroup, 56 | onChipClicked = action::onChipClicked, 57 | hasChipsListener = action::hasChipsListener 58 | ) 59 | // When 60 | uiState.update(UIState.Success(emptyList())) 61 | // Then 62 | assertThat(chipGroup.childCount).isZero 63 | assertThat(action.hasChips()).isFalse 64 | } 65 | } 66 | 67 | @Test 68 | fun `should populate chips when success with valid list`() { 69 | launchActivity().onActivity { activity -> 70 | // Given 71 | val chipGroup = ChipGroup(activity) 72 | ChipsComponent( 73 | uiState = uiState.stateFlow, 74 | view = chipGroup, 75 | onChipClicked = action::onChipClicked, 76 | hasChipsListener = action::hasChipsListener 77 | ) 78 | val chips = List(5) { index -> "chip$index" } 79 | // When 80 | uiState.update(UIState.Success(chips)) 81 | // Then 82 | assertThat(chipGroup.childCount).isEqualTo(5) 83 | assertThat(action.hasChips()).isTrue 84 | } 85 | } 86 | 87 | @Test 88 | fun `should handle chip when one is clicked`() { 89 | launchActivity().onActivity { activity -> 90 | // Given 91 | val chipGroup = ChipGroup(activity) 92 | ChipsComponent( 93 | uiState = uiState.stateFlow, 94 | view = chipGroup, 95 | onChipClicked = action::onChipClicked, 96 | hasChipsListener = action::hasChipsListener 97 | ) 98 | val chips = List(5) { index -> "chip$index" } 99 | val chipIndex = random.nextInt(5) 100 | val expected = chips[chipIndex] 101 | // When 102 | uiState.update(UIState.Success(chips)) 103 | chipGroup[chipIndex].performClick() 104 | // Then 105 | assertThat(chipGroup.childCount).isEqualTo(5) 106 | assertThat(action.hasChips()).isTrue 107 | assertThat(action.chip()).isEqualTo(expected) 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /features/norris-facts/ui-test/src/test/kotlin/dev/programadorthi/norris/ui/test/component/ErrorComponentTest.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.norris.ui.test.component 2 | 3 | import androidx.test.core.app.launchActivity 4 | import dev.programadorthi.norris.ui.component.ErrorComponent 5 | import dev.programadorthi.norris.ui.fake.EmptyActivityFake 6 | import dev.programadorthi.shared.domain.UIState 7 | import dev.programadorthi.shared.domain.flow.PropertyUIStateFlow 8 | import org.assertj.core.api.Assertions.assertThat 9 | import org.junit.Test 10 | import org.junit.runner.RunWith 11 | import org.robolectric.RobolectricTestRunner 12 | import org.robolectric.shadows.ShadowToast 13 | 14 | @RunWith(RobolectricTestRunner::class) 15 | class ErrorComponentTest { 16 | private val uiState = PropertyUIStateFlow() 17 | 18 | @Test 19 | fun `should do nothing while state is not an error or business`() { 20 | launchActivity().onActivity { activity -> 21 | // Given 22 | ErrorComponent( 23 | uiState = uiState.stateFlow, 24 | view = activity.root 25 | ) 26 | // When 27 | uiState.update(UIState.Loading) 28 | // Then 29 | assertThat(ShadowToast.shownToastCount()).isZero 30 | } 31 | } 32 | 33 | @Test 34 | fun `should show business toast when has business UIState`() { 35 | launchActivity().onActivity { activity -> 36 | // Given 37 | ErrorComponent( 38 | uiState = uiState.stateFlow, 39 | view = activity.root 40 | ) 41 | val expectedMessage = "business message" 42 | // When 43 | uiState.update(UIState.Business(cause = null, message = expectedMessage)) 44 | // Then 45 | assertThat(ShadowToast.shownToastCount()).isOne 46 | assertThat(ShadowToast.getTextOfLatestToast()).isEqualTo(expectedMessage) 47 | } 48 | } 49 | 50 | @Test 51 | fun `should show error toast when has error UIState`() { 52 | launchActivity().onActivity { activity -> 53 | // Given 54 | ErrorComponent( 55 | uiState = uiState.stateFlow, 56 | view = activity.root 57 | ) 58 | val expectedMessage = "error message" 59 | // When 60 | uiState.update(UIState.Error(cause = null, message = expectedMessage)) 61 | // Then 62 | assertThat(ShadowToast.shownToastCount()).isOne 63 | assertThat(ShadowToast.getTextOfLatestToast()).isEqualTo(expectedMessage) 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /features/norris-facts/ui-test/src/test/kotlin/dev/programadorthi/norris/ui/test/component/LoadingComponentTest.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.norris.ui.test.component 2 | 3 | import androidx.core.view.isVisible 4 | import androidx.test.core.app.launchActivity 5 | import dev.programadorthi.norris.ui.component.LoadingComponent 6 | import dev.programadorthi.norris.ui.fake.EmptyActivityFake 7 | import dev.programadorthi.shared.domain.UIState 8 | import dev.programadorthi.shared.domain.flow.PropertyUIStateFlow 9 | import org.assertj.core.api.Assertions.assertThat 10 | import org.junit.Test 11 | import org.junit.runner.RunWith 12 | import org.robolectric.RobolectricTestRunner 13 | 14 | @RunWith(RobolectricTestRunner::class) 15 | class LoadingComponentTest { 16 | private val uiState = PropertyUIStateFlow() 17 | 18 | @Test 19 | fun `should hide view when state is not loading`() { 20 | launchActivity().onActivity { activity -> 21 | // Given 22 | LoadingComponent( 23 | uiState = uiState.stateFlow, 24 | view = activity.root 25 | ) 26 | // When 27 | uiState.update(UIState.Idle) 28 | // Then 29 | assertThat(activity.root.isVisible).isFalse 30 | } 31 | } 32 | 33 | @Test 34 | fun `should show view when state is loading`() { 35 | launchActivity().onActivity { activity -> 36 | // Given 37 | LoadingComponent( 38 | uiState = uiState.stateFlow, 39 | view = activity.root 40 | ) 41 | // When 42 | uiState.update(UIState.Loading) 43 | // Then 44 | assertThat(activity.root.isVisible).isTrue 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /features/norris-facts/ui-test/src/test/kotlin/dev/programadorthi/norris/ui/test/component/SearchEditTextComponentTest.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.norris.ui.test.component 2 | 3 | import android.view.inputmethod.EditorInfo 4 | import android.widget.EditText 5 | import androidx.test.core.app.launchActivity 6 | import dev.programadorthi.norris.ui.component.SearchEditTextComponent 7 | import dev.programadorthi.norris.ui.fake.EmptyActivityFake 8 | import dev.programadorthi.norris.ui.fake.component.SearchEditTextComponentActionsFake 9 | import org.assertj.core.api.Assertions.assertThat 10 | import org.junit.Before 11 | import org.junit.Test 12 | import org.junit.runner.RunWith 13 | import org.robolectric.RobolectricTestRunner 14 | 15 | @RunWith(RobolectricTestRunner::class) 16 | class SearchEditTextComponentTest { 17 | private lateinit var action: SearchEditTextComponentActionsFake 18 | 19 | @Before 20 | fun `before each test`() { 21 | action = SearchEditTextComponentActionsFake() 22 | } 23 | 24 | @Test 25 | fun `should do nothing when attach the EditText`() { 26 | launchActivity().onActivity { activity -> 27 | // Given 28 | val editText = EditText(activity) 29 | SearchEditTextComponent( 30 | view = editText, 31 | onDoSearch = action::onSearch 32 | ) 33 | // Then 34 | assertThat(action.term()).isNull() 35 | } 36 | } 37 | 38 | @Test 39 | fun `should search empty term when click keyboard search`() { 40 | launchActivity().onActivity { activity -> 41 | // Given 42 | val editText = EditText(activity) 43 | SearchEditTextComponent( 44 | view = editText, 45 | onDoSearch = action::onSearch 46 | ) 47 | val expected = "" 48 | // When 49 | editText.onEditorAction(EditorInfo.IME_ACTION_SEARCH) 50 | // Then 51 | assertThat(action.term()).isEqualTo(expected) 52 | } 53 | } 54 | 55 | @Test 56 | fun `should search term when click keyboard search`() { 57 | launchActivity().onActivity { activity -> 58 | // Given 59 | val editText = EditText(activity) 60 | SearchEditTextComponent( 61 | view = editText, 62 | onDoSearch = action::onSearch 63 | ) 64 | val expected = "animal" 65 | // When 66 | editText.apply { 67 | setText(expected) 68 | onEditorAction(EditorInfo.IME_ACTION_SEARCH) 69 | } 70 | // Then 71 | assertThat(action.term()).isEqualTo(expected) 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /features/norris-facts/ui-test/src/test/resources/robolectric.properties: -------------------------------------------------------------------------------- 1 | application=dev.programadorthi.norris.ui.fake.NorrisApplicationFake 2 | sdk=28 -------------------------------------------------------------------------------- /features/norris-facts/ui/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /features/norris-facts/ui/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("com.android.library") 3 | id("android-project") 4 | } 5 | 6 | dependencies { 7 | api(projects.features.norrisFacts.di) 8 | api(projects.sharedUiDi) 9 | implementation(libs.androidx.lifecycle.runtime.ktx) 10 | implementation(libs.androidx.lifecycle.viewmodel.ktx) 11 | implementation(libs.androidx.recyclerview) 12 | implementation(libs.kodein.di) 13 | implementation(libs.kodein.android) 14 | implementation(libs.bundles.android.common) 15 | } 16 | -------------------------------------------------------------------------------- /features/norris-facts/ui/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /features/norris-facts/ui/src/main/kotlin/dev/programadorthi/norris/ui/activity/FactsActivity.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.norris.ui.activity 2 | 3 | import android.app.Activity 4 | import android.content.Intent 5 | import android.os.Bundle 6 | import android.view.Menu 7 | import android.view.MenuItem 8 | import android.widget.Toast 9 | import androidx.appcompat.app.AppCompatActivity 10 | import dev.programadorthi.norris.domain.model.presentation.FactViewData 11 | import dev.programadorthi.norris.domain.viewmodel.FactsViewModel 12 | import dev.programadorthi.norris.ui.R 13 | import dev.programadorthi.norris.ui.component.ErrorComponent 14 | import dev.programadorthi.norris.ui.component.LoadingComponent 15 | import dev.programadorthi.norris.ui.component.SuccessComponent 16 | import dev.programadorthi.norris.ui.databinding.ActivityFactsBinding 17 | import dev.programadorthi.shared.ui.di.ext.viewModel 18 | import org.kodein.di.DI 19 | import org.kodein.di.DIAware 20 | import org.kodein.di.android.closestDI 21 | 22 | class FactsActivity : AppCompatActivity(), DIAware { 23 | override val di: DI by closestDI() 24 | 25 | private val factsViewModel: FactsViewModel by viewModel() 26 | private val binding by lazy { 27 | ActivityFactsBinding.inflate(layoutInflater) 28 | } 29 | 30 | override fun onCreate(savedInstanceState: Bundle?) { 31 | super.onCreate(savedInstanceState) 32 | setContentView(binding.root) 33 | // TODO: using facts container but could be a Error Custom View managed by ErrorComponent 34 | ErrorComponent( 35 | uiState = factsViewModel.facts, 36 | view = binding.factsContainer 37 | ) 38 | LoadingComponent( 39 | uiState = factsViewModel.facts, 40 | view = binding.factsProgressBar 41 | ) 42 | SuccessComponent( 43 | uiState = factsViewModel.facts, 44 | view = binding.factsRecyclerView, 45 | shareFact = ::shareFact, 46 | onEmptyDataSet = { 47 | Toast.makeText( 48 | this, 49 | R.string.activity_facts_empty_search_result, 50 | Toast.LENGTH_LONG 51 | ).show() 52 | } 53 | ) 54 | } 55 | 56 | override fun onCreateOptionsMenu(menu: Menu?): Boolean { 57 | menuInflater.inflate(R.menu.chuck_norris_menu, menu) 58 | return true 59 | } 60 | 61 | override fun onOptionsItemSelected(item: MenuItem): Boolean { 62 | if (item.itemId == R.id.menuSearch) { 63 | startSearch() 64 | return true 65 | } 66 | return super.onOptionsItemSelected(item) 67 | } 68 | 69 | override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { 70 | super.onActivityResult(requestCode, resultCode, data) 71 | if (requestCode == SEARCH_FACT_REQUEST_CODE && resultCode == Activity.RESULT_OK) { 72 | val query = data?.getStringExtra(SEARCH_RESULT_EXTRA_KEY) ?: EMPTY_TEXT 73 | factsViewModel.search(query) 74 | } 75 | } 76 | 77 | private fun shareFact(factViewData: FactViewData) { 78 | val shareFactIntent = Intent().apply { 79 | action = Intent.ACTION_SEND 80 | putExtra(Intent.EXTRA_TEXT, factViewData.url) 81 | type = SHARE_FACT_CONTENT_TYPE 82 | } 83 | startActivity( 84 | Intent.createChooser(shareFactIntent, "Share this fact") 85 | ) 86 | } 87 | 88 | private fun startSearch() { 89 | val intent = Intent(this, SearchFactsActivity::class.java) 90 | startActivityForResult(intent, SEARCH_FACT_REQUEST_CODE) 91 | } 92 | 93 | companion object { 94 | private const val EMPTY_TEXT = "" 95 | private const val SHARE_FACT_CONTENT_TYPE = "text/plain" 96 | private const val SEARCH_FACT_REQUEST_CODE = 999 97 | 98 | const val SEARCH_RESULT_EXTRA_KEY = "search_result" 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /features/norris-facts/ui/src/main/kotlin/dev/programadorthi/norris/ui/activity/SearchFactsActivity.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.norris.ui.activity 2 | 3 | import android.app.Activity 4 | import android.content.Intent 5 | import android.os.Bundle 6 | import androidx.appcompat.app.AppCompatActivity 7 | import androidx.core.view.isVisible 8 | import dev.programadorthi.norris.domain.viewmodel.SearchFactsViewModel 9 | import dev.programadorthi.norris.ui.component.ChipsComponent 10 | import dev.programadorthi.norris.ui.component.SearchEditTextComponent 11 | import dev.programadorthi.norris.ui.databinding.ActivitySearchFactsBinding 12 | import dev.programadorthi.shared.ui.di.ext.viewModel 13 | import org.kodein.di.DI 14 | import org.kodein.di.DIAware 15 | import org.kodein.di.android.closestDI 16 | 17 | class SearchFactsActivity : AppCompatActivity(), DIAware { 18 | override val di: DI by closestDI() 19 | 20 | private val searchFactsViewModel: SearchFactsViewModel by viewModel() 21 | private val viewBinding by lazy { 22 | ActivitySearchFactsBinding.inflate(layoutInflater) 23 | } 24 | 25 | override fun onCreate(savedInstanceState: Bundle?) { 26 | super.onCreate(savedInstanceState) 27 | setContentView(viewBinding.root) 28 | 29 | SearchEditTextComponent(viewBinding.searchFactsEditText, ::goToFactsList) 30 | ChipsComponent( 31 | uiState = searchFactsViewModel.categories, 32 | view = viewBinding.searchFactsCategoriesChipGroup, 33 | onChipClicked = ::goToFactsList, 34 | hasChipsListener = { hasChips -> 35 | viewBinding.suggestionsContainer.isVisible = hasChips 36 | } 37 | ) 38 | ChipsComponent( 39 | uiState = searchFactsViewModel.lastSearches, 40 | view = viewBinding.searchFactsLastSearchesChipGroup, 41 | onChipClicked = ::goToFactsList, 42 | hasChipsListener = { hasChips -> 43 | viewBinding.lastSearchesContainer.isVisible = hasChips 44 | } 45 | ) 46 | 47 | searchFactsViewModel.run { 48 | fetchCategories() 49 | fetchLastSearches() 50 | } 51 | } 52 | 53 | private fun goToFactsList(query: String) { 54 | val intent = Intent().apply { 55 | putExtra(FactsActivity.SEARCH_RESULT_EXTRA_KEY, query) 56 | } 57 | setResult(Activity.RESULT_OK, intent) 58 | finish() 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /features/norris-facts/ui/src/main/kotlin/dev/programadorthi/norris/ui/adapter/FactsAdapter.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.norris.ui.adapter 2 | 3 | import android.view.LayoutInflater 4 | import android.view.ViewGroup 5 | import androidx.recyclerview.widget.RecyclerView 6 | import dev.programadorthi.norris.domain.model.presentation.FactViewData 7 | import dev.programadorthi.norris.ui.databinding.ItemFactBinding 8 | 9 | internal class FactsAdapter( 10 | private val shareAction: (FactViewData) -> Unit 11 | ) : RecyclerView.Adapter() { 12 | private val dataSet = mutableListOf() 13 | 14 | override fun getItemCount(): Int = dataSet.size 15 | 16 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FactsViewHolder { 17 | val inflater = LayoutInflater.from(parent.context) 18 | val view = ItemFactBinding.inflate(inflater) 19 | return FactsViewHolder(view) 20 | } 21 | 22 | override fun onBindViewHolder(holder: FactsViewHolder, position: Int) { 23 | val item = dataSet[position] 24 | holder.bind(item) 25 | holder.itemView.setOnClickListener { 26 | shareAction.invoke(item) 27 | } 28 | } 29 | 30 | fun update(facts: List) { 31 | dataSet.clear() 32 | dataSet.addAll(facts) 33 | notifyDataSetChanged() 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /features/norris-facts/ui/src/main/kotlin/dev/programadorthi/norris/ui/adapter/FactsViewHolder.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.norris.ui.adapter 2 | 3 | import androidx.core.widget.TextViewCompat 4 | import androidx.recyclerview.widget.RecyclerView 5 | import dev.programadorthi.norris.domain.model.presentation.FactViewData 6 | import dev.programadorthi.norris.ui.databinding.ItemFactBinding 7 | 8 | internal class FactsViewHolder( 9 | private val viewBinding: ItemFactBinding 10 | ) : RecyclerView.ViewHolder(viewBinding.root) { 11 | fun bind(fact: FactViewData) { 12 | with(viewBinding) { 13 | TextViewCompat.setTextAppearance(itemFactContentTextView, fact.style) 14 | itemFactContentTextView.text = fact.value 15 | itemFactCategoryTextView.text = fact.category 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /features/norris-facts/ui/src/main/kotlin/dev/programadorthi/norris/ui/component/ChipsComponent.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.norris.ui.component 2 | 3 | import android.view.LayoutInflater 4 | import android.widget.TextView 5 | import androidx.core.view.children 6 | import com.google.android.material.chip.ChipGroup 7 | import dev.programadorthi.norris.ui.R 8 | import dev.programadorthi.shared.domain.UIState 9 | import dev.programadorthi.shared.ui.ext.lifecycleScope 10 | import kotlinx.coroutines.flow.Flow 11 | import kotlinx.coroutines.flow.collect 12 | import kotlinx.coroutines.launch 13 | 14 | class ChipsComponent( 15 | uiState: Flow>>, 16 | private val view: ChipGroup, 17 | private val onChipClicked: (String) -> Unit, 18 | private val hasChipsListener: (Boolean) -> Unit 19 | ) { 20 | init { 21 | view.lifecycleScope.launch { 22 | uiState.collect { state -> 23 | val chips = when (state) { 24 | is UIState.Success -> state.data 25 | else -> emptyList() 26 | } 27 | loadChips(chips) 28 | } 29 | } 30 | } 31 | 32 | private fun loadChips(items: List) { 33 | view.removeAllViews() 34 | val inflater = LayoutInflater.from(view.context) 35 | for (category in items) { 36 | // Because we passing view as root, inflate function returns it 37 | // instead of inflated layout 38 | val chipGroup = inflater.inflate(R.layout.item_search_fact_category, view) as ChipGroup 39 | // Inflate function also call addView when passing a root. 40 | // So in the ChipGroup our inflated layout is the last added view 41 | val chip = chipGroup.children.last() as TextView 42 | chip.text = category 43 | chip.setOnClickListener { onChipClicked(category) } 44 | } 45 | hasChipsListener.invoke(items.isNotEmpty()) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /features/norris-facts/ui/src/main/kotlin/dev/programadorthi/norris/ui/component/ErrorComponent.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.norris.ui.component 2 | 3 | import android.view.View 4 | import android.widget.Toast 5 | import dev.programadorthi.shared.domain.UIState 6 | import dev.programadorthi.shared.ui.ext.lifecycleScope 7 | import kotlinx.coroutines.flow.Flow 8 | import kotlinx.coroutines.flow.collect 9 | import kotlinx.coroutines.launch 10 | 11 | // TODO: maybe move to an UI module 12 | class ErrorComponent( 13 | uiState: Flow>, 14 | view: View 15 | ) { 16 | init { 17 | view.lifecycleScope.launch { 18 | uiState.collect { state -> 19 | val message = when (state) { 20 | is UIState.Business -> state.message 21 | is UIState.Error -> state.message 22 | else -> "" 23 | } 24 | if (message.isNotBlank()) { 25 | // FIXME: using toast here but it could be a TextView 26 | Toast.makeText(view.context, message, Toast.LENGTH_LONG).show() 27 | } 28 | } 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /features/norris-facts/ui/src/main/kotlin/dev/programadorthi/norris/ui/component/LoadingComponent.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.norris.ui.component 2 | 3 | import android.view.View 4 | import androidx.core.view.isVisible 5 | import dev.programadorthi.shared.domain.UIState 6 | import dev.programadorthi.shared.ui.ext.lifecycleScope 7 | import kotlinx.coroutines.flow.Flow 8 | import kotlinx.coroutines.flow.collect 9 | import kotlinx.coroutines.launch 10 | 11 | // TODO: maybe move to an UI module 12 | class LoadingComponent( 13 | uiState: Flow>, 14 | view: View 15 | ) { 16 | init { 17 | view.lifecycleScope.launch { 18 | uiState.collect { state -> 19 | view.isVisible = state is UIState.Loading 20 | } 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /features/norris-facts/ui/src/main/kotlin/dev/programadorthi/norris/ui/component/SearchEditTextComponent.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.norris.ui.component 2 | 3 | import android.view.inputmethod.EditorInfo 4 | import android.widget.EditText 5 | 6 | class SearchEditTextComponent( 7 | view: EditText, 8 | onDoSearch: (String) -> Unit 9 | ) { 10 | init { 11 | view.setOnEditorActionListener { _, actionId, _ -> 12 | if (actionId == EditorInfo.IME_ACTION_SEARCH) { 13 | onDoSearch(view.text.toString()) 14 | } 15 | return@setOnEditorActionListener false 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /features/norris-facts/ui/src/main/kotlin/dev/programadorthi/norris/ui/component/SuccessComponent.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.norris.ui.component 2 | 3 | import androidx.recyclerview.widget.RecyclerView 4 | import dev.programadorthi.norris.domain.model.presentation.FactViewData 5 | import dev.programadorthi.norris.ui.adapter.FactsAdapter 6 | import dev.programadorthi.shared.domain.UIState 7 | import dev.programadorthi.shared.ui.ext.lifecycleScope 8 | import kotlinx.coroutines.flow.Flow 9 | import kotlinx.coroutines.flow.collect 10 | import kotlinx.coroutines.launch 11 | 12 | // Feature specific component. Avoid move to an UI module 13 | class SuccessComponent( 14 | uiState: Flow>>, 15 | view: RecyclerView, 16 | shareFact: (FactViewData) -> Unit, 17 | onEmptyDataSet: () -> Unit 18 | ) { 19 | private val factsAdapter = FactsAdapter(shareFact) 20 | 21 | init { 22 | view.adapter = factsAdapter 23 | view.lifecycleScope.launch { 24 | uiState.collect { state -> 25 | if (state is UIState.Loading) { 26 | factsAdapter.update(emptyList()) 27 | } 28 | if (state is UIState.Success) { 29 | factsAdapter.update(state.data) 30 | if (state.data.isEmpty()) { 31 | onEmptyDataSet.invoke() 32 | } 33 | } 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /features/norris-facts/ui/src/main/kotlin/dev/programadorthi/norris/ui/di/NorrisUIModule.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.norris.ui.di 2 | 3 | import dev.programadorthi.norris.domain.provider.FactsStyleProvider 4 | import dev.programadorthi.norris.domain.provider.FactsTextProvider 5 | import dev.programadorthi.norris.ui.provider.FactsStyleProviderImpl 6 | import dev.programadorthi.norris.ui.provider.FactsTextProviderImpl 7 | import org.kodein.di.DI 8 | import org.kodein.di.bindProvider 9 | import org.kodein.di.instance 10 | 11 | object NorrisUIModule { 12 | operator fun invoke() = DI.Module(name = "norris-ui") { 13 | bindProvider { FactsStyleProviderImpl() } 14 | bindProvider { 15 | FactsTextProviderImpl( 16 | context = instance(), 17 | sharedTextProvider = instance() 18 | ) 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /features/norris-facts/ui/src/main/kotlin/dev/programadorthi/norris/ui/provider/FactsStyleProviderImpl.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.norris.ui.provider 2 | 3 | import com.google.android.material.R 4 | import dev.programadorthi.norris.domain.provider.FactsStyleProvider 5 | 6 | internal class FactsStyleProviderImpl : FactsStyleProvider { 7 | override fun providerHeadline(): Int = 8 | R.style.TextAppearance_MaterialComponents_Headline4 9 | 10 | override fun providerSubtitle(): Int = 11 | R.style.TextAppearance_MaterialComponents_Subtitle1 12 | } 13 | -------------------------------------------------------------------------------- /features/norris-facts/ui/src/main/kotlin/dev/programadorthi/norris/ui/provider/FactsTextProviderImpl.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.norris.ui.provider 2 | 3 | import android.content.Context 4 | import dev.programadorthi.norris.domain.provider.FactsTextProvider 5 | import dev.programadorthi.norris.ui.R 6 | import dev.programadorthi.shared.domain.provider.SharedTextProvider 7 | 8 | internal class FactsTextProviderImpl( 9 | private val context: Context, 10 | private val sharedTextProvider: SharedTextProvider 11 | ) : FactsTextProvider { 12 | override fun emptySearchTerm(): String = 13 | context.getString(R.string.activity_facts_empty_search_term) 14 | 15 | override fun generalFailure(): String = 16 | sharedTextProvider.somethingWrong() 17 | 18 | override fun withoutCategory(): String = 19 | context.getString(R.string.item_fact_view_holder_uncategorized_label) 20 | } 21 | -------------------------------------------------------------------------------- /features/norris-facts/ui/src/main/res/drawable/ic_search_white_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /features/norris-facts/ui/src/main/res/drawable/ic_share_black_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /features/norris-facts/ui/src/main/res/drawable/shape_rectangle_solid_blue.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /features/norris-facts/ui/src/main/res/layout/activity_facts.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 22 | 23 | 33 | 34 | -------------------------------------------------------------------------------- /features/norris-facts/ui/src/main/res/layout/activity_search_facts.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 15 | 16 | 24 | 25 | 33 | 34 | 35 | 36 | 44 | 45 | 53 | 54 | 59 | 60 | 61 | 62 | 70 | 71 | 79 | 80 | 85 | 86 | 87 | 88 | 89 | 90 | -------------------------------------------------------------------------------- /features/norris-facts/ui/src/main/res/layout/item_fact.xml: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 18 | 19 | 25 | 26 | 30 | 31 | 41 | 42 | 46 | 47 | 54 | 55 | 56 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /features/norris-facts/ui/src/main/res/layout/item_search_fact_category.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /features/norris-facts/ui/src/main/res/menu/chuck_norris_menu.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /features/norris-facts/ui/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Search Facts 3 | 4 | Search 5 | 6 | No result for entered term 7 | Please, enter a term to search! 8 | 9 | Suggestions 10 | Enter your search term 11 | Past Searches 12 | 13 | Share 14 | Uncategorized 15 | -------------------------------------------------------------------------------- /generate_modules.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # declare an array of 124 features plus original reach 125 3 | declare -a arr=("aa" "ab" "ac" "ad" "ae" "af" "ag" "ah" "ai" "aj" "ak" "al" "am" "an" "ao" "ap" "aq" "ar" "as" "at" "au" "av" "aw" "ax" "ay" "az" "ba" "bb" "bc" "bd" "be" "bf" "bg" "bh" "bi" "bj" "bk" "bl" "bm" "bn" "bo" "bp" "bq" "br" "bs" "bt" "bu" "bv" "bw" "bx" "by" "bz" "ca" "cb" "cc" "cd" "ce" "cf" "cg" "ch" "ci" "cj" "ck" "cl" "cm" "cn" "co" "cp" "cq" "cr" "cs" "ct" "cu" "cv" "cw" "cx" "cy" "cz" "da" "db" "dc" "dd" "de" "df" "dg" "dh" "di" "dj" "dk" "dl" "dm" "dn" "do" "dp" "dq" "dr" "ds" "dt" "du" "dv" "dw" "dx" "dy" "dz" "ea" "eb" "ec" "ed" "ee" "ef" "eg" "eh" "ei" "ej" "ek" "el" "em" "en" "eo" "ep" "eq" "er" "es" "et") 4 | 5 | # declare an array of submodules because 125 * 8 = 1k features + modules 6 | declare -a arr2=("di" "domain" "domain-fake" "domain-impl" "domain-test" "ui" "ui-fake" "ui-test") 7 | 8 | # define defaults directories 9 | origindir="features/norris-facts" 10 | maindir="$origindir/src/main/kotlin/dev/programadorthi/norris" 11 | testdir="$origindir/src/test/kotlin/dev/programadorthi/norris" 12 | 13 | # base package used in all modules 14 | package="dev.programadorthi.norris" 15 | 16 | # build.gradle.kts (app) line to add implementation after 17 | line=25 18 | 19 | for suffix in "${arr[@]}" 20 | do 21 | newfolder="$origindir-$suffix" 22 | newpackage="$package.$suffix" 23 | 24 | # Create a full copy of original directory 25 | cp -r $origindir $newfolder 26 | 27 | for submodule in "${arr2[@]}" 28 | do 29 | newfoldermaindir="$newfolder/$submodule/$maindir" 30 | newfoldertestdir="$newfolder/$submodule/$testdir" 31 | 32 | if [ -d "$newfoldermaindir" ]; then 33 | rm -rf $newfoldermaindir 34 | mkdir -p "$newfoldermaindir/$suffix" 35 | cp -R "$origindir/$submodule/$maindir"/* "$newfoldermaindir/$suffix" 36 | elif [ -d "$newfoldertestdir" ]; then 37 | rm -rf $newfoldertestdir 38 | mkdir -p "$newfoldertestdir/$suffix" 39 | cp -R "$origindir/$submodule/$testdir"/* "$newfoldertestdir/$suffix" 40 | fi 41 | done 42 | 43 | # Find all *.kt or AndroidManifest.xml files and replace they package with new suffix 44 | find $newfolder -type f \( -iname \*.kt -o -name AndroidManifest.xml \) -exec sed -i "s/$package/$newpackage/g" {} \; 45 | 46 | # Find all Gradle Kotlin DSL files in the new module folder and replace they implementations with new suffix 47 | find $newfolder -type f -name "*.kts" -exec sed -i "s/NorrisFacts/NorrisFacts${suffix^}/g" {} \; 48 | 49 | # Add new module implementation to the app build gradle file 50 | sed -i "$line a implementation(project(LibraryModules.Features.NorrisFacts${suffix^}.UI))" app/build.gradle.kts 51 | 52 | # Increment line number to add next module implementation 53 | ((line=line+1)) 54 | done 55 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | # IDE (e.g. Android Studio) users: 3 | # Gradle settings configured through the IDE *will override* 4 | # any settings specified in this file. 5 | # For more details on how to configure your build environment visit 6 | # http://www.gradle.org/docs/current/userguide/build_environment.html 7 | # Specifies the JVM arguments used for the daemon process. 8 | # The setting is particularly useful for tweaking memory settings. 9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 10 | # When configured, Gradle will run in incubating parallel mode. 11 | # This option should only be used with decoupled projects. More details, visit 12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 13 | org.gradle.parallel=true 14 | # AndroidX package structure to make it clearer which packages are bundled with the 15 | # Android operating system, and which are packaged with your app"s APK 16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 17 | android.useAndroidX=true 18 | # Automatically convert third-party libraries to use AndroidX 19 | android.enableJetifier=false 20 | # Kotlin code style for this project: "official" or "obsolete": 21 | kotlin.code.style=official 22 | # If your environment is memory constraint, you may want to disable file system watching. 23 | # https://docs.gradle.org/7.0/userguide/gradle_daemon.html#sec:daemon_watch_fs 24 | org.gradle.vfs.watch=true -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | # Android 3 | androidMaterial = "1.3.0" 4 | androidXActivityKtx = "1.2.2" 5 | androidXAppCompat = "1.2.0" 6 | androidXCoreKtx = "1.3.2" 7 | androidXLifecycle = "2.3.1" 8 | androidXRecyclerView = "1.2.0-rc01" 9 | androidXTestCoreKtx = "1.3.0" 10 | androidXTestExtJUnit = "1.1.2" 11 | androidXTestEspressoCore = "3.3.0" 12 | # Dependency injection 13 | kodeinDI = "7.5.0" 14 | # Kotlin 15 | kotlinCoroutines = "1.4.3" 16 | kotlinSerialization = "1.1.0" 17 | # Network 18 | okhttp = "4.9.1" 19 | retrofit = "2.9.0" 20 | serializationConverter = "0.8.0" 21 | # SQLDelight 22 | sqldelightDriver = "1.4.4" 23 | # Unit test 24 | assertjCore = "3.19.0" 25 | junit = "4.13.2" 26 | robolectric = "4.5.1" 27 | 28 | [libraries] 29 | # Android 30 | android-material = { module = "com.google.android.material:material", version.ref = "androidMaterial" } 31 | androidx-activity-ktx = { module = "androidx.activity:activity-ktx", version.ref = "androidXActivityKtx" } 32 | androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidXAppCompat" } 33 | androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidXCoreKtx" } 34 | androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "androidXLifecycle" } 35 | androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidXLifecycle" } 36 | androidx-recyclerview = { module = "androidx.recyclerview:recyclerview", version.ref = "androidXRecyclerView" } 37 | androidx-test-core-ktx = { module = "androidx.test:core-ktx", version.ref = "androidXTestCoreKtx" } 38 | androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "androidXTestExtJUnit" } 39 | androidx-test-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidXTestEspressoCore" } 40 | # Dependency injection 41 | kodein-di = { module = "org.kodein.di:kodein-di", version.ref = "kodeinDI" } 42 | kodein-android = { module = "org.kodein.di:kodein-di-framework-android-x", version.ref = "kodeinDI" } 43 | # Kotlin 44 | kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinCoroutines" } 45 | kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinCoroutines" } 46 | kotlinx-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinSerialization" } 47 | #Network 48 | okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } 49 | retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } 50 | serializationConverter = { module = "com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter", version.ref = "serializationConverter" } 51 | # SQLDelight 52 | sqldelight-android-driver = { module = "com.squareup.sqldelight:android-driver", version.ref = "sqldelightDriver" } 53 | sqldelight-sqlite-driver = { module = "com.squareup.sqldelight:sqlite-driver", version.ref = "sqldelightDriver" } 54 | # Unit test 55 | assertj-core = { module = "org.assertj:assertj-core", version.ref = "assertjCore" } 56 | junit = { module = "junit:junit", version.ref = "junit" } 57 | robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } 58 | 59 | [bundles] 60 | android-common = ["android-material", "androidx-activity-ktx", "androidx-appcompat", "androidx-core-ktx"] 61 | android-test = ["androidx-test-core-ktx", "androidx-test-ext-junit", "androidx-test-espresso-core"] 62 | unit-test = ["assertj-core", "junit", "kotlinx-coroutines-test"] -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/programadorthi/android-super-app/f62cb1811621ec328c567bbe6bd61b02890ad46a/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Thu Mar 25 11:45:27 BRT 2021 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.0-bin.zip 7 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /module_graph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/programadorthi/android-super-app/f62cb1811621ec328c567bbe6bd61b02890ad46a/module_graph.png -------------------------------------------------------------------------------- /proguard/kotlinxserialization.pro: -------------------------------------------------------------------------------- 1 | -keepattributes *Annotation*, InnerClasses 2 | -dontnote kotlinx.serialization.AnnotationsKt # core serialization annotations 3 | 4 | # kotlinx-serialization-json specific. Add this if you have java.lang.NoClassDefFoundError kotlinx.serialization.json.JsonObjectSerializer 5 | -keepclassmembers class kotlinx.serialization.json.** { 6 | *** Companion; 7 | } 8 | -keepclasseswithmembers class kotlinx.serialization.json.** { 9 | kotlinx.serialization.KSerializer serializer(...); 10 | } -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | // Enable Gradle's Type-safe dependency accessors 2 | // https://docs.gradle.org/7.0/userguide/declaring_dependencies.html#sec:type-safe-project-accessors 3 | enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") 4 | // Enable Gradle's version catalog support 5 | // https://docs.gradle.org/current/userguide/platforms.html 6 | enableFeaturePreview("VERSION_CATALOGS") 7 | 8 | rootProject.name = "super-app" 9 | 10 | include( 11 | ":app", 12 | ":shared-database", 13 | ":shared-database-di", 14 | ":shared-database-di-android", 15 | ":shared-database-fake", 16 | ":shared-database-test", 17 | ":shared-domain", 18 | ":shared-domain-di", 19 | ":shared-domain-fake", 20 | ":shared-domain-test", 21 | ":shared-network", 22 | ":shared-network-di", 23 | ":shared-network-fake", 24 | ":shared-network-test", 25 | ":shared-retrofit", 26 | ":shared-retrofit-di", 27 | ":shared-ui", 28 | ":shared-ui-di" 29 | ) 30 | include(":features:norris-facts:di") 31 | include(":features:norris-facts:domain") 32 | include(":features:norris-facts:domain-fake") 33 | include(":features:norris-facts:domain-impl") 34 | include(":features:norris-facts:domain-test") 35 | include(":features:norris-facts:ui") 36 | include(":features:norris-facts:ui-fake") 37 | include(":features:norris-facts:ui-test") 38 | -------------------------------------------------------------------------------- /shared-database-di-android/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /shared-database-di-android/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("com.android.library") 3 | id("android-project") 4 | } 5 | 6 | dependencies { 7 | api(projects.sharedDatabaseDi) 8 | implementation(libs.sqldelight.android.driver) 9 | implementation(libs.kodein.di) 10 | } 11 | -------------------------------------------------------------------------------- /shared-database-di-android/consumer-rules.pro: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/programadorthi/android-super-app/f62cb1811621ec328c567bbe6bd61b02890ad46a/shared-database-di-android/consumer-rules.pro -------------------------------------------------------------------------------- /shared-database-di-android/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.kts. 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 -------------------------------------------------------------------------------- /shared-database-di-android/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | -------------------------------------------------------------------------------- /shared-database-di-android/src/main/kotlin/dev/programadorthi/shared/database/android/SharedDatabaseAndroidModule.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.shared.database.android 2 | 3 | import com.squareup.sqldelight.android.AndroidSqliteDriver 4 | import com.squareup.sqldelight.db.SqlDriver 5 | import dev.programadorthi.shared.database.DatabaseInjectionTags 6 | import dev.programadorthi.shared.database.SuperApp 7 | import org.kodein.di.DI 8 | import org.kodein.di.bindSingleton 9 | import org.kodein.di.instance 10 | 11 | object SharedDatabaseAndroidModule { 12 | operator fun invoke() = DI.Module(name = "shared-database-android") { 13 | bindSingleton { 14 | AndroidSqliteDriver( 15 | schema = SuperApp.Schema, 16 | context = instance(), 17 | name = instance(DatabaseInjectionTags.DATABASE_NAME) 18 | ) 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /shared-database-di/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /shared-database-di/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("jvm-project") 3 | } 4 | 5 | dependencies { 6 | api(projects.sharedDatabase) 7 | implementation(libs.kodein.di) 8 | } 9 | -------------------------------------------------------------------------------- /shared-database-di/src/main/kotlin/dev/programadorthi/shared/database/di/SharedDatabaseModule.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.shared.database.di 2 | 3 | import com.squareup.sqldelight.db.SqlDriver 4 | import dev.programadorthi.shared.database.SuperApp 5 | import org.kodein.di.DI 6 | import org.kodein.di.bindSingleton 7 | import org.kodein.di.instance 8 | 9 | object SharedDatabaseModule { 10 | operator fun invoke() = DI.Module(name = "shared-database") { 11 | bindSingleton { 12 | val driver = instance() 13 | SuperApp(driver) 14 | } 15 | 16 | bindSingleton { 17 | instance().norrisQueries 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /shared-database-fake/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /shared-database-fake/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("jvm-project") 3 | } 4 | 5 | dependencies { 6 | api(projects.sharedDatabase) 7 | implementation(libs.sqldelight.sqlite.driver) 8 | } 9 | -------------------------------------------------------------------------------- /shared-database-fake/src/main/kotlin/dev/programadorthi/shared/database/fake/SuperAppFake.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.shared.database.fake 2 | 3 | import com.squareup.sqldelight.db.SqlDriver 4 | import com.squareup.sqldelight.sqlite.driver.JdbcSqliteDriver 5 | import dev.programadorthi.shared.database.SuperApp 6 | 7 | class SuperAppFake { 8 | private var driver: SqlDriver? = null 9 | private var superApp: SuperApp? = null 10 | 11 | val database: SuperApp 12 | get() = superApp 13 | ?: throw IllegalStateException("No database found. Have you called open function?") 14 | 15 | fun open() { 16 | val tempDriver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY) 17 | superApp = SuperApp(tempDriver) 18 | SuperApp.Schema.create(tempDriver) 19 | driver = tempDriver 20 | } 21 | 22 | fun close() { 23 | driver?.close() 24 | driver = null 25 | superApp = null 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /shared-database-test/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /shared-database-test/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("jvm-project") 3 | } 4 | 5 | dependencies { 6 | testImplementation(projects.sharedDatabaseFake) 7 | testImplementation(libs.bundles.unit.test) 8 | } 9 | -------------------------------------------------------------------------------- /shared-database-test/src/test/kotlin/dev/programadorthi/shared/database/test/CategoriesTest.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.shared.database.test 2 | 3 | import dev.programadorthi.shared.database.Categories 4 | import dev.programadorthi.shared.database.fake.SuperAppFake 5 | import org.assertj.core.api.Assertions.assertThat 6 | import org.junit.After 7 | import org.junit.Before 8 | import org.junit.Test 9 | 10 | class CategoriesTest { 11 | private val superAppFake = SuperAppFake() 12 | 13 | @Before 14 | fun `before each test`() { 15 | superAppFake.open() 16 | } 17 | 18 | @After 19 | fun `after each test`() { 20 | superAppFake.close() 21 | } 22 | 23 | @Test 24 | fun `should database has no categories`() { 25 | val expected = emptyList() 26 | val queries = superAppFake.database.norrisQueries 27 | val categories = queries.selectCategories().executeAsList() 28 | assertThat(categories).isEqualTo(expected) 29 | } 30 | 31 | @Test 32 | fun `should persist a category`() { 33 | val expected = "cat1" 34 | val queries = superAppFake.database.norrisQueries 35 | queries.insertCategory(Categories(name = expected)) 36 | val categories = queries.selectCategories().executeAsOne() 37 | assertThat(categories).isEqualTo(expected) 38 | } 39 | 40 | @Test 41 | fun `should persist many categories`() { 42 | val expected = listOf("cat1", "cat2", "cat3", "cat4", "cat5") 43 | val queries = superAppFake.database.norrisQueries 44 | for (cat in expected) { 45 | queries.insertCategory(Categories(name = cat)) 46 | } 47 | val categories = queries.selectCategories().executeAsList() 48 | assertThat(categories).isEqualTo(expected) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /shared-database-test/src/test/kotlin/dev/programadorthi/shared/database/test/FactsTest.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.shared.database.test 2 | 3 | import dev.programadorthi.shared.database.Facts 4 | import dev.programadorthi.shared.database.fake.SuperAppFake 5 | import org.assertj.core.api.Assertions.assertThat 6 | import org.junit.After 7 | import org.junit.Before 8 | import org.junit.Test 9 | 10 | class FactsTest { 11 | private val superAppFake = SuperAppFake() 12 | 13 | @Before 14 | fun `before each test`() { 15 | superAppFake.open() 16 | } 17 | 18 | @After 19 | fun `after each test`() { 20 | superAppFake.close() 21 | } 22 | 23 | @Test 24 | fun `should database has no facts`() { 25 | val expected = emptyList() 26 | val queries = superAppFake.database.norrisQueries 27 | val categories = queries.selectFacts().executeAsList() 28 | assertThat(categories).isEqualTo(expected) 29 | } 30 | 31 | @Test 32 | fun `should persist a fact`() { 33 | val expected = Facts( 34 | id = "id", 35 | url = "url", 36 | text = "text" 37 | ) 38 | val queries = superAppFake.database.norrisQueries 39 | queries.insertFacts(expected) 40 | val categories = queries.selectFacts().executeAsOne() 41 | assertThat(categories).isEqualTo(expected) 42 | } 43 | 44 | @Test 45 | fun `should persist many facts`() { 46 | val expected = List(5) { index -> 47 | Facts( 48 | id = "id$index", 49 | url = "url$index", 50 | text = "text$index" 51 | ) 52 | } 53 | val queries = superAppFake.database.norrisQueries 54 | for (fact in expected) { 55 | queries.insertFacts(fact) 56 | } 57 | val categories = queries.selectFacts().executeAsList() 58 | assertThat(categories).isEqualTo(expected) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /shared-database-test/src/test/kotlin/dev/programadorthi/shared/database/test/LastSearchTest.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.shared.database.test 2 | 3 | import dev.programadorthi.shared.database.LastSearch 4 | import dev.programadorthi.shared.database.fake.SuperAppFake 5 | import org.assertj.core.api.Assertions.assertThat 6 | import org.junit.After 7 | import org.junit.Before 8 | import org.junit.Test 9 | 10 | class LastSearchTest { 11 | private val superAppFake = SuperAppFake() 12 | 13 | @Before 14 | fun `before each test`() { 15 | superAppFake.open() 16 | } 17 | 18 | @After 19 | fun `after each test`() { 20 | superAppFake.close() 21 | } 22 | 23 | @Test 24 | fun `should database has no last search`() { 25 | val expected = emptyList() 26 | val queries = superAppFake.database.norrisQueries 27 | val categories = queries.selectLastSearches().executeAsList() 28 | assertThat(categories).isEqualTo(expected) 29 | } 30 | 31 | @Test 32 | fun `should persist the last search`() { 33 | val expected = listOf("animal") 34 | val queries = superAppFake.database.norrisQueries 35 | for (term in expected) { 36 | queries.insertLastSearch(LastSearch(term = term)) 37 | } 38 | val categories = queries.selectLastSearches().executeAsList() 39 | assertThat(categories).isEqualTo(expected) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /shared-database/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /shared-database/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("jvm-project") 3 | id("com.squareup.sqldelight") 4 | } 5 | 6 | sqldelight { 7 | database("SuperApp") { 8 | packageName = "dev.programadorthi.shared.database" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /shared-database/src/main/kotlin/dev/programadorthi/shared/database/DatabaseInjectionTags.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.shared.database 2 | 3 | object DatabaseInjectionTags { 4 | const val DATABASE_NAME = "database_name" 5 | } 6 | -------------------------------------------------------------------------------- /shared-database/src/main/sqldelight/dev/programadorthi/shared/database/Norris.sq: -------------------------------------------------------------------------------- 1 | CREATE TABLE categories ( 2 | name TEXT NOT NULL PRIMARY KEY 3 | ); 4 | 5 | CREATE TABLE lastSearch ( 6 | term TEXT NOT NULL PRIMARY KEY 7 | ); 8 | 9 | CREATE TABLE facts ( 10 | id TEXT NOT NULL PRIMARY KEY, 11 | url TEXT NOT NULL, 12 | text TEXT NOT NULL 13 | ); 14 | 15 | selectCategories: 16 | SELECT * 17 | FROM categories; 18 | 19 | insertCategory: 20 | INSERT OR REPLACE INTO categories (name) 21 | VALUES ?; 22 | 23 | selectLastSearches: 24 | SELECT * 25 | FROM lastSearch; 26 | 27 | insertLastSearch: 28 | INSERT OR REPLACE INTO lastSearch (term) 29 | VALUES ?; 30 | 31 | selectFacts: 32 | SELECT * 33 | FROM facts; 34 | 35 | insertFacts: 36 | INSERT INTO facts 37 | VALUES ?; -------------------------------------------------------------------------------- /shared-domain-di/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /shared-domain-di/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("jvm-project") 3 | } 4 | 5 | dependencies { 6 | api(projects.sharedDomain) 7 | implementation(libs.kodein.di) 8 | implementation(libs.kotlinx.coroutines.core) 9 | } 10 | -------------------------------------------------------------------------------- /shared-domain-di/src/main/kotlin/dev/programadorthi/shared/domain/di/SharedDomainModule.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.shared.domain.di 2 | 3 | import dev.programadorthi.shared.domain.DomainInjectionTags 4 | import dev.programadorthi.shared.domain.exception.NetworkingErrorMapper 5 | import dev.programadorthi.shared.domain.viewmodel.ViewModelScope 6 | import kotlinx.coroutines.CoroutineDispatcher 7 | import kotlinx.coroutines.Dispatchers 8 | import kotlinx.coroutines.Job 9 | import org.kodein.di.DI 10 | import org.kodein.di.bindProvider 11 | import org.kodein.di.instance 12 | 13 | object SharedDomainModule { 14 | operator fun invoke() = DI.Module("shared-domain") { 15 | bindProvider(tag = DomainInjectionTags.IO_DISPATCHER) { 16 | Dispatchers.IO 17 | } 18 | bindProvider { 19 | NetworkingErrorMapper( 20 | crashReport = instance() 21 | ) 22 | } 23 | bindProvider(tag = DomainInjectionTags.VIEW_MODEL_SCOPE) { 24 | val ioDispatcher: CoroutineDispatcher = instance(DomainInjectionTags.IO_DISPATCHER) 25 | ViewModelScope(coroutineContext = Job() + ioDispatcher) 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /shared-domain-fake/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /shared-domain-fake/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("jvm-project") 3 | } 4 | 5 | dependencies { 6 | api(projects.sharedDomain) 7 | } 8 | -------------------------------------------------------------------------------- /shared-domain-fake/src/main/kotlin/dev/programadorthi/shared/domain/fake/ConnectionCheckFake.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.shared.domain.fake 2 | 3 | import dev.programadorthi.shared.domain.network.ConnectionCheck 4 | 5 | class ConnectionCheckFake(var hasConnection: Boolean = true) : ConnectionCheck { 6 | override suspend fun hasInternetConnection(): Boolean = hasConnection 7 | } 8 | -------------------------------------------------------------------------------- /shared-domain-fake/src/main/kotlin/dev/programadorthi/shared/domain/fake/CrashReportFake.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.shared.domain.fake 2 | 3 | import dev.programadorthi.shared.domain.report.CrashReport 4 | 5 | class CrashReportFake : CrashReport { 6 | var message: String? = null 7 | private set 8 | 9 | var cause: Throwable? = null 10 | private set 11 | 12 | override fun report(message: String, cause: Throwable) { 13 | this.message = message 14 | this.cause = cause 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /shared-domain-fake/src/main/kotlin/dev/programadorthi/shared/domain/fake/SharedTextProviderFake.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.shared.domain.fake 2 | 3 | import dev.programadorthi.shared.domain.provider.SharedTextProvider 4 | 5 | class SharedTextProviderFake : SharedTextProvider { 6 | var noInternetConnection: String = "" 7 | var somethingWrong: String = "" 8 | 9 | override fun noInternetConnection(): String = noInternetConnection 10 | 11 | override fun somethingWrong(): String = somethingWrong 12 | } 13 | -------------------------------------------------------------------------------- /shared-domain-test/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /shared-domain-test/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("jvm-project") 3 | } 4 | 5 | dependencies { 6 | testImplementation(projects.sharedDomainFake) 7 | testImplementation(libs.kotlinx.serialization) 8 | testImplementation(libs.bundles.unit.test) 9 | } 10 | -------------------------------------------------------------------------------- /shared-domain-test/src/test/kotlin/dev/programadorthi/shared/domain/exception/NetworkingErrorMapperTest.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.shared.domain.exception 2 | 3 | import dev.programadorthi.shared.domain.fake.CrashReportFake 4 | import kotlinx.coroutines.test.runBlockingTest 5 | import kotlinx.serialization.SerializationException 6 | import org.assertj.core.api.Assertions.assertThat 7 | import org.junit.Before 8 | import org.junit.Test 9 | import java.io.IOException 10 | import java.net.SocketTimeoutException 11 | import java.net.UnknownHostException 12 | 13 | class NetworkingErrorMapperTest { 14 | 15 | private lateinit var crashReportFake: CrashReportFake 16 | private lateinit var networkingErrorMapper: NetworkingErrorMapper 17 | 18 | @Before 19 | fun `before each test`() { 20 | crashReportFake = CrashReportFake() 21 | networkingErrorMapper = NetworkingErrorMapper(crashReportFake) 22 | } 23 | 24 | @Test 25 | fun `should throw EssentialParamMissing when throwable is an EssentialParamMissing`() = 26 | runBlockingTest { 27 | val expected = NetworkingError.EssentialParamMissing("", 1) 28 | val exception = networkingErrorMapper.mapper(expected) 29 | assertThat(exception).isEqualTo(expected) 30 | } 31 | 32 | @Test 33 | fun `should throw InvalidDataFormat when throwable is a SerializationException`() = 34 | runBlockingTest { 35 | val expected = NetworkingError.InvalidDataFormat 36 | val exception = networkingErrorMapper.mapper(SerializationException("field")) 37 | assertThat(exception).isEqualTo(expected) 38 | } 39 | 40 | @Test 41 | fun `should throw ConnectionTimeout when throwable is a SocketTimeoutException`() = 42 | runBlockingTest { 43 | val expected = NetworkingError.ConnectionTimeout 44 | val exception = networkingErrorMapper.mapper(SocketTimeoutException("field")) 45 | assertThat(exception).isEqualTo(expected) 46 | } 47 | 48 | @Test 49 | fun `should throw UnknownEndpoint when throwable is an UnknownHostException`() = 50 | runBlockingTest { 51 | val throwException = UnknownHostException() 52 | val exception = networkingErrorMapper.mapper(throwException) 53 | assertThat(exception) 54 | .isInstanceOf(NetworkingError.UnknownEndpoint::class.java) 55 | .hasCause(throwException) 56 | } 57 | 58 | @Test 59 | fun `should throw UnknownNetworkException when any other exception`() = runBlockingTest { 60 | val throwException = IOException() 61 | val exception = networkingErrorMapper.mapper(throwException) 62 | assertThat(exception) 63 | .isInstanceOf(NetworkingError.UnknownNetworkException::class.java) 64 | .hasCause(throwException) 65 | } 66 | 67 | @Test 68 | fun `should report EssentialParamMissing when throwable is an EssentialParamMissing`() = 69 | runBlockingTest { 70 | val expected = NetworkingError.EssentialParamMissing("", 1) 71 | networkingErrorMapper.mapper(expected) 72 | assertThat(crashReportFake.cause).isEqualTo(expected) 73 | } 74 | 75 | @Test 76 | fun `should report InvalidDataFormat when throwable is a SerializationException`() = 77 | runBlockingTest { 78 | val expected = SerializationException("field") 79 | networkingErrorMapper.mapper(expected) 80 | assertThat(crashReportFake.cause).isEqualTo(expected) 81 | } 82 | 83 | @Test 84 | fun `should report UnknownEndpoint when throwable is an UnknownHostException`() = 85 | runBlockingTest { 86 | val expected = UnknownHostException() 87 | networkingErrorMapper.mapper(expected) 88 | assertThat(crashReportFake.cause).isEqualTo(expected) 89 | } 90 | 91 | @Test 92 | fun `should report UnknownNetworkException when throwable is any other exception`() = 93 | runBlockingTest { 94 | val expected = IOException() 95 | networkingErrorMapper.mapper(expected) 96 | assertThat(crashReportFake.cause).isEqualTo(expected) 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /shared-domain/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /shared-domain/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("jvm-project") 3 | } 4 | 5 | dependencies { 6 | implementation(libs.kotlinx.coroutines.core) 7 | implementation(libs.kotlinx.serialization) 8 | } 9 | -------------------------------------------------------------------------------- /shared-domain/src/main/kotlin/dev/programadorthi/shared/domain/DomainInjectionTags.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.shared.domain 2 | 3 | object DomainInjectionTags { 4 | const val IO_DISPATCHER = "io_dispatcher" 5 | const val VIEW_MODEL_SCOPE = "view_model_scope" 6 | } 7 | -------------------------------------------------------------------------------- /shared-domain/src/main/kotlin/dev/programadorthi/shared/domain/UIState.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.shared.domain 2 | 3 | sealed class UIState { 4 | object Idle : UIState() 5 | object Loading : UIState() 6 | data class Error(val cause: Throwable?, val message: String) : UIState() 7 | data class Business(val cause: Result.Business?, val message: String) : UIState() 8 | data class Success(val data: R) : UIState() 9 | } 10 | -------------------------------------------------------------------------------- /shared-domain/src/main/kotlin/dev/programadorthi/shared/domain/exception/NetworkingError.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.shared.domain.exception 2 | 3 | sealed class NetworkingError constructor( 4 | message: String? = "", 5 | throwable: Throwable? = null 6 | ) : Exception(message, throwable) { 7 | 8 | object ConnectionTimeout : NetworkingError("Networking operation timed out") 9 | 10 | object InvalidDataFormat : NetworkingError("Invalid response data format") 11 | 12 | object NoInternetConnection : NetworkingError("There is no internet connection") 13 | 14 | class EssentialParamMissing( 15 | missingParams: String, 16 | rawObject: Any 17 | ) : NetworkingError("The $rawObject has missing parameters. They are: $missingParams") 18 | 19 | class UnknownEndpoint( 20 | override val cause: Throwable? 21 | ) : NetworkingError("Unknown endpoint. $cause", cause) 22 | 23 | class UnknownNetworkException( 24 | override val cause: Throwable? 25 | ) : NetworkingError("Unknown network exception. $cause", cause) 26 | } 27 | 28 | internal fun NetworkingError.needsReport(): Boolean = 29 | this is NetworkingError.UnknownEndpoint || 30 | this is NetworkingError.EssentialParamMissing || 31 | this is NetworkingError.InvalidDataFormat || 32 | this is NetworkingError.UnknownNetworkException 33 | -------------------------------------------------------------------------------- /shared-domain/src/main/kotlin/dev/programadorthi/shared/domain/exception/NetworkingErrorMapper.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.shared.domain.exception 2 | 3 | import dev.programadorthi.shared.domain.report.CrashReport 4 | 5 | interface NetworkingErrorMapper { 6 | suspend fun mapper(cause: Throwable): NetworkingError 7 | 8 | companion object Instance { 9 | operator fun invoke(crashReport: CrashReport): NetworkingErrorMapper = 10 | NetworkingErrorMapperImpl(crashReport) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /shared-domain/src/main/kotlin/dev/programadorthi/shared/domain/exception/NetworkingErrorMapperImpl.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.shared.domain.exception 2 | 3 | import dev.programadorthi.shared.domain.report.CrashReport 4 | import kotlinx.serialization.SerializationException 5 | import java.net.SocketTimeoutException 6 | import java.net.UnknownHostException 7 | 8 | internal class NetworkingErrorMapperImpl( 9 | private val crashReport: CrashReport 10 | ) : NetworkingErrorMapper { 11 | override suspend fun mapper(cause: Throwable): NetworkingError { 12 | val error = when (cause) { 13 | is NetworkingError.EssentialParamMissing -> cause 14 | is SerializationException -> NetworkingError.InvalidDataFormat 15 | is SocketTimeoutException -> NetworkingError.ConnectionTimeout 16 | is UnknownHostException -> NetworkingError.UnknownEndpoint(cause) 17 | else -> NetworkingError.UnknownNetworkException(cause) 18 | } 19 | 20 | if (error.needsReport()) { 21 | crashReport.report( 22 | message = "Original cause was mapped to $error", 23 | cause = cause 24 | ) 25 | } 26 | 27 | return error 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /shared-domain/src/main/kotlin/dev/programadorthi/shared/domain/ext/ResultExt.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.shared.domain.ext 2 | 3 | import dev.programadorthi.shared.domain.Result 4 | import dev.programadorthi.shared.domain.UIState 5 | 6 | fun Result.toUIState( 7 | businessMessage: String = "", 8 | failureMessage: String = "", 9 | successMapper: (T?) -> UIState.Success 10 | ) = when { 11 | isBusiness -> UIState.Business( 12 | cause = businessOrNull(), 13 | message = businessMessage 14 | ) 15 | isFailure -> UIState.Error( 16 | cause = exceptionOrNull(), 17 | message = failureMessage 18 | ) 19 | else -> successMapper.invoke(getOrNull()) 20 | } 21 | -------------------------------------------------------------------------------- /shared-domain/src/main/kotlin/dev/programadorthi/shared/domain/flow/PropertyUIStateFlow.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.shared.domain.flow 2 | 3 | import dev.programadorthi.shared.domain.UIState 4 | import kotlinx.coroutines.flow.MutableStateFlow 5 | import kotlinx.coroutines.flow.StateFlow 6 | import kotlinx.coroutines.flow.asStateFlow 7 | 8 | class PropertyUIStateFlow { 9 | private val mutableStateFlow = MutableStateFlow>(UIState.Idle) 10 | val stateFlow: StateFlow> 11 | get() = mutableStateFlow.asStateFlow() 12 | 13 | fun loading() { 14 | mutableStateFlow.value = UIState.Loading 15 | } 16 | 17 | fun update(value: UIState) { 18 | mutableStateFlow.value = value 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /shared-domain/src/main/kotlin/dev/programadorthi/shared/domain/network/ConnectionCheck.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.shared.domain.network 2 | 3 | interface ConnectionCheck { 4 | suspend fun hasInternetConnection(): Boolean 5 | } 6 | -------------------------------------------------------------------------------- /shared-domain/src/main/kotlin/dev/programadorthi/shared/domain/persist/PreferencesManager.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.shared.domain.persist 2 | 3 | interface PreferencesManager { 4 | fun getItem(key: String): T 5 | fun putItem(key: String, item: T) 6 | } 7 | -------------------------------------------------------------------------------- /shared-domain/src/main/kotlin/dev/programadorthi/shared/domain/provider/SharedTextProvider.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.shared.domain.provider 2 | 3 | interface SharedTextProvider { 4 | fun noInternetConnection(): String 5 | fun somethingWrong(): String 6 | } 7 | -------------------------------------------------------------------------------- /shared-domain/src/main/kotlin/dev/programadorthi/shared/domain/report/CrashReport.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.shared.domain.report 2 | 3 | interface CrashReport { 4 | fun report(message: String, cause: Throwable) 5 | } 6 | -------------------------------------------------------------------------------- /shared-domain/src/main/kotlin/dev/programadorthi/shared/domain/viewmodel/ViewModel.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.shared.domain.viewmodel 2 | 3 | interface ViewModel { 4 | fun dispose() 5 | } 6 | -------------------------------------------------------------------------------- /shared-domain/src/main/kotlin/dev/programadorthi/shared/domain/viewmodel/ViewModelScope.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.shared.domain.viewmodel 2 | 3 | import kotlinx.coroutines.CoroutineScope 4 | import kotlinx.coroutines.cancel 5 | import kotlin.coroutines.CoroutineContext 6 | 7 | class ViewModelScope( 8 | override val coroutineContext: CoroutineContext 9 | ) : ViewModel, CoroutineScope { 10 | override fun dispose() = cancel() 11 | } 12 | -------------------------------------------------------------------------------- /shared-network-di/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /shared-network-di/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("jvm-project") 3 | } 4 | 5 | dependencies { 6 | api(projects.sharedNetwork) 7 | implementation(libs.kodein.di) 8 | implementation(libs.kotlinx.coroutines.core) 9 | implementation(libs.kotlinx.serialization) 10 | } 11 | -------------------------------------------------------------------------------- /shared-network-di/src/main/kotlin/dev/programadorthi/shared/network/di/SharedNetworkModule.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.shared.network.di 2 | 3 | import dev.programadorthi.shared.domain.DomainInjectionTags 4 | import dev.programadorthi.shared.network.JsonParser 5 | import dev.programadorthi.shared.network.manager.NetworkManager 6 | import org.kodein.di.DI 7 | import org.kodein.di.bindProvider 8 | import org.kodein.di.bindSingleton 9 | import org.kodein.di.instance 10 | 11 | object SharedNetworkModule { 12 | operator fun invoke() = DI.Module(name = "shared-network") { 13 | bindProvider { 14 | NetworkManager( 15 | connectionCheck = instance(), 16 | networkingErrorMapper = instance(), 17 | ioDispatcher = instance(DomainInjectionTags.IO_DISPATCHER) 18 | ) 19 | } 20 | bindSingleton { JsonParser } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /shared-network-fake/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /shared-network-fake/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("jvm-project") 3 | } 4 | 5 | dependencies { 6 | api(projects.sharedNetwork) 7 | implementation(libs.kotlinx.coroutines.core) 8 | } 9 | -------------------------------------------------------------------------------- /shared-network-fake/src/main/kotlin/dev/programadorthi/shared/network/fake/NetworkManagerFake.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.shared.network.fake 2 | 3 | import dev.programadorthi.shared.network.manager.NetworkManager 4 | import kotlinx.coroutines.CoroutineScope 5 | 6 | class NetworkManagerFake( 7 | private val coroutineScope: CoroutineScope 8 | ) : NetworkManager { 9 | override suspend fun execute( 10 | request: suspend CoroutineScope.() -> T 11 | ): T = request.invoke(coroutineScope) 12 | } 13 | -------------------------------------------------------------------------------- /shared-network-fake/src/main/kotlin/dev/programadorthi/shared/network/fake/RemoteMapperFake.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.shared.network.fake 2 | 3 | import dev.programadorthi.shared.network.mapper.Mapper 4 | import dev.programadorthi.shared.network.mapper.RemoteMapper 5 | 6 | class RemoteMapperFake : RemoteMapper() { 7 | private val fields = mutableSetOf() 8 | lateinit var mapper: Mapper 9 | 10 | override fun checkEssentialParams(missingFields: MutableSet, raw: Raw) { 11 | missingFields += fields 12 | } 13 | 14 | override fun mapRawToModel(raw: Raw): Model = mapper.invoke(raw) 15 | 16 | fun addMissingField(vararg field: String) { 17 | fields += field 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /shared-network-test/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /shared-network-test/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("jvm-project") 3 | } 4 | 5 | dependencies { 6 | testImplementation(projects.sharedDomainFake) 7 | testImplementation(projects.sharedNetworkFake) 8 | implementation(libs.kotlinx.serialization) 9 | testImplementation(libs.bundles.unit.test) 10 | } 11 | -------------------------------------------------------------------------------- /shared-network/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /shared-network/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("jvm-project") 3 | } 4 | 5 | dependencies { 6 | api(projects.sharedDomain) 7 | 8 | implementation(libs.kotlinx.coroutines.core) 9 | implementation(libs.kotlinx.serialization) 10 | 11 | testImplementation(libs.bundles.unit.test) 12 | } 13 | -------------------------------------------------------------------------------- /shared-network/src/main/kotlin/dev/programadorthi/shared/network/JsonParser.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.shared.network 2 | 3 | import kotlinx.serialization.json.Json 4 | 5 | val JsonParser = Json { 6 | allowSpecialFloatingPointValues = true 7 | encodeDefaults = true 8 | ignoreUnknownKeys = true 9 | isLenient = true 10 | useArrayPolymorphism = true 11 | } 12 | -------------------------------------------------------------------------------- /shared-network/src/main/kotlin/dev/programadorthi/shared/network/NetworkInjectionTags.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.shared.network 2 | 3 | object NetworkInjectionTags { 4 | const val BASE_URL = "base_url" 5 | } 6 | -------------------------------------------------------------------------------- /shared-network/src/main/kotlin/dev/programadorthi/shared/network/manager/DefaultNetworkManager.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.shared.network.manager 2 | 3 | import dev.programadorthi.shared.domain.exception.NetworkingError 4 | import dev.programadorthi.shared.domain.exception.NetworkingErrorMapper 5 | import dev.programadorthi.shared.domain.network.ConnectionCheck 6 | import kotlinx.coroutines.CoroutineDispatcher 7 | import kotlinx.coroutines.CoroutineScope 8 | import kotlinx.coroutines.withContext 9 | import kotlinx.serialization.SerializationException 10 | import java.io.IOException 11 | 12 | internal class DefaultNetworkManager( 13 | private val connectionCheck: ConnectionCheck, 14 | private val networkingErrorMapper: NetworkingErrorMapper, 15 | private val ioDispatcher: CoroutineDispatcher 16 | ) : NetworkManager { 17 | override suspend fun execute( 18 | request: suspend CoroutineScope.() -> T 19 | ): T = withContext(ioDispatcher) { 20 | if (connectionCheck.hasInternetConnection().not()) { 21 | throw NetworkingError.NoInternetConnection 22 | } 23 | try { 24 | request.invoke(this) 25 | } catch (ex: SerializationException) { 26 | throw networkingErrorMapper.mapper(ex) 27 | } catch (ex: IOException) { 28 | throw networkingErrorMapper.mapper(ex) 29 | } catch (ex: Throwable) { 30 | throw networkingErrorMapper.mapper(ex) 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /shared-network/src/main/kotlin/dev/programadorthi/shared/network/manager/NetworkManager.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.shared.network.manager 2 | 3 | import dev.programadorthi.shared.domain.exception.NetworkingErrorMapper 4 | import dev.programadorthi.shared.domain.network.ConnectionCheck 5 | import kotlinx.coroutines.CoroutineDispatcher 6 | import kotlinx.coroutines.CoroutineScope 7 | 8 | interface NetworkManager { 9 | suspend fun execute(request: suspend CoroutineScope.() -> T): T 10 | 11 | companion object Instance { 12 | operator fun invoke( 13 | connectionCheck: ConnectionCheck, 14 | networkingErrorMapper: NetworkingErrorMapper, 15 | ioDispatcher: CoroutineDispatcher 16 | ): NetworkManager = 17 | DefaultNetworkManager( 18 | connectionCheck, 19 | networkingErrorMapper, 20 | ioDispatcher 21 | ) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /shared-network/src/main/kotlin/dev/programadorthi/shared/network/mapper/RemoteMapper.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.shared.network.mapper 2 | 3 | import dev.programadorthi.shared.domain.exception.NetworkingError 4 | import dev.programadorthi.shared.domain.exception.NetworkingError.EssentialParamMissing 5 | 6 | typealias Mapper = (From) -> To 7 | 8 | /** 9 | * Base map used for inheritance to map network response in feature model 10 | * 11 | * @param Raw The data returned from server 12 | * @param Model The feature model created from Raw 13 | */ 14 | abstract class RemoteMapper : Mapper { 15 | /** 16 | * Mapper the raw to feature model 17 | * 18 | * @param from The server response data 19 | * @return A feature model with mapped server data 20 | * @throws NetworkingError When the server response is not valid 21 | */ 22 | @Throws(NetworkingError::class) 23 | override fun invoke(from: Raw): Model { 24 | assertEssentialParams(from) 25 | return mapRawToModel(from) 26 | } 27 | 28 | /** 29 | * Check if the required parameters were returned from server 30 | * 31 | * @param raw The server response data 32 | * @throws EssentialParamMissing When required data is missing in the server response 33 | */ 34 | @Throws(EssentialParamMissing::class) 35 | private fun assertEssentialParams(raw: Raw) { 36 | val missingFields = mutableSetOf() 37 | checkEssentialParams(missingFields, raw) 38 | if (missingFields.isNotEmpty()) { 39 | throw EssentialParamMissing(missingParams = missingFields.toString(), rawObject = raw!!) 40 | } 41 | } 42 | 43 | /** 44 | * Check if the specific implementation parameters were return from server 45 | * 46 | * @param missingFields The missing required fields in the server response 47 | * @param raw The server response data 48 | * @return A missing parameters list or empty list when is all ok 49 | */ 50 | protected abstract fun checkEssentialParams(missingFields: MutableSet, raw: Raw) 51 | 52 | /** 53 | * Create a [Model] using the [Raw] values 54 | * 55 | * @param raw The server response data 56 | * @return A model with the raw's values 57 | */ 58 | protected abstract fun mapRawToModel(raw: Raw): Model 59 | } 60 | -------------------------------------------------------------------------------- /shared-retrofit-di/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /shared-retrofit-di/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("jvm-project") 3 | } 4 | 5 | dependencies { 6 | api(projects.sharedNetwork) 7 | api(projects.sharedRetrofit) 8 | implementation(libs.kodein.di) 9 | implementation(libs.kotlinx.serialization) 10 | implementation(libs.retrofit) 11 | } 12 | -------------------------------------------------------------------------------- /shared-retrofit-di/src/main/java/dev/programadorthi/shared/retrofit/di/SharedRetrofitModule.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.shared.retrofit.di 2 | 3 | import dev.programadorthi.shared.network.NetworkInjectionTags 4 | import dev.programadorthi.shared.retrofit.RetrofitBuilder 5 | import org.kodein.di.DI 6 | import org.kodein.di.bindSingleton 7 | import org.kodein.di.instance 8 | 9 | object SharedRetrofitModule { 10 | operator fun invoke() = DI.Module("shared-retrofit") { 11 | bindSingleton { 12 | RetrofitBuilder( 13 | baseUrl = instance(NetworkInjectionTags.BASE_URL), 14 | json = instance() 15 | ) 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /shared-retrofit/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /shared-retrofit/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("jvm-project") 3 | } 4 | 5 | dependencies { 6 | implementation(libs.kotlinx.serialization) 7 | implementation(libs.okhttp) 8 | implementation(libs.retrofit) 9 | implementation(libs.serializationConverter) 10 | } 11 | -------------------------------------------------------------------------------- /shared-retrofit/src/main/kotlin/dev/programadorthi/shared/retrofit/RetrofitBuilder.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.shared.retrofit 2 | 3 | import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory 4 | import kotlinx.serialization.json.Json 5 | import okhttp3.MediaType.Companion.toMediaType 6 | import okhttp3.OkHttpClient 7 | import retrofit2.Retrofit 8 | 9 | object RetrofitBuilder { 10 | private val contentType = "application/json".toMediaType() 11 | 12 | operator fun invoke( 13 | baseUrl: String, 14 | json: Json, 15 | httpClient: OkHttpClient = OkHttpClient() 16 | ): Retrofit = with(Retrofit.Builder()) { 17 | baseUrl(baseUrl) 18 | client(httpClient) 19 | addConverterFactory(json.asConverterFactory(contentType)) 20 | build() 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /shared-ui-di/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /shared-ui-di/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("com.android.library") 3 | id("android-project") 4 | } 5 | 6 | dependencies { 7 | api(projects.sharedUi) 8 | implementation(libs.androidx.lifecycle.viewmodel.ktx) 9 | implementation(libs.kodein.di) 10 | implementation(libs.kodein.android) 11 | implementation(libs.bundles.android.common) 12 | } 13 | -------------------------------------------------------------------------------- /shared-ui-di/consumer-rules.pro: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/programadorthi/android-super-app/f62cb1811621ec328c567bbe6bd61b02890ad46a/shared-ui-di/consumer-rules.pro -------------------------------------------------------------------------------- /shared-ui-di/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.kts. 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 -------------------------------------------------------------------------------- /shared-ui-di/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | -------------------------------------------------------------------------------- /shared-ui-di/src/main/kotlin/dev/programadorthi/shared/ui/di/SharedUIModule.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.shared.ui.di 2 | 3 | import dev.programadorthi.shared.ui.network.ConnectionCheckFactory 4 | import dev.programadorthi.shared.ui.provider.SharedTextProviderFactory 5 | import dev.programadorthi.shared.ui.report.CrashReportFactory 6 | import org.kodein.di.DI 7 | import org.kodein.di.bindProvider 8 | import org.kodein.di.bindSingleton 9 | import org.kodein.di.instance 10 | 11 | object SharedUIModule { 12 | operator fun invoke() = DI.Module("shared-ui") { 13 | bindSingleton { 14 | ConnectionCheckFactory(context = instance()) 15 | } 16 | bindSingleton { CrashReportFactory() } 17 | bindProvider { 18 | SharedTextProviderFactory(context = instance()) 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /shared-ui-di/src/main/kotlin/dev/programadorthi/shared/ui/di/ext/ViewModelExt.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.shared.ui.di.ext 2 | 3 | import androidx.activity.ComponentActivity 4 | import dev.programadorthi.shared.domain.viewmodel.ViewModel 5 | import dev.programadorthi.shared.ui.viewmodel.ViewModelFactory 6 | import dev.programadorthi.shared.ui.viewmodel.ViewModelLazy 7 | import org.kodein.di.DIAware 8 | import org.kodein.di.direct 9 | import org.kodein.type.erased 10 | 11 | inline fun T.viewModel(): Lazy where VM : ViewModel, 12 | T : DIAware, 13 | T : ComponentActivity = 14 | ViewModelLazy( 15 | viewModelClass = VM::class, 16 | storeProducer = { viewModelStore }, 17 | factoryProducer = { 18 | ViewModelFactory( 19 | // Lazy to avoid create an instance without needed 20 | viewModel = { di.direct.Instance(erased()) }, 21 | owner = this, 22 | defaultArgs = intent.extras 23 | ) 24 | } 25 | ) 26 | -------------------------------------------------------------------------------- /shared-ui/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /shared-ui/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("com.android.library") 3 | id("android-project") 4 | } 5 | 6 | dependencies { 7 | api(projects.sharedDomain) 8 | implementation(libs.androidx.lifecycle.runtime.ktx) 9 | implementation(libs.androidx.lifecycle.viewmodel.ktx) 10 | implementation(libs.bundles.android.common) 11 | } 12 | -------------------------------------------------------------------------------- /shared-ui/consumer-rules.pro: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/programadorthi/android-super-app/f62cb1811621ec328c567bbe6bd61b02890ad46a/shared-ui/consumer-rules.pro -------------------------------------------------------------------------------- /shared-ui/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.kts. 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 -------------------------------------------------------------------------------- /shared-ui/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /shared-ui/src/main/kotlin/dev/programadorthi/shared/ui/ext/ViewExt.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.shared.ui.ext 2 | 3 | import android.view.View 4 | import androidx.lifecycle.LifecycleCoroutineScope 5 | import androidx.lifecycle.LifecycleOwner 6 | import androidx.lifecycle.lifecycleScope 7 | 8 | val View.lifecycleScope: LifecycleCoroutineScope 9 | get() = 10 | (context as? LifecycleOwner)?.lifecycleScope 11 | ?: throw IllegalStateException("View context is not a LifecycleOwner: $context") 12 | -------------------------------------------------------------------------------- /shared-ui/src/main/kotlin/dev/programadorthi/shared/ui/network/ConnectionCheckFactory.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.shared.ui.network 2 | 3 | import android.content.Context 4 | import dev.programadorthi.shared.domain.network.ConnectionCheck 5 | 6 | object ConnectionCheckFactory { 7 | operator fun invoke( 8 | context: Context 9 | ): ConnectionCheck = ConnectionCheckImpl(context) 10 | } 11 | -------------------------------------------------------------------------------- /shared-ui/src/main/kotlin/dev/programadorthi/shared/ui/network/ConnectionCheckImpl.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.shared.ui.network 2 | 3 | import android.content.Context 4 | import android.net.ConnectivityManager 5 | import android.net.NetworkCapabilities 6 | import android.os.Build 7 | import androidx.core.content.ContextCompat 8 | import dev.programadorthi.shared.domain.network.ConnectionCheck 9 | 10 | internal class ConnectionCheckImpl(context: Context) : ConnectionCheck { 11 | private val service = ContextCompat.getSystemService(context, ConnectivityManager::class.java) 12 | 13 | override suspend fun hasInternetConnection(): Boolean { 14 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { 15 | return service?.getNetworkCapabilities(service.activeNetwork)?.run { 16 | hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) || 17 | hasTransport(NetworkCapabilities.TRANSPORT_WIFI) 18 | } ?: false 19 | } 20 | return service?.activeNetworkInfo?.isConnectedOrConnecting ?: false 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /shared-ui/src/main/kotlin/dev/programadorthi/shared/ui/provider/SharedTextProviderFactory.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.shared.ui.provider 2 | 3 | import android.content.Context 4 | import dev.programadorthi.shared.domain.provider.SharedTextProvider 5 | 6 | object SharedTextProviderFactory { 7 | operator fun invoke(context: Context): SharedTextProvider = 8 | SharedTextProviderImpl(context) 9 | } 10 | -------------------------------------------------------------------------------- /shared-ui/src/main/kotlin/dev/programadorthi/shared/ui/provider/SharedTextProviderImpl.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.shared.ui.provider 2 | 3 | import android.content.Context 4 | import dev.programadorthi.shared.domain.provider.SharedTextProvider 5 | import dev.programadorthi.shared.ui.R 6 | 7 | internal class SharedTextProviderImpl( 8 | private val context: Context 9 | ) : SharedTextProvider { 10 | override fun noInternetConnection(): String = 11 | context.getString(R.string.no_internet_connection) 12 | 13 | override fun somethingWrong(): String = 14 | context.getString(R.string.something_wrong) 15 | } 16 | -------------------------------------------------------------------------------- /shared-ui/src/main/kotlin/dev/programadorthi/shared/ui/report/CrashReportFactory.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.shared.ui.report 2 | 3 | import dev.programadorthi.shared.domain.report.CrashReport 4 | 5 | object CrashReportFactory { 6 | operator fun invoke(): CrashReport = CrashReportImpl() 7 | } 8 | -------------------------------------------------------------------------------- /shared-ui/src/main/kotlin/dev/programadorthi/shared/ui/report/CrashReportImpl.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.shared.ui.report 2 | 3 | import dev.programadorthi.shared.domain.report.CrashReport 4 | 5 | // FIXME: maybe this guy should be in a specific report module 6 | internal class CrashReportImpl : CrashReport { 7 | override fun report(message: String, cause: Throwable) { 8 | println( 9 | """ 10 | |message: $message 11 | |cause: $cause 12 | """.trimIndent() 13 | ) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /shared-ui/src/main/kotlin/dev/programadorthi/shared/ui/viewmodel/ViewModelContainer.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.shared.ui.viewmodel 2 | 3 | import dev.programadorthi.shared.domain.viewmodel.ViewModel 4 | import androidx.lifecycle.ViewModel as AACViewModel 5 | 6 | class ViewModelContainer( 7 | val viewModel: T 8 | ) : AACViewModel() { 9 | override fun onCleared() { 10 | super.onCleared() 11 | viewModel.dispose() 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /shared-ui/src/main/kotlin/dev/programadorthi/shared/ui/viewmodel/ViewModelFactory.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.shared.ui.viewmodel 2 | 3 | import android.os.Bundle 4 | import androidx.lifecycle.AbstractSavedStateViewModelFactory 5 | import androidx.lifecycle.SavedStateHandle 6 | import androidx.savedstate.SavedStateRegistryOwner 7 | import dev.programadorthi.shared.domain.viewmodel.ViewModel 8 | import androidx.lifecycle.ViewModel as AACViewModel 9 | 10 | class ViewModelFactory( 11 | private val viewModel: () -> R, 12 | owner: SavedStateRegistryOwner, 13 | defaultArgs: Bundle? 14 | ) : AbstractSavedStateViewModelFactory(owner, defaultArgs) { 15 | @Suppress("UNCHECKED_CAST") 16 | override fun create( 17 | key: String, 18 | modelClass: Class, 19 | handle: SavedStateHandle 20 | ): T = ViewModelContainer(viewModel.invoke()) as T 21 | } 22 | -------------------------------------------------------------------------------- /shared-ui/src/main/kotlin/dev/programadorthi/shared/ui/viewmodel/ViewModelLazy.kt: -------------------------------------------------------------------------------- 1 | package dev.programadorthi.shared.ui.viewmodel 2 | 3 | import androidx.lifecycle.ViewModelProvider 4 | import androidx.lifecycle.ViewModelStore 5 | import dev.programadorthi.shared.domain.viewmodel.ViewModel 6 | import kotlin.reflect.KClass 7 | 8 | /** 9 | * Extracted from original AAC [androidx.lifecycle.ViewModelLazy] 10 | */ 11 | class ViewModelLazy( 12 | private val viewModelClass: KClass, 13 | private val storeProducer: () -> ViewModelStore, 14 | private val factoryProducer: () -> ViewModelProvider.Factory 15 | ) : Lazy { 16 | private var cached: VM? = null 17 | 18 | override val value: VM 19 | get() = when (cached) { 20 | is VM -> cached!! 21 | else -> createOne() 22 | } 23 | 24 | override fun isInitialized(): Boolean = cached != null 25 | 26 | @Suppress("UNCHECKED_CAST") 27 | private fun createOne(): VM { 28 | val provider = ViewModelProvider(storeProducer.invoke(), factoryProducer.invoke()) 29 | val viewModel = provider 30 | .get(viewModelClass.java.canonicalName!!, ViewModelContainer::class.java) 31 | .viewModel 32 | return viewModel as? VM 33 | ?: throw IllegalStateException("$viewModelClass not created/found on ViewModelStore") 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /shared-ui/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | You are not connected to the internet 4 | Who doesn\'t go wrong? This time was the Chuck Norris Server. 5 | --------------------------------------------------------------------------------