├── .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 | [](https://github.com/programadorthi/android-super-app/actions)
3 | [](http://kotlinlang.org)
4 | [](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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------