├── .github
└── workflows
│ └── android_build.yml
├── .gitignore
├── .scripts
└── install_ktlint.sh
├── LICENSE
├── README.md
├── app
├── .gitignore
├── build.gradle.kts
├── proguard-rules.pro
└── src
│ └── main
│ ├── AndroidManifest.xml
│ ├── java
│ └── com
│ │ └── ezike
│ │ └── tobenna
│ │ └── starwarssearch
│ │ ├── ApplicationClass.kt
│ │ ├── MainActivity.kt
│ │ └── di
│ │ └── AppModule.kt
│ └── res
│ ├── drawable-v24
│ └── ic_launcher_foreground.xml
│ ├── drawable
│ └── ic_launcher_background.xml
│ ├── layout
│ └── activity_main.xml
│ ├── mipmap-anydpi-v26
│ ├── ic_launcher.xml
│ └── ic_launcher_round.xml
│ ├── mipmap-hdpi
│ ├── ic_launcher.png
│ └── ic_launcher_round.png
│ ├── mipmap-mdpi
│ ├── ic_launcher.png
│ └── ic_launcher_round.png
│ ├── mipmap-xhdpi
│ ├── ic_launcher.png
│ └── ic_launcher_round.png
│ ├── mipmap-xxhdpi
│ ├── ic_launcher.png
│ └── ic_launcher_round.png
│ ├── mipmap-xxxhdpi
│ ├── ic_launcher.png
│ └── ic_launcher_round.png
│ └── values
│ ├── colors.xml
│ └── strings.xml
├── build.gradle.kts
├── buildSrc
├── build.gradle.kts
└── src
│ └── main
│ └── kotlin
│ ├── AppDependencies.kt
│ ├── BuildType.kt
│ ├── Extensions.kt
│ └── plugin
│ ├── kotlin-library.gradle.kts
│ └── spotless.gradle.kts
├── character-detail
├── .gitignore
├── build.gradle.kts
├── consumer-rules.pro
├── proguard-rules.pro
└── src
│ ├── main
│ ├── AndroidManifest.xml
│ ├── java
│ │ └── com
│ │ │ └── ezike
│ │ │ └── tobenna
│ │ │ └── starwarssearch
│ │ │ └── characterdetail
│ │ │ ├── data
│ │ │ ├── ApiService.kt
│ │ │ ├── CharacterDetailEntity.kt
│ │ │ ├── CharacterDetailRepository.kt
│ │ │ └── DataModule.kt
│ │ │ ├── di
│ │ │ └── CharacterDetailModule.kt
│ │ │ ├── mapper
│ │ │ ├── FilmModelMapper.kt
│ │ │ ├── PlanetModelMapper.kt
│ │ │ └── SpecieModelMapper.kt
│ │ │ ├── model
│ │ │ ├── CharacterDetailModel.kt
│ │ │ ├── FilmModel.kt
│ │ │ ├── PlanetModel.kt
│ │ │ └── SpecieModel.kt
│ │ │ ├── presentation
│ │ │ ├── Alias.kt
│ │ │ ├── CharacterDetailViewIntentProcessor.kt
│ │ │ ├── CharacterDetailViewResult.kt
│ │ │ ├── CharacterDetailViewStateMachine.kt
│ │ │ ├── CharacterDetailViewStateReducer.kt
│ │ │ └── viewstate
│ │ │ │ ├── CharacterDetailViewState.kt
│ │ │ │ └── CharacterDetailViewStateFactory.kt
│ │ │ └── ui
│ │ │ ├── CharacterDetailFragment.kt
│ │ │ ├── CharacterDetailViewModel.kt
│ │ │ ├── LoadCharacterDetailIntent.kt
│ │ │ ├── adapter
│ │ │ ├── FilmAdapter.kt
│ │ │ └── SpecieAdapter.kt
│ │ │ └── views
│ │ │ ├── error
│ │ │ ├── DetailErrorView.kt
│ │ │ ├── DetailErrorViewState.kt
│ │ │ ├── DetailErrorViewStateFactory.kt
│ │ │ └── RetryFetchCharacterDetailsIntent.kt
│ │ │ ├── film
│ │ │ ├── FilmView.kt
│ │ │ ├── FilmViewState.kt
│ │ │ ├── FilmViewStateFactory.kt
│ │ │ └── RetryFetchFilmIntent.kt
│ │ │ ├── planet
│ │ │ ├── PlanetView.kt
│ │ │ ├── PlanetViewState.kt
│ │ │ ├── PlanetViewStateFactory.kt
│ │ │ └── RetryFetchPlanetIntent.kt
│ │ │ ├── profile
│ │ │ ├── ProfileView.kt
│ │ │ ├── ProfileViewState.kt
│ │ │ └── ProfileViewStateFactory.kt
│ │ │ └── specie
│ │ │ ├── RetryFetchSpecieIntent.kt
│ │ │ ├── SpecieView.kt
│ │ │ ├── SpecieViewState.kt
│ │ │ └── SpecieViewStateFactory.kt
│ └── res
│ │ ├── drawable
│ │ └── arrow_back.xml
│ │ ├── layout
│ │ ├── detail_loading_layout.xml
│ │ ├── film_view_layout.xml
│ │ ├── fragment_character_detail.xml
│ │ ├── item_film.xml
│ │ ├── item_specie.xml
│ │ ├── planet_view_layout.xml
│ │ ├── profile_view_layout.xml
│ │ └── specie_view_layout.xml
│ │ ├── navigation
│ │ └── detail_nav_graph.xml
│ │ └── values
│ │ ├── colors.xml
│ │ ├── dimens.xml
│ │ └── strings.xml
│ └── test
│ └── java
│ └── com
│ └── ezike
│ └── tobenna
│ └── starwarssearch
│ └── characterdetail
│ ├── data
│ └── DummyData.kt
│ ├── fakes
│ ├── FakeCharacterDetailRepository.kt
│ └── TestPostExecutionThread.kt
│ ├── mapper
│ ├── CharacterDetailModelMapperTest.kt
│ ├── FilmModelMapperTest.kt
│ ├── PlanetModelMapperTest.kt
│ └── SpecieModelMapperTest.kt
│ └── presentation
│ ├── CharacterDetailViewIntentProcessorTest.kt
│ └── CharacterDetailViewStateReducerTest.kt
├── character_search
├── .gitignore
├── build.gradle.kts
├── consumer-rules.pro
├── proguard-rules.pro
└── src
│ ├── androidTest
│ └── java
│ │ └── com
│ │ └── ezike
│ │ └── tobenna
│ │ └── starwarssearch
│ │ └── charactersearch
│ │ ├── CustomTestRunner.kt
│ │ ├── di
│ │ ├── TestModule.kt
│ │ └── fakes
│ │ │ ├── FakeCharacterDetailRepository.kt
│ │ │ ├── FakeSearchHistoryRepository.kt
│ │ │ └── FakeSearchRepository.kt
│ │ └── ui
│ │ ├── DummyData.kt
│ │ └── SearchFragmentTest.kt
│ ├── main
│ ├── AndroidManifest.xml
│ ├── java
│ │ └── com
│ │ │ └── ezike
│ │ │ └── tobenna
│ │ │ └── starwarssearch
│ │ │ └── charactersearch
│ │ │ ├── data
│ │ │ ├── ApiService.kt
│ │ │ ├── CharacterEntity.kt
│ │ │ ├── DataModule.kt
│ │ │ └── SearchRepository.kt
│ │ │ ├── di
│ │ │ └── SearchCharacterModule.kt
│ │ │ ├── mapper
│ │ │ └── CharacterModelMapper.kt
│ │ │ ├── model
│ │ │ └── CharacterModel.kt
│ │ │ ├── navigation
│ │ │ └── Navigator.kt
│ │ │ ├── presentation
│ │ │ ├── Alias.kt
│ │ │ ├── SearchScreenIntent.kt
│ │ │ ├── SearchScreenIntentProcessor.kt
│ │ │ ├── SearchScreenResult.kt
│ │ │ ├── SearchScreenStateMachine.kt
│ │ │ ├── SearchScreenStateReducer.kt
│ │ │ └── viewstate
│ │ │ │ └── SearchScreenState.kt
│ │ │ └── ui
│ │ │ ├── CharacterSearchViewModel.kt
│ │ │ ├── SearchFragment.kt
│ │ │ ├── adapter
│ │ │ ├── SearchHistoryAdapter.kt
│ │ │ └── SearchResultAdapter.kt
│ │ │ └── views
│ │ │ ├── history
│ │ │ ├── SearchHistoryView.kt
│ │ │ └── SearchHistoryViewState.kt
│ │ │ ├── result
│ │ │ ├── SearchResultView.kt
│ │ │ └── SearchResultViewState.kt
│ │ │ └── search
│ │ │ ├── SearchBarView.kt
│ │ │ └── SearchExtensions.kt
│ └── res
│ │ ├── drawable
│ │ ├── ic_baseline_access_time_24.xml
│ │ ├── ic_baseline_keyboard_arrow_right_24.xml
│ │ ├── ic_baseline_search_24.xml
│ │ ├── ic_empty.xml
│ │ └── search_bar_bg.xml
│ │ ├── layout
│ │ ├── fragment_search.xml
│ │ ├── layout_search_history.xml
│ │ ├── layout_search_result.xml
│ │ ├── search_history.xml
│ │ └── search_result.xml
│ │ ├── navigation
│ │ └── search_nav_graph.xml
│ │ └── values
│ │ ├── attrs.xml
│ │ ├── colors.xml
│ │ ├── dimens.xml
│ │ ├── strings.xml
│ │ ├── styles.xml
│ │ └── themes.xml
│ ├── sharedTest
│ └── java
│ │ └── charactersearch
│ │ └── TestPostExecutionThread.kt
│ └── test
│ └── java
│ └── com
│ └── ezike
│ └── tobenna
│ └── starwarssearch
│ └── charactersearch
│ ├── data
│ └── DummyData.kt
│ ├── fakes
│ ├── FakeSearchHistoryRepository.kt
│ └── FakeSearchRepository.kt
│ ├── mapper
│ └── CharacterModelMapperTest.kt
│ └── presentation
│ ├── SearchScreenIntentProcessorTest.kt
│ └── SearchScreenStateReducerTest.kt
├── core
├── .gitignore
├── build.gradle.kts
├── consumer-rules.pro
├── proguard-rules.pro
└── src
│ └── main
│ ├── AndroidManifest.xml
│ ├── java
│ └── com
│ │ └── ezike
│ │ └── tobenna
│ │ └── starwarssearch
│ │ └── core
│ │ ├── AppString.kt
│ │ ├── EmptyStateView.kt
│ │ └── ext
│ │ ├── Extensions.kt
│ │ ├── NavigateBack.kt
│ │ └── ViewExt.kt
│ └── res
│ ├── drawable
│ └── ic_error_page_2.xml
│ ├── layout
│ └── simple_empty_state_view_layout.xml
│ └── values
│ ├── attrs.xml
│ ├── colors.xml
│ ├── dimens.xml
│ ├── strings.xml
│ └── styles.xml
├── gradle.properties
├── gradle
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
├── libraries
├── cache
│ ├── .gitignore
│ ├── build.gradle.kts
│ ├── consumer-rules.pro
│ ├── proguard-rules.pro
│ └── src
│ │ └── main
│ │ ├── AndroidManifest.xml
│ │ └── java
│ │ └── com
│ │ └── ezike
│ │ └── tobenna
│ │ └── starwarssearch
│ │ └── cache
│ │ ├── di
│ │ └── CacheModule.kt
│ │ ├── mapper
│ │ └── CacheModelMapper.kt
│ │ ├── model
│ │ ├── CharacterCacheModel.kt
│ │ └── CharacterDetailCacheModel.kt
│ │ └── room
│ │ ├── CharacterDetailDao.kt
│ │ ├── SearchHistoryDao.kt
│ │ ├── StarWarsDatabase.kt
│ │ └── TypeConverter.kt
├── remote
│ ├── .gitignore
│ ├── build.gradle.kts
│ └── src
│ │ └── main
│ │ └── java
│ │ └── com
│ │ └── ezike
│ │ └── tobenna
│ │ └── starwarssearch
│ │ └── remote
│ │ ├── RemoteFactory.kt
│ │ ├── di
│ │ └── RemoteModule.kt
│ │ ├── interceptor
│ │ ├── HttpsInterceptor.kt
│ │ └── NoInternetInterceptor.kt
│ │ └── mapper
│ │ └── RemoteModelMapper.kt
└── testUtils
│ ├── .gitignore
│ ├── build.gradle.kts
│ └── src
│ └── main
│ └── java
│ └── com
│ └── ezike
│ └── tobenna
│ └── starwarssearch
│ └── testutils
│ ├── Extensions.kt
│ ├── FlowRecorder.kt
│ ├── MainCoroutineRule.kt
│ └── ResponseType.kt
├── navigation
├── .gitignore
├── build.gradle.kts
├── consumer-rules.pro
├── proguard-rules.pro
└── src
│ └── main
│ ├── AndroidManifest.xml
│ ├── java
│ └── com
│ │ └── ezike
│ │ └── tobenna
│ │ └── starwarssearch
│ │ └── navigation
│ │ ├── SearchScreenNavigator.kt
│ │ └── di
│ │ └── NavigationModule.kt
│ └── res
│ └── navigation
│ └── navigation_root.xml
├── presentation-android
├── .gitignore
├── build.gradle.kts
├── consumer-rules.pro
├── proguard-rules.pro
└── src
│ └── main
│ ├── AndroidManifest.xml
│ └── java
│ └── com
│ └── ezike
│ └── tobenna
│ └── starwarssearch
│ └── presentation_android
│ ├── AssistedCreator.kt
│ ├── ComponentManager.kt
│ ├── Disposer.kt
│ └── UIComponent.kt
├── presentation
├── .gitignore
├── build.gradle.kts
└── src
│ └── main
│ └── java
│ └── com
│ └── ezike
│ └── tobenna
│ └── starwarssearch
│ └── presentation
│ ├── base
│ ├── BaseComponentManager.kt
│ ├── IntentProcessor.kt
│ ├── StateReducer.kt
│ ├── Subscriber.kt
│ ├── ViewIntent.kt
│ ├── ViewResult.kt
│ └── ViewState.kt
│ ├── mapper
│ └── ModelMapper.kt
│ └── stateMachine
│ ├── RenderStrategy.kt
│ ├── StateMachine.kt
│ ├── Subscription.kt
│ └── SubscriptionManager.kt
├── process.md
├── settings.gradle.kts
└── setup.sh
/.github/workflows/android_build.yml:
--------------------------------------------------------------------------------
1 | name: Android Build
2 |
3 | on:
4 | pull_request:
5 | branches:
6 | - master
7 | push:
8 |
9 | jobs:
10 | build:
11 | runs-on: ubuntu-latest
12 |
13 | steps:
14 | - uses: actions/checkout@v2
15 |
16 | - name: Set Up JDK
17 | uses: actions/setup-java@v1
18 | with:
19 | java-version: 11
20 |
21 | - name: Run Spotless Apply
22 | run: ./gradlew spotlessApply
23 |
24 | - name: Run Spotless Check
25 | run: ./gradlew spotlessCheck
26 |
27 | - name: Run Tests
28 | run: ./gradlew testDebugUnitTest
29 |
30 | - name: Build Project
31 | run: ./gradlew assembleDebug
32 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Built application files
2 | *.apk
3 | *.ap_
4 |
5 | # Files for the ART/Dalvik VM
6 | *.dex
7 |
8 | # Java class files
9 | *.class
10 |
11 | # Generated files
12 | bin/
13 | gen/
14 | out/
15 |
16 | # Gradle files
17 | /.idea
18 | .gradle/
19 | build/
20 |
21 | # Local configuration file (sdk path, etc)
22 | local.properties
23 |
24 | # Proguard folder generated by Eclipse
25 | proguard/
26 |
27 | # Log Files
28 | *.log
29 |
30 | # Android Studio Navigation editor temp files
31 | .navigation/
32 |
33 | # Android Studio captures folder
34 | captures/
35 |
36 | # Intellij
37 | *.iml
38 | .idea/workspace.xml
39 | .idea/tasks.xml
40 | .idea/gradle.xml
41 | .idea/dictionaries
42 | .idea/libraries
43 |
44 | # Keystore files
45 | *.jks
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 | /.zshrc
58 |
--------------------------------------------------------------------------------
/.scripts/install_ktlint.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | if ! (command -v ktlint)
4 | then
5 | echo "Installing ktlint..."
6 | brew install ktlint
7 | else
8 | echo ">> ktlint is already installed but we'll try to remove and re-install"
9 | brew uninstall ktlint
10 | brew install ktlint
11 | echo ">> ktlint is all set up!"
12 | fi
13 |
14 | echo ">> Installing git pre-commit hook"
15 | ktlint installGitPreCommitHook
16 | echo ">> Git pre-commit hook installed!"
17 |
18 | echo ">> Installing git pre-push hook"
19 | ktlint installGitPrePushHook
20 | echo ">> Git pre-push hook installed!"
21 |
22 | echo "------"
23 |
24 | echo ">> Applying ktlint to IDEA!"
25 | ktlint --android applyToIDEA -y
26 | echo ">> ktlint applied to IDEA!"
27 |
--------------------------------------------------------------------------------
/app/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/app/build.gradle.kts:
--------------------------------------------------------------------------------
1 |
2 | import Dependencies.AndroidX
3 | import Dependencies.DI
4 | import Dependencies.Network
5 | import Dependencies.Performance
6 | import Dependencies.View
7 | import ProjectLib.cache
8 | import ProjectLib.characterDetail
9 | import ProjectLib.characterSearch
10 | import ProjectLib.core
11 | import ProjectLib.navigation
12 | import ProjectLib.presentation
13 | import ProjectLib.presentationAndroid
14 | import ProjectLib.remote
15 |
16 | plugins {
17 | androidApplication
18 | kotlin(kotlinAndroid)
19 | kotlin(kotlinKapt)
20 | safeArgs
21 | daggerHilt
22 | }
23 |
24 | android {
25 | namespace = "com.ezike.tobenna.starwarssearch"
26 | defaultConfig {
27 | applicationId = Config.Android.applicationId
28 | minSdk = Config.Version.minSdkVersion
29 | compileSdk = Config.Version.compileSdkVersion
30 | targetSdk = Config.Version.targetSdkVersion
31 | versionCode = Config.Version.versionCode
32 | versionName = Config.Version.versionName
33 | multiDexEnabled = Config.isMultiDexEnabled
34 | testInstrumentationRunner = Config.Android.testInstrumentationRunner
35 | }
36 |
37 | compileOptions {
38 | sourceCompatibility = JavaVersion.VERSION_11
39 | targetCompatibility = JavaVersion.VERSION_11
40 | }
41 |
42 | buildTypes {
43 | named(BuildType.DEBUG) {
44 | isMinifyEnabled = BuildTypeDebug.isMinifyEnabled
45 | applicationIdSuffix = BuildTypeDebug.applicationIdSuffix
46 | versionNameSuffix = BuildTypeDebug.versionNameSuffix
47 | }
48 | }
49 | }
50 |
51 | hilt {
52 | enableAggregatingTask = true
53 | }
54 |
55 | dependencies {
56 | implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar"))))
57 |
58 | implementation(project(characterSearch))
59 | implementation(project(characterDetail))
60 | implementation(project(cache))
61 | implementation(project(presentation))
62 | implementation(project(presentationAndroid))
63 | implementation(project(remote))
64 | implementation(project(core))
65 | implementation(project(navigation))
66 |
67 | debugImplementation(Performance.leakCanary)
68 |
69 | implementAll(View.components)
70 | implementation(Network.moshi)
71 | implementation(DI.daggerHiltAndroid)
72 | implementation(View.fragment)
73 |
74 | AndroidX.run {
75 | implementation(activity)
76 | implementation(coreKtx)
77 | implementation(navigationFragmentKtx)
78 | implementation(navigationUiKtx)
79 | implementation(multiDex)
80 | }
81 |
82 | kapt(DI.AnnotationProcessor.daggerHilt)
83 | }
84 |
--------------------------------------------------------------------------------
/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.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
--------------------------------------------------------------------------------
/app/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
15 |
16 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/app/src/main/java/com/ezike/tobenna/starwarssearch/ApplicationClass.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch
2 |
3 | import android.app.Application
4 | import dagger.hilt.android.HiltAndroidApp
5 |
6 | @HiltAndroidApp
7 | class ApplicationClass : Application()
8 |
--------------------------------------------------------------------------------
/app/src/main/java/com/ezike/tobenna/starwarssearch/MainActivity.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch
2 |
3 | import android.os.Bundle
4 | import androidx.appcompat.app.AppCompatActivity
5 | import com.ezike.tobenna.starwarssearch.databinding.ActivityMainBinding
6 | import dagger.hilt.android.AndroidEntryPoint
7 |
8 | @AndroidEntryPoint
9 | class MainActivity : AppCompatActivity() {
10 |
11 | override fun onCreate(savedInstanceState: Bundle?) {
12 | super.onCreate(savedInstanceState)
13 | val binding: ActivityMainBinding = ActivityMainBinding.inflate(layoutInflater)
14 | setContentView(binding.root)
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/app/src/main/java/com/ezike/tobenna/starwarssearch/di/AppModule.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.di
2 |
3 | import androidx.fragment.app.FragmentActivity
4 | import androidx.navigation.NavController
5 | import androidx.navigation.fragment.NavHostFragment
6 | import com.ezike.tobenna.starwarssearch.R
7 | import dagger.Module
8 | import dagger.Provides
9 | import dagger.hilt.InstallIn
10 | import dagger.hilt.android.components.ActivityComponent
11 |
12 | @InstallIn(ActivityComponent::class)
13 | @Module
14 | object AppModule {
15 |
16 | @Provides
17 | fun provideNavController(activity: FragmentActivity): NavController =
18 | NavHostFragment.findNavController(
19 | activity.supportFragmentManager.findFragmentById(R.id.mainHostFragment)!!
20 | )
21 | }
22 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable-v24/ic_launcher_foreground.xml:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 |
15 |
18 |
21 |
22 |
23 |
24 |
30 |
31 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/activity_main.xml:
--------------------------------------------------------------------------------
1 |
2 |
13 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ezike/StarWarsSearch-MVI/f091dc9b19b0a4b5b38d27c5331a4d70388f970d/app/src/main/res/mipmap-hdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ezike/StarWarsSearch-MVI/f091dc9b19b0a4b5b38d27c5331a4d70388f970d/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ezike/StarWarsSearch-MVI/f091dc9b19b0a4b5b38d27c5331a4d70388f970d/app/src/main/res/mipmap-mdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ezike/StarWarsSearch-MVI/f091dc9b19b0a4b5b38d27c5331a4d70388f970d/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ezike/StarWarsSearch-MVI/f091dc9b19b0a4b5b38d27c5331a4d70388f970d/app/src/main/res/mipmap-xhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ezike/StarWarsSearch-MVI/f091dc9b19b0a4b5b38d27c5331a4d70388f970d/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ezike/StarWarsSearch-MVI/f091dc9b19b0a4b5b38d27c5331a4d70388f970d/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ezike/StarWarsSearch-MVI/f091dc9b19b0a4b5b38d27c5331a4d70388f970d/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ezike/StarWarsSearch-MVI/f091dc9b19b0a4b5b38d27c5331a4d70388f970d/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ezike/StarWarsSearch-MVI/f091dc9b19b0a4b5b38d27c5331a4d70388f970d/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #FFBB86FC
4 | #FF6200EE
5 | #FF3700B3
6 | #FF03DAC5
7 | #FF018786
8 | #FF000000
9 | #FFFFFFFF
10 |
11 |
--------------------------------------------------------------------------------
/app/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | Star wars search
3 |
4 |
--------------------------------------------------------------------------------
/build.gradle.kts:
--------------------------------------------------------------------------------
1 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
2 |
3 | buildscript {
4 | repositories.applyDefault()
5 | }
6 |
7 | allprojects {
8 | repositories.applyDefault()
9 | configurations.all {
10 | resolutionStrategy.eachDependency {
11 | if (requested.group == "org.jetbrains.kotlin") {
12 | useVersion(kotlinVersion)
13 | }
14 | }
15 | }
16 | }
17 |
18 | subprojects {
19 | applySpotless
20 | tasks.withType().configureEach {
21 | with(kotlinOptions) {
22 | jvmTarget = JavaVersion.VERSION_11.toString()
23 | freeCompilerArgs += "-Xuse-experimental=" +
24 | "kotlin.Experimental," +
25 | "kotlinx.coroutines.ExperimentalCoroutinesApi," +
26 | "kotlinx.coroutines.InternalCoroutinesApi," +
27 | "kotlinx.coroutines.ObsoleteCoroutinesApi," +
28 | "kotlinx.coroutines.FlowPreview"
29 | freeCompilerArgs += "-Xopt-in=kotlin.ExperimentalStdlibApi"
30 | }
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/buildSrc/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | `kotlin-dsl`
3 | `kotlin-dsl-precompiled-script-plugins`
4 | }
5 |
6 | repositories {
7 | google()
8 | mavenCentral()
9 | maven("https://dl.bintray.com/kotlin/kotlin-eap")
10 | maven("https://oss.sonatype.org/content/repositories/snapshots/")
11 | }
12 |
13 | object Plugin {
14 | object Version {
15 | const val spotless: String = "6.11.0"
16 | const val kotlin: String = "1.9.23"
17 | const val androidGradle: String = "8.2.0"
18 | const val navigation: String = "2.7.7"
19 | const val daggerHiltAndroid: String = "2.51"
20 | }
21 |
22 | const val spotless: String = "com.diffplug.spotless:spotless-plugin-gradle:${Version.spotless}"
23 | const val kotlin: String = "org.jetbrains.kotlin:kotlin-gradle-plugin:${Version.kotlin}"
24 | const val androidGradle: String = "com.android.tools.build:gradle:${Version.androidGradle}"
25 | const val navigationSafeArgs: String =
26 | "androidx.navigation:navigation-safe-args-gradle-plugin:${Version.navigation}"
27 | const val daggerHilt: String =
28 | "com.google.dagger:hilt-android-gradle-plugin:${Version.daggerHiltAndroid}"
29 | }
30 |
31 | dependencies {
32 | implementation(Plugin.spotless)
33 | implementation(Plugin.kotlin)
34 | implementation(Plugin.androidGradle)
35 | implementation(Plugin.navigationSafeArgs)
36 | implementation(Plugin.daggerHilt)
37 | }
38 |
--------------------------------------------------------------------------------
/buildSrc/src/main/kotlin/BuildType.kt:
--------------------------------------------------------------------------------
1 | interface BuildType {
2 |
3 | companion object {
4 | const val DEBUG: String = "debug"
5 | const val RELEASE: String = "release"
6 | }
7 |
8 | val isMinifyEnabled: Boolean
9 | val isTestCoverageEnabled: Boolean
10 | }
11 |
12 | object BuildTypeDebug : BuildType {
13 | override val isMinifyEnabled: Boolean = false
14 | override val isTestCoverageEnabled: Boolean = true
15 |
16 | const val applicationIdSuffix: String = ".debug"
17 | const val versionNameSuffix: String = "-DEBUG"
18 | }
19 |
--------------------------------------------------------------------------------
/buildSrc/src/main/kotlin/Extensions.kt:
--------------------------------------------------------------------------------
1 | import org.gradle.api.Project
2 | import org.gradle.api.artifacts.Dependency
3 | import org.gradle.api.artifacts.dsl.DependencyHandler
4 | import org.gradle.api.artifacts.dsl.RepositoryHandler
5 | import org.gradle.api.initialization.dsl.ScriptHandler
6 | import org.gradle.kotlin.dsl.apply
7 | import org.gradle.plugin.use.PluginDependenciesSpec
8 | import org.gradle.plugin.use.PluginDependencySpec
9 |
10 | val PluginDependenciesSpec.androidApplication: PluginDependencySpec
11 | get() = id("com.android.application")
12 |
13 | val PluginDependenciesSpec.androidLibrary: PluginDependencySpec
14 | get() = id("com.android.library")
15 |
16 | val PluginDependenciesSpec.daggerHilt: PluginDependencySpec
17 | get() = id("dagger.hilt.android.plugin")
18 |
19 | val Project.applySpotless
20 | get() = apply(plugin = "spotless")
21 |
22 | val PluginDependenciesSpec.kotlinLibrary: PluginDependencySpec
23 | get() = id("kotlin-library")
24 |
25 | val PluginDependenciesSpec.safeArgs: PluginDependencySpec
26 | get() = id("androidx.navigation.safeargs.kotlin")
27 |
28 | val PluginDependenciesSpec.parcelize: PluginDependencySpec
29 | get() = id("kotlin-parcelize")
30 |
31 | fun RepositoryHandler.maven(url: String) {
32 | maven {
33 | setUrl(url)
34 | }
35 | }
36 |
37 | fun RepositoryHandler.applyDefault() {
38 | google()
39 | mavenCentral()
40 | maven("https://dl.bintray.com/kotlin/kotlin-eap")
41 | maven("https://oss.sonatype.org/content/repositories/snapshots/")
42 | }
43 |
44 | fun DependencyHandler.implementAll(list: List) {
45 | list.forEach {
46 | add("implementation", it)
47 | }
48 | }
49 |
50 | fun DependencyHandler.addPlugins(list: List) {
51 | list.forEach {
52 | add(ScriptHandler.CLASSPATH_CONFIGURATION, it)
53 | }
54 | }
55 |
56 | fun DependencyHandler.kapt(dependencyNotation: String): Dependency? =
57 | add("kapt", dependencyNotation)
58 |
--------------------------------------------------------------------------------
/buildSrc/src/main/kotlin/plugin/kotlin-library.gradle.kts:
--------------------------------------------------------------------------------
1 | import Dependencies.Coroutines
2 |
3 | plugins {
4 | id("kotlin")
5 | }
6 |
7 | java {
8 | sourceCompatibility = JavaVersion.VERSION_11
9 | targetCompatibility = JavaVersion.VERSION_11
10 | }
11 |
12 | kotlin {
13 | explicitApi()
14 | }
15 |
16 | dependencies {
17 | implementation(Coroutines.core)
18 | }
19 |
--------------------------------------------------------------------------------
/buildSrc/src/main/kotlin/plugin/spotless.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | id("com.diffplug.spotless")
3 | }
4 |
5 | spotless {
6 | kotlin {
7 | target(
8 | fileTree(
9 | mapOf(
10 | "dir" to ".",
11 | "include" to listOf("**/*.kt"),
12 | "exclude" to listOf("**/build/**", "**/buildSrc/**", "**/.*", ".idea/")
13 | )
14 | )
15 | )
16 | trimTrailingWhitespace()
17 | indentWithSpaces()
18 | endWithNewline()
19 | }
20 | format("xml") {
21 | target("**/res/**/*.xml")
22 | indentWithSpaces()
23 | trimTrailingWhitespace()
24 | endWithNewline()
25 | }
26 | kotlinGradle {
27 | target(
28 | fileTree(
29 | mapOf(
30 | "dir" to ".",
31 | "include" to listOf("**/*.gradle.kts", "*.gradle.kts"),
32 | "exclude" to listOf("**/build/**")
33 | )
34 | )
35 | )
36 | trimTrailingWhitespace()
37 | indentWithSpaces()
38 | endWithNewline()
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/character-detail/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/character-detail/build.gradle.kts:
--------------------------------------------------------------------------------
1 | import Dependencies.AndroidX
2 | import Dependencies.Coroutines
3 | import Dependencies.DI
4 | import Dependencies.Network
5 | import Dependencies.View
6 | import ProjectLib.cache
7 | import ProjectLib.core
8 | import ProjectLib.presentation
9 | import ProjectLib.presentationAndroid
10 | import ProjectLib.remote
11 | import ProjectLib.testUtils
12 |
13 | plugins {
14 | androidLibrary
15 | kotlin(kotlinAndroid)
16 | parcelize
17 | kotlin(kotlinKapt)
18 | safeArgs
19 | daggerHilt
20 | }
21 |
22 | android {
23 | namespace = "com.ezike.tobenna.starwarssearch.character.detail"
24 | defaultConfig {
25 | compileSdk = Config.Version.compileSdkVersion
26 | minSdk = Config.Version.minSdkVersion
27 | targetSdk = Config.Version.targetSdkVersion
28 | testInstrumentationRunner = Config.Android.testInstrumentationRunner
29 | }
30 |
31 | compileOptions {
32 | sourceCompatibility = JavaVersion.VERSION_11
33 | targetCompatibility = JavaVersion.VERSION_11
34 | }
35 |
36 | buildTypes {
37 | named(BuildType.DEBUG) {
38 | isMinifyEnabled = BuildTypeDebug.isMinifyEnabled
39 | // versionNameSuffix(BuildTypeDebug.versionNameSuffix)
40 | }
41 | }
42 | packagingOptions {
43 | exclude("META-INF/DEPENDENCIES")
44 | exclude("META-INF/LICENSE")
45 | exclude("META-INF/LICENSE.txt")
46 | exclude("META-INF/license.txt")
47 | exclude("META-INF/NOTICE")
48 | exclude("META-INF/NOTICE.txt")
49 | exclude("META-INF/notice.txt")
50 | exclude("META-INF/AL2.0")
51 | exclude("META-INF/LGPL2.1")
52 | exclude("META-INF/*.kotlin_module")
53 | }
54 | }
55 |
56 | dependencies {
57 | implementation(project(core))
58 | implementation(project(presentation))
59 | implementation(project(presentationAndroid))
60 |
61 | implementation(project(remote))
62 | implementation(project(cache))
63 | implementation(Coroutines.core)
64 | implementation(Network.retrofit)
65 |
66 | testImplementation(project(testUtils))
67 | androidTestImplementation(project(testUtils))
68 |
69 | with(View) {
70 | implementAll(components)
71 | implementation(fragment)
72 | implementation(materialComponent)
73 | implementation(constraintLayout)
74 | implementation(cardView)
75 | implementation(recyclerView)
76 | implementation(shimmerLayout)
77 | }
78 |
79 | implementation(DI.daggerHiltAndroid)
80 | implementAll(AndroidX.components)
81 | implementAll(Coroutines.components)
82 |
83 | kapt(DI.AnnotationProcessor.daggerHilt)
84 | }
85 |
--------------------------------------------------------------------------------
/character-detail/consumer-rules.pro:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ezike/StarWarsSearch-MVI/f091dc9b19b0a4b5b38d27c5331a4d70388f970d/character-detail/consumer-rules.pro
--------------------------------------------------------------------------------
/character-detail/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
--------------------------------------------------------------------------------
/character-detail/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/character-detail/src/main/java/com/ezike/tobenna/starwarssearch/characterdetail/data/ApiService.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.characterdetail.data
2 |
3 | import retrofit2.http.GET
4 | import retrofit2.http.Url
5 |
6 | internal interface ApiService {
7 |
8 | @GET
9 | suspend fun fetchCharacterDetail(@Url url: String): CharacterRemoteModel
10 |
11 | @GET
12 | suspend fun fetchSpecieDetails(@Url speciesUrl: String): SpecieEntity
13 |
14 | @GET
15 | suspend fun fetchFilmDetails(@Url filmsUrl: String): FilmEntity
16 |
17 | @GET
18 | suspend fun fetchPlanet(@Url characterUrl: String): PlanetEntity
19 | }
20 |
21 | internal data class FilmEntity(
22 | val title: String,
23 | val opening_crawl: String
24 | )
25 |
26 | internal data class PlanetEntity(
27 | val name: String,
28 | val population: String,
29 | )
30 |
31 | internal data class SpecieEntity(
32 | val name: String,
33 | val language: String,
34 | val homeworld: String?
35 | )
36 |
37 | internal data class CharacterRemoteModel(
38 | val name: String,
39 | val birth_year: String,
40 | val height: String,
41 | val films: List,
42 | val homeworld: String,
43 | val species: List,
44 | val url: String
45 | )
46 |
--------------------------------------------------------------------------------
/character-detail/src/main/java/com/ezike/tobenna/starwarssearch/characterdetail/data/CharacterDetailEntity.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.characterdetail.data
2 |
3 | internal data class CharacterDetailEntity(
4 | val filmUrls: List,
5 | val planetUrl: String,
6 | val speciesUrls: List,
7 | val url: String
8 | )
9 |
10 | internal data class CharacterModel(
11 | val filmUrls: List,
12 | val planetUrl: String,
13 | val speciesUrls: List,
14 | val url: String
15 | )
16 |
--------------------------------------------------------------------------------
/character-detail/src/main/java/com/ezike/tobenna/starwarssearch/characterdetail/data/DataModule.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.characterdetail.data
2 |
3 | import dagger.Binds
4 | import dagger.Module
5 | import dagger.Provides
6 | import dagger.hilt.InstallIn
7 | import dagger.hilt.components.SingletonComponent
8 | import retrofit2.Retrofit
9 | import javax.inject.Singleton
10 |
11 | @InstallIn(SingletonComponent::class)
12 | @Module
13 | internal interface DataModule {
14 |
15 | @get:Binds
16 | val CharacterDetailRepositoryImpl.characterDetailRepository: CharacterDetailRepository
17 |
18 | companion object {
19 | @[Provides Singleton]
20 | fun apiService(retrofit: Retrofit): ApiService =
21 | retrofit.create(ApiService::class.java)
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/character-detail/src/main/java/com/ezike/tobenna/starwarssearch/characterdetail/di/CharacterDetailModule.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.characterdetail.di
2 |
3 | import com.ezike.tobenna.starwarssearch.characterdetail.presentation.CharacterDetailIntentProcessor
4 | import com.ezike.tobenna.starwarssearch.characterdetail.presentation.CharacterDetailStateReducer
5 | import com.ezike.tobenna.starwarssearch.characterdetail.presentation.CharacterDetailViewIntentProcessor
6 | import com.ezike.tobenna.starwarssearch.characterdetail.presentation.CharacterDetailViewStateReducer
7 | import dagger.Binds
8 | import dagger.Module
9 | import dagger.hilt.InstallIn
10 | import dagger.hilt.android.components.ActivityRetainedComponent
11 |
12 | @InstallIn(ActivityRetainedComponent::class)
13 | @Module
14 | internal interface CharacterDetailModule {
15 |
16 | @get:Binds
17 | val CharacterDetailViewIntentProcessor.intentProcessor: CharacterDetailIntentProcessor
18 |
19 | @get:Binds
20 | val CharacterDetailViewStateReducer.reducer: CharacterDetailStateReducer
21 | }
22 |
--------------------------------------------------------------------------------
/character-detail/src/main/java/com/ezike/tobenna/starwarssearch/characterdetail/mapper/FilmModelMapper.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.characterdetail.mapper
2 |
3 | import com.ezike.tobenna.starwarssearch.characterdetail.data.FilmEntity
4 | import com.ezike.tobenna.starwarssearch.characterdetail.model.FilmModel
5 | import com.ezike.tobenna.starwarssearch.presentation.mapper.ModelMapper
6 | import javax.inject.Inject
7 |
8 | internal class FilmModelMapper @Inject constructor() : ModelMapper {
9 |
10 | override fun mapToModel(domain: FilmEntity): FilmModel =
11 | FilmModel(domain.title, domain.opening_crawl)
12 |
13 | override fun mapToDomain(model: FilmModel): FilmEntity =
14 | FilmEntity(model.title, model.openingCrawl)
15 | }
16 |
--------------------------------------------------------------------------------
/character-detail/src/main/java/com/ezike/tobenna/starwarssearch/characterdetail/mapper/PlanetModelMapper.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.characterdetail.mapper
2 |
3 | import com.ezike.tobenna.starwarssearch.characterdetail.data.PlanetEntity
4 | import com.ezike.tobenna.starwarssearch.characterdetail.model.PlanetModel
5 | import com.ezike.tobenna.starwarssearch.presentation.mapper.ModelMapper
6 | import javax.inject.Inject
7 |
8 | internal class PlanetModelMapper @Inject constructor() : ModelMapper {
9 |
10 | override fun mapToModel(domain: PlanetEntity): PlanetModel =
11 | PlanetModel(domain.name, domain.population)
12 |
13 | override fun mapToDomain(model: PlanetModel): PlanetEntity =
14 | PlanetEntity(model.name, model.population)
15 | }
16 |
--------------------------------------------------------------------------------
/character-detail/src/main/java/com/ezike/tobenna/starwarssearch/characterdetail/mapper/SpecieModelMapper.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.characterdetail.mapper
2 |
3 | import com.ezike.tobenna.starwarssearch.characterdetail.data.SpecieEntity
4 | import com.ezike.tobenna.starwarssearch.characterdetail.model.SpecieModel
5 | import com.ezike.tobenna.starwarssearch.presentation.mapper.ModelMapper
6 | import javax.inject.Inject
7 |
8 | internal class SpecieModelMapper @Inject constructor() : ModelMapper {
9 |
10 | override fun mapToModel(domain: SpecieEntity): SpecieModel =
11 | SpecieModel(domain.name, domain.language, domain.homeworld ?: "")
12 |
13 | override fun mapToDomain(model: SpecieModel): SpecieEntity =
14 | SpecieEntity(model.name, model.language, model.homeWorld)
15 | }
16 |
--------------------------------------------------------------------------------
/character-detail/src/main/java/com/ezike/tobenna/starwarssearch/characterdetail/model/CharacterDetailModel.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.characterdetail.model
2 |
3 | import android.os.Parcelable
4 | import kotlinx.parcelize.Parcelize
5 |
6 | @Parcelize
7 | data class CharacterDetailModel(
8 | val name: String,
9 | val birthYear: String,
10 | val heightCm: String,
11 | val url: String
12 | ) : Parcelable
13 |
--------------------------------------------------------------------------------
/character-detail/src/main/java/com/ezike/tobenna/starwarssearch/characterdetail/model/FilmModel.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.characterdetail.model
2 |
3 | data class FilmModel(
4 | val title: String,
5 | val openingCrawl: String
6 | )
7 |
--------------------------------------------------------------------------------
/character-detail/src/main/java/com/ezike/tobenna/starwarssearch/characterdetail/model/PlanetModel.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.characterdetail.model
2 |
3 | data class PlanetModel(
4 | val name: String,
5 | val population: String
6 | )
7 |
--------------------------------------------------------------------------------
/character-detail/src/main/java/com/ezike/tobenna/starwarssearch/characterdetail/model/SpecieModel.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.characterdetail.model
2 |
3 | data class SpecieModel(
4 | val name: String,
5 | val language: String,
6 | val homeWorld: String
7 | )
8 |
--------------------------------------------------------------------------------
/character-detail/src/main/java/com/ezike/tobenna/starwarssearch/characterdetail/presentation/Alias.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.characterdetail.presentation
2 |
3 | import com.ezike.tobenna.starwarssearch.characterdetail.presentation.viewstate.CharacterDetailViewState
4 | import com.ezike.tobenna.starwarssearch.presentation.base.IntentProcessor
5 | import com.ezike.tobenna.starwarssearch.presentation.base.StateReducer
6 | import com.ezike.tobenna.starwarssearch.presentation.stateMachine.StateMachine
7 | import com.ezike.tobenna.starwarssearch.presentation_android.ComponentManager
8 |
9 | internal typealias CharacterDetailIntentProcessor =
10 | @JvmSuppressWildcards IntentProcessor
11 |
12 | internal typealias CharacterDetailStateReducer =
13 | @JvmSuppressWildcards StateReducer
14 |
15 | internal typealias CharacterDetailStateMachine =
16 | @JvmSuppressWildcards StateMachine
17 |
18 | internal typealias DetailComponentManager =
19 | @JvmSuppressWildcards ComponentManager
20 |
--------------------------------------------------------------------------------
/character-detail/src/main/java/com/ezike/tobenna/starwarssearch/characterdetail/presentation/CharacterDetailViewResult.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.characterdetail.presentation
2 |
3 | import com.ezike.tobenna.starwarssearch.characterdetail.data.FilmEntity
4 | import com.ezike.tobenna.starwarssearch.characterdetail.data.PlanetEntity
5 | import com.ezike.tobenna.starwarssearch.characterdetail.data.SpecieEntity
6 | import com.ezike.tobenna.starwarssearch.characterdetail.model.CharacterDetailModel
7 | import com.ezike.tobenna.starwarssearch.presentation.base.ViewResult
8 |
9 | internal sealed class CharacterDetailViewResult : ViewResult {
10 | data class CharacterDetail(val character: CharacterDetailModel) : CharacterDetailViewResult()
11 | data class FetchCharacterDetailError(
12 | val characterName: String,
13 | val error: Throwable
14 | ) : CharacterDetailViewResult()
15 |
16 | object Retrying : CharacterDetailViewResult()
17 | }
18 |
19 | internal sealed class PlanetDetailViewResult : CharacterDetailViewResult() {
20 | data class Success(val planet: PlanetEntity) : PlanetDetailViewResult()
21 | data class Error(val error: Throwable) : PlanetDetailViewResult()
22 | object Loading : PlanetDetailViewResult()
23 | }
24 |
25 | internal sealed class SpecieDetailViewResult : CharacterDetailViewResult() {
26 | data class Success(val specie: List) : SpecieDetailViewResult()
27 | data class Error(val error: Throwable) : SpecieDetailViewResult()
28 | object Loading : SpecieDetailViewResult()
29 | }
30 |
31 | internal sealed class FilmDetailViewResult : CharacterDetailViewResult() {
32 | data class Success(val film: List) : FilmDetailViewResult()
33 | data class Error(val error: Throwable) : FilmDetailViewResult()
34 | object Loading : FilmDetailViewResult()
35 | }
36 |
--------------------------------------------------------------------------------
/character-detail/src/main/java/com/ezike/tobenna/starwarssearch/characterdetail/presentation/CharacterDetailViewStateMachine.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.characterdetail.presentation
2 |
3 | import com.ezike.tobenna.starwarssearch.characterdetail.model.CharacterDetailModel
4 | import com.ezike.tobenna.starwarssearch.characterdetail.presentation.viewstate.CharacterDetailViewStateFactory
5 | import com.ezike.tobenna.starwarssearch.characterdetail.ui.LoadCharacterDetailIntent
6 | import dagger.assisted.Assisted
7 | import dagger.assisted.AssistedFactory
8 | import dagger.assisted.AssistedInject
9 |
10 | internal class CharacterDetailViewStateMachine @AssistedInject constructor(
11 | intentProcessor: CharacterDetailIntentProcessor,
12 | reducer: CharacterDetailStateReducer,
13 | @Assisted character: CharacterDetailModel
14 | ) : CharacterDetailStateMachine(
15 | intentProcessor,
16 | reducer,
17 | CharacterDetailViewStateFactory.initialState,
18 | LoadCharacterDetailIntent(character)
19 | ) {
20 |
21 | @AssistedFactory
22 | interface Factory {
23 | fun create(character: CharacterDetailModel): CharacterDetailViewStateMachine
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/character-detail/src/main/java/com/ezike/tobenna/starwarssearch/characterdetail/presentation/viewstate/CharacterDetailViewState.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.characterdetail.presentation.viewstate
2 |
3 | import com.ezike.tobenna.starwarssearch.characterdetail.ui.views.error.DetailErrorViewState
4 | import com.ezike.tobenna.starwarssearch.characterdetail.ui.views.error.DetailErrorViewStateFactory
5 | import com.ezike.tobenna.starwarssearch.characterdetail.ui.views.film.FilmViewState
6 | import com.ezike.tobenna.starwarssearch.characterdetail.ui.views.film.FilmViewStateFactory
7 | import com.ezike.tobenna.starwarssearch.characterdetail.ui.views.planet.PlanetViewState
8 | import com.ezike.tobenna.starwarssearch.characterdetail.ui.views.planet.PlanetViewStateFactory
9 | import com.ezike.tobenna.starwarssearch.characterdetail.ui.views.profile.ProfileViewState
10 | import com.ezike.tobenna.starwarssearch.characterdetail.ui.views.profile.ProfileViewStateFactory
11 | import com.ezike.tobenna.starwarssearch.characterdetail.ui.views.specie.SpecieViewState
12 | import com.ezike.tobenna.starwarssearch.characterdetail.ui.views.specie.SpecieViewStateFactory
13 | import com.ezike.tobenna.starwarssearch.presentation.base.ScreenState
14 |
15 | data class CharacterDetailViewState(
16 | val profileViewState: ProfileViewState = ProfileViewStateFactory.initialState,
17 | val planetViewState: PlanetViewState = PlanetViewStateFactory.initialState,
18 | val specieViewState: SpecieViewState = SpecieViewStateFactory.initialState,
19 | val filmViewState: FilmViewState = FilmViewStateFactory.initialState,
20 | val errorViewState: DetailErrorViewState = DetailErrorViewStateFactory.initialState
21 | ) : ScreenState
22 |
--------------------------------------------------------------------------------
/character-detail/src/main/java/com/ezike/tobenna/starwarssearch/characterdetail/ui/CharacterDetailViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.characterdetail.ui
2 |
3 | import com.ezike.tobenna.starwarssearch.characterdetail.model.CharacterDetailModel
4 | import com.ezike.tobenna.starwarssearch.characterdetail.presentation.CharacterDetailViewStateMachine
5 | import com.ezike.tobenna.starwarssearch.characterdetail.presentation.DetailComponentManager
6 | import com.ezike.tobenna.starwarssearch.presentation_android.AssistedCreator
7 | import dagger.assisted.Assisted
8 | import dagger.assisted.AssistedFactory
9 | import dagger.assisted.AssistedInject
10 |
11 | internal class CharacterDetailViewModel @AssistedInject constructor(
12 | stateMachine: CharacterDetailViewStateMachine.Factory,
13 | @Assisted character: CharacterDetailModel
14 | ) : DetailComponentManager(stateMachine.create(character)) {
15 |
16 | @AssistedFactory
17 | interface Creator : AssistedCreator
18 | }
19 |
--------------------------------------------------------------------------------
/character-detail/src/main/java/com/ezike/tobenna/starwarssearch/characterdetail/ui/LoadCharacterDetailIntent.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.characterdetail.ui
2 |
3 | import com.ezike.tobenna.starwarssearch.characterdetail.model.CharacterDetailModel
4 | import com.ezike.tobenna.starwarssearch.presentation.base.ViewIntent
5 |
6 | data class LoadCharacterDetailIntent(val character: CharacterDetailModel) : ViewIntent
7 |
--------------------------------------------------------------------------------
/character-detail/src/main/java/com/ezike/tobenna/starwarssearch/characterdetail/ui/adapter/FilmAdapter.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.characterdetail.ui.adapter
2 |
3 | import android.view.ViewGroup
4 | import androidx.recyclerview.widget.DiffUtil
5 | import androidx.recyclerview.widget.ListAdapter
6 | import androidx.recyclerview.widget.RecyclerView
7 | import com.ezike.tobenna.starwarssearch.character.detail.R
8 | import com.ezike.tobenna.starwarssearch.character.detail.databinding.ItemFilmBinding
9 | import com.ezike.tobenna.starwarssearch.characterdetail.model.FilmModel
10 | import com.ezike.tobenna.starwarssearch.core.ext.inflate
11 |
12 | class FilmAdapter : ListAdapter(diffUtilCallback) {
13 |
14 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FilmViewHolder {
15 | return FilmViewHolder(ItemFilmBinding.bind(parent.inflate(R.layout.item_film)))
16 | }
17 |
18 | override fun onBindViewHolder(holder: FilmViewHolder, position: Int) {
19 | holder.bind(getItem(position))
20 | }
21 |
22 | class FilmViewHolder(private val binding: ItemFilmBinding) :
23 | RecyclerView.ViewHolder(binding.root) {
24 |
25 | fun bind(filmModel: FilmModel) {
26 | binding.filmTitle.text = filmModel.title
27 | binding.filmOpeningCrawl.text = filmModel.openingCrawl
28 | }
29 | }
30 |
31 | companion object {
32 | val diffUtilCallback: DiffUtil.ItemCallback
33 | get() = object : DiffUtil.ItemCallback() {
34 | override fun areItemsTheSame(oldItem: FilmModel, newItem: FilmModel): Boolean {
35 | return oldItem.title == newItem.title
36 | }
37 |
38 | override fun areContentsTheSame(oldItem: FilmModel, newItem: FilmModel): Boolean {
39 | return oldItem == newItem
40 | }
41 | }
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/character-detail/src/main/java/com/ezike/tobenna/starwarssearch/characterdetail/ui/adapter/SpecieAdapter.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.characterdetail.ui.adapter
2 |
3 | import android.content.Context
4 | import android.view.ViewGroup
5 | import androidx.recyclerview.widget.DiffUtil
6 | import androidx.recyclerview.widget.ListAdapter
7 | import androidx.recyclerview.widget.RecyclerView
8 | import com.ezike.tobenna.starwarssearch.character.detail.R
9 | import com.ezike.tobenna.starwarssearch.character.detail.databinding.ItemSpecieBinding
10 | import com.ezike.tobenna.starwarssearch.characterdetail.model.SpecieModel
11 | import com.ezike.tobenna.starwarssearch.core.ext.inflate
12 |
13 | class SpecieAdapter : ListAdapter(diffUtilCallback) {
14 |
15 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SpecieViewHolder {
16 | return SpecieViewHolder(ItemSpecieBinding.bind(parent.inflate(R.layout.item_specie)))
17 | }
18 |
19 | override fun onBindViewHolder(holder: SpecieViewHolder, position: Int) {
20 | holder.bind(getItem(position))
21 | }
22 |
23 | class SpecieViewHolder(private val binding: ItemSpecieBinding) :
24 | RecyclerView.ViewHolder(binding.root) {
25 |
26 | fun bind(specieModel: SpecieModel) {
27 | val context: Context = binding.root.context
28 | with(binding) {
29 | specieName.text = context.getString(R.string.specie_name, specieModel.name)
30 | specieLanguage.text =
31 | context.getString(R.string.specie_language, specieModel.language)
32 | specieHomeWorld.text = getHomeWorld(specieModel, context)
33 | }
34 | }
35 |
36 | private fun getHomeWorld(specieModel: SpecieModel, context: Context): String {
37 | return if (specieModel.homeWorld.isNotEmpty()) {
38 | context.getString(R.string.specie_home, specieModel.homeWorld)
39 | } else {
40 | context.getString(R.string.specie_home_unavailable)
41 | }
42 | }
43 | }
44 |
45 | companion object {
46 | val diffUtilCallback: DiffUtil.ItemCallback
47 | get() = object : DiffUtil.ItemCallback() {
48 | override fun areItemsTheSame(oldItem: SpecieModel, newItem: SpecieModel): Boolean {
49 | return oldItem.name == newItem.name
50 | }
51 |
52 | override fun areContentsTheSame(
53 | oldItem: SpecieModel,
54 | newItem: SpecieModel
55 | ): Boolean {
56 | return oldItem == newItem
57 | }
58 | }
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/character-detail/src/main/java/com/ezike/tobenna/starwarssearch/characterdetail/ui/views/error/DetailErrorView.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.characterdetail.ui.views.error
2 |
3 | import androidx.core.view.isVisible
4 | import com.ezike.tobenna.starwarssearch.character.detail.R
5 | import com.ezike.tobenna.starwarssearch.characterdetail.model.CharacterDetailModel
6 | import com.ezike.tobenna.starwarssearch.core.EmptyStateView
7 | import com.ezike.tobenna.starwarssearch.presentation_android.UIComponent
8 |
9 | class DetailErrorView(
10 | private val view: EmptyStateView,
11 | character: CharacterDetailModel
12 | ) : UIComponent() {
13 |
14 | init {
15 | view.onRetry {
16 | sendIntent(RetryFetchCharacterDetailsIntent(character))
17 | }
18 | }
19 |
20 | override fun render(state: DetailErrorViewState) {
21 | view.run {
22 | isVisible = state.showError
23 | setCaption(state.errorMessage)
24 | setTitle(
25 | context.getString(
26 | R.string.error_fetching_details,
27 | state.characterName
28 | )
29 | )
30 | }
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/character-detail/src/main/java/com/ezike/tobenna/starwarssearch/characterdetail/ui/views/error/DetailErrorViewState.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.characterdetail.ui.views.error
2 |
3 | import com.ezike.tobenna.starwarssearch.presentation.base.ViewState
4 |
5 | data class DetailErrorViewState(
6 | val characterName: String = "",
7 | val errorMessage: String = "",
8 | val showError: Boolean = false
9 | ) : ViewState
10 |
--------------------------------------------------------------------------------
/character-detail/src/main/java/com/ezike/tobenna/starwarssearch/characterdetail/ui/views/error/DetailErrorViewStateFactory.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.characterdetail.ui.views.error
2 |
3 | inline fun DetailErrorViewState.state(
4 | transform: DetailErrorViewStateFactory.() -> DetailErrorViewState
5 | ): DetailErrorViewState = transform(
6 | DetailErrorViewStateFactory(this)
7 | )
8 |
9 | object DetailErrorViewStateFactory {
10 |
11 | private lateinit var state: DetailErrorViewState
12 |
13 | val initialState: DetailErrorViewState
14 | get() = DetailErrorViewState()
15 |
16 | val Hide: DetailErrorViewState
17 | get() = DetailErrorViewState()
18 |
19 | operator fun invoke(viewState: DetailErrorViewState): DetailErrorViewStateFactory {
20 | state = viewState
21 | return this
22 | }
23 |
24 | fun DisplayError(characterName: String, errorMessage: String): DetailErrorViewState =
25 | state.copy(
26 | characterName = characterName,
27 | errorMessage = errorMessage,
28 | showError = true
29 | )
30 | }
31 |
--------------------------------------------------------------------------------
/character-detail/src/main/java/com/ezike/tobenna/starwarssearch/characterdetail/ui/views/error/RetryFetchCharacterDetailsIntent.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.characterdetail.ui.views.error
2 |
3 | import com.ezike.tobenna.starwarssearch.characterdetail.model.CharacterDetailModel
4 | import com.ezike.tobenna.starwarssearch.presentation.base.ViewIntent
5 |
6 | data class RetryFetchCharacterDetailsIntent(
7 | val character: CharacterDetailModel
8 | ) : ViewIntent
9 |
--------------------------------------------------------------------------------
/character-detail/src/main/java/com/ezike/tobenna/starwarssearch/characterdetail/ui/views/film/FilmView.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.characterdetail.ui.views.film
2 |
3 | import androidx.core.view.isVisible
4 | import com.ezike.tobenna.starwarssearch.character.detail.databinding.FilmViewLayoutBinding
5 | import com.ezike.tobenna.starwarssearch.characterdetail.ui.adapter.FilmAdapter
6 | import com.ezike.tobenna.starwarssearch.core.ext.init
7 | import com.ezike.tobenna.starwarssearch.presentation_android.UIComponent
8 |
9 | class FilmView(
10 | private val view: FilmViewLayoutBinding,
11 | characterUrl: String
12 | ) : UIComponent() {
13 |
14 | private val filmAdapter: FilmAdapter by init { FilmAdapter() }
15 |
16 | init {
17 | view.filmList.adapter = filmAdapter
18 | view.filmErrorState.onRetry {
19 | sendIntent(RetryFetchFilmIntent(characterUrl))
20 | }
21 | }
22 |
23 | override fun render(state: FilmViewState) {
24 | filmAdapter.submitList(state.films)
25 | view.run {
26 | filmTitle.isVisible = state.showTitle
27 | emptyView.isVisible = state.showEmpty
28 | filmLoadingView.root.isVisible = state.isLoading
29 | filmErrorState.isVisible = state.showError
30 | filmErrorState.setCaption(state.errorMessage)
31 | }
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/character-detail/src/main/java/com/ezike/tobenna/starwarssearch/characterdetail/ui/views/film/FilmViewState.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.characterdetail.ui.views.film
2 |
3 | import com.ezike.tobenna.starwarssearch.characterdetail.model.FilmModel
4 | import com.ezike.tobenna.starwarssearch.presentation.base.ViewState
5 |
6 | data class FilmViewState(
7 | val films: List = emptyList(),
8 | val errorMessage: String? = null,
9 | val isLoading: Boolean = false,
10 | val showError: Boolean = false,
11 | val showFilms: Boolean = false,
12 | val showTitle: Boolean = false,
13 | val showEmpty: Boolean = false
14 | ) : ViewState
15 |
--------------------------------------------------------------------------------
/character-detail/src/main/java/com/ezike/tobenna/starwarssearch/characterdetail/ui/views/film/FilmViewStateFactory.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.characterdetail.ui.views.film
2 |
3 | import com.ezike.tobenna.starwarssearch.characterdetail.model.FilmModel
4 |
5 | inline fun FilmViewState.state(
6 | transform: FilmViewStateFactory.() -> FilmViewState
7 | ): FilmViewState = transform(FilmViewStateFactory(this))
8 |
9 | object FilmViewStateFactory {
10 |
11 | private lateinit var state: FilmViewState
12 |
13 | operator fun invoke(viewState: FilmViewState): FilmViewStateFactory {
14 | state = viewState
15 | return this
16 | }
17 |
18 | val initialState: FilmViewState
19 | get() = FilmViewState()
20 |
21 | val Loading: FilmViewState
22 | get() = state.copy(
23 | films = emptyList(),
24 | errorMessage = null,
25 | isLoading = true,
26 | showError = false,
27 | showFilms = false,
28 | showEmpty = false,
29 | showTitle = true
30 | )
31 |
32 | val Hide: FilmViewState
33 | get() = FilmViewState()
34 |
35 | fun Error(message: String): FilmViewState =
36 | state.copy(
37 | films = emptyList(),
38 | errorMessage = message,
39 | isLoading = false,
40 | showError = true,
41 | showFilms = false,
42 | showEmpty = false,
43 | showTitle = true
44 | )
45 |
46 | fun Success(films: List): FilmViewState =
47 | state.copy(
48 | films = films,
49 | errorMessage = null,
50 | isLoading = false,
51 | showError = false,
52 | showTitle = true,
53 | showFilms = films.isNotEmpty(),
54 | showEmpty = films.isEmpty()
55 | )
56 | }
57 |
--------------------------------------------------------------------------------
/character-detail/src/main/java/com/ezike/tobenna/starwarssearch/characterdetail/ui/views/film/RetryFetchFilmIntent.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.characterdetail.ui.views.film
2 |
3 | import com.ezike.tobenna.starwarssearch.presentation.base.ViewIntent
4 |
5 | data class RetryFetchFilmIntent(
6 | val url: String
7 | ) : ViewIntent
8 |
--------------------------------------------------------------------------------
/character-detail/src/main/java/com/ezike/tobenna/starwarssearch/characterdetail/ui/views/planet/PlanetView.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.characterdetail.ui.views.planet
2 |
3 | import androidx.core.view.isVisible
4 | import com.ezike.tobenna.starwarssearch.character.detail.databinding.PlanetViewLayoutBinding
5 | import com.ezike.tobenna.starwarssearch.core.string
6 | import com.ezike.tobenna.starwarssearch.presentation_android.UIComponent
7 |
8 | class PlanetView(
9 | private val view: PlanetViewLayoutBinding,
10 | characterUrl: String
11 | ) : UIComponent() {
12 |
13 | init {
14 | view.planetError.onRetry {
15 | sendIntent(RetryFetchPlanetIntent(characterUrl))
16 | }
17 | }
18 |
19 | override fun render(state: PlanetViewState) {
20 | view.run {
21 | planetView.isVisible = state.showPlanet
22 | planetTitle.isVisible = state.showTitle
23 | planetName.string = state.data.name
24 | planetPopulation.string = state.data.population
25 | planetLoadingView.root.isVisible = state.isLoading
26 | planetError.isVisible = state.showError
27 | planetError.setCaption(state.errorMessage)
28 | }
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/character-detail/src/main/java/com/ezike/tobenna/starwarssearch/characterdetail/ui/views/planet/PlanetViewState.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.characterdetail.ui.views.planet
2 |
3 | import com.ezike.tobenna.starwarssearch.core.AppString
4 | import com.ezike.tobenna.starwarssearch.presentation.base.ViewState
5 |
6 | data class PlanetViewState(
7 | val data: PlanetViewData,
8 | val errorMessage: String? = null,
9 | val isLoading: Boolean = false,
10 | val showError: Boolean = false,
11 | val showPlanet: Boolean = false,
12 | val showTitle: Boolean = false
13 | ) : ViewState
14 |
15 | data class PlanetViewData(
16 | val name: AppString,
17 | val population: AppString
18 | )
19 |
--------------------------------------------------------------------------------
/character-detail/src/main/java/com/ezike/tobenna/starwarssearch/characterdetail/ui/views/planet/PlanetViewStateFactory.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.characterdetail.ui.views.planet
2 |
3 | import com.ezike.tobenna.starwarssearch.character.detail.R
4 | import com.ezike.tobenna.starwarssearch.characterdetail.model.PlanetModel
5 | import com.ezike.tobenna.starwarssearch.core.AppString
6 | import com.ezike.tobenna.starwarssearch.core.ParamString
7 | import com.ezike.tobenna.starwarssearch.core.StringResource
8 |
9 | inline fun PlanetViewState.state(
10 | transform: PlanetViewStateFactory.() -> PlanetViewState
11 | ): PlanetViewState = transform(PlanetViewStateFactory(this))
12 |
13 | object PlanetViewStateFactory {
14 |
15 | private lateinit var state: PlanetViewState
16 |
17 | operator fun invoke(
18 | viewState: PlanetViewState
19 | ): PlanetViewStateFactory {
20 | state = viewState
21 | return this
22 | }
23 |
24 | private val emptyPlanetData: PlanetViewData
25 | get() = PlanetViewData(
26 | name = StringResource(
27 | res = R.string.empty
28 | ),
29 | population = StringResource(
30 | res = R.string.empty
31 | )
32 | )
33 |
34 | val initialState: PlanetViewState
35 | get() = PlanetViewState(
36 | data = emptyPlanetData
37 | )
38 |
39 | val Loading: PlanetViewState
40 | get() = state.copy(
41 | data = emptyPlanetData,
42 | errorMessage = null,
43 | isLoading = true,
44 | showError = false,
45 | showPlanet = false,
46 | showTitle = true
47 | )
48 |
49 | val Hide: PlanetViewState
50 | get() = PlanetViewState(
51 | data = emptyPlanetData
52 | )
53 |
54 | fun Error(message: String): PlanetViewState =
55 | state.copy(
56 | data = emptyPlanetData,
57 | errorMessage = message,
58 | isLoading = false,
59 | showError = true,
60 | showPlanet = false,
61 | showTitle = true
62 | )
63 |
64 | fun Success(planet: PlanetModel): PlanetViewState =
65 | state.copy(
66 | data = createPlanetData(planet),
67 | errorMessage = null,
68 | isLoading = false,
69 | showError = false,
70 | showPlanet = true,
71 | showTitle = true
72 | )
73 |
74 | private fun createPlanetData(
75 | planet: PlanetModel
76 | ) = PlanetViewData(
77 | name = ParamString(R.string.planet_name, planet.name),
78 | population = getPopulation(planet.population)
79 | )
80 |
81 | private fun getPopulation(
82 | population: String
83 | ): AppString = try {
84 | ParamString(R.string.population, population.toLong())
85 | } catch (e: Exception) {
86 | StringResource(R.string.population_not_available)
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/character-detail/src/main/java/com/ezike/tobenna/starwarssearch/characterdetail/ui/views/planet/RetryFetchPlanetIntent.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.characterdetail.ui.views.planet
2 |
3 | import com.ezike.tobenna.starwarssearch.presentation.base.ViewIntent
4 |
5 | data class RetryFetchPlanetIntent(
6 | val url: String
7 | ) : ViewIntent
8 |
--------------------------------------------------------------------------------
/character-detail/src/main/java/com/ezike/tobenna/starwarssearch/characterdetail/ui/views/profile/ProfileView.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.characterdetail.ui.views.profile
2 |
3 | import com.ezike.tobenna.starwarssearch.character.detail.databinding.ProfileViewLayoutBinding
4 | import com.ezike.tobenna.starwarssearch.core.string
5 | import com.ezike.tobenna.starwarssearch.presentation_android.UIComponent
6 |
7 | class ProfileView(
8 | private val view: ProfileViewLayoutBinding,
9 | navigateUp: () -> Unit
10 | ) : UIComponent() {
11 |
12 | init {
13 | view.backBtn.setOnClickListener { navigateUp() }
14 | }
15 |
16 | override fun render(state: ProfileViewState) {
17 | view.run {
18 | profileTitle.string = state.title
19 | characterName.string = state.name
20 | characterBirthYear.string = state.birthYear
21 | characterHeight.string = state.height
22 | }
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/character-detail/src/main/java/com/ezike/tobenna/starwarssearch/characterdetail/ui/views/profile/ProfileViewState.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.characterdetail.ui.views.profile
2 |
3 | import com.ezike.tobenna.starwarssearch.core.AppString
4 | import com.ezike.tobenna.starwarssearch.presentation.base.ViewState
5 |
6 | data class ProfileViewState(
7 | val title: AppString,
8 | val name: AppString,
9 | val birthYear: AppString,
10 | val height: AppString
11 | ) : ViewState
12 |
--------------------------------------------------------------------------------
/character-detail/src/main/java/com/ezike/tobenna/starwarssearch/characterdetail/ui/views/profile/ProfileViewStateFactory.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.characterdetail.ui.views.profile
2 |
3 | import com.ezike.tobenna.starwarssearch.character.detail.R
4 | import com.ezike.tobenna.starwarssearch.characterdetail.model.CharacterDetailModel
5 | import com.ezike.tobenna.starwarssearch.core.AppString
6 | import com.ezike.tobenna.starwarssearch.core.ParamString
7 | import com.ezike.tobenna.starwarssearch.core.StringResource
8 | import java.math.BigDecimal
9 | import java.math.RoundingMode
10 |
11 | object ProfileViewStateFactory {
12 |
13 | val initialState = ProfileViewState(
14 | title = StringResource(res = R.string.empty),
15 | name = StringResource(res = R.string.empty),
16 | birthYear = StringResource(res = R.string.empty),
17 | height = StringResource(res = R.string.empty)
18 | )
19 |
20 | fun create(
21 | model: CharacterDetailModel
22 | ): ProfileViewState {
23 | val title = ParamString(res = R.string.profile_title, model.name)
24 | val name = ParamString(res = R.string.character_name, model.name)
25 | val birthYear = ParamString(res = R.string.character_birth_year, model.birthYear)
26 | val characterHeight = getCharacterHeight(heightCm = model.heightCm)
27 |
28 | return ProfileViewState(
29 | title = title,
30 | name = name,
31 | birthYear = birthYear,
32 | height = characterHeight
33 | )
34 | }
35 |
36 | private fun getCharacterHeight(
37 | heightCm: String
38 | ): AppString {
39 | val heightInches = getHeightInches(heightCm = heightCm)
40 | return if (heightInches != null) {
41 | ParamString(
42 | res = R.string.height,
43 | heightCm,
44 | heightInches
45 | )
46 | } else {
47 | StringResource(res = R.string.height_unavailable)
48 | }
49 | }
50 |
51 | private fun getHeightInches(
52 | heightCm: String
53 | ): String? = try {
54 | BigDecimal(heightCm.toDouble() * 0.393701)
55 | .setScale(1, RoundingMode.HALF_EVEN)
56 | .toString()
57 | } catch (e: Exception) {
58 | e.printStackTrace()
59 | null
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/character-detail/src/main/java/com/ezike/tobenna/starwarssearch/characterdetail/ui/views/specie/RetryFetchSpecieIntent.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.characterdetail.ui.views.specie
2 |
3 | import com.ezike.tobenna.starwarssearch.presentation.base.ViewIntent
4 |
5 | data class RetryFetchSpecieIntent(
6 | val url: String
7 | ) : ViewIntent
8 |
--------------------------------------------------------------------------------
/character-detail/src/main/java/com/ezike/tobenna/starwarssearch/characterdetail/ui/views/specie/SpecieView.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.characterdetail.ui.views.specie
2 |
3 | import androidx.core.view.isVisible
4 | import com.ezike.tobenna.starwarssearch.character.detail.databinding.SpecieViewLayoutBinding
5 | import com.ezike.tobenna.starwarssearch.characterdetail.ui.adapter.SpecieAdapter
6 | import com.ezike.tobenna.starwarssearch.core.ext.init
7 | import com.ezike.tobenna.starwarssearch.presentation_android.UIComponent
8 |
9 | class SpecieView(
10 | private val view: SpecieViewLayoutBinding,
11 | characterUrl: String
12 | ) : UIComponent() {
13 |
14 | private val specieAdapter: SpecieAdapter by init { SpecieAdapter() }
15 |
16 | init {
17 | view.specieList.adapter = specieAdapter
18 | view.specieErrorState.onRetry {
19 | sendIntent(RetryFetchSpecieIntent(characterUrl))
20 | }
21 | }
22 |
23 | override fun render(state: SpecieViewState) {
24 | specieAdapter.submitList(state.species)
25 | view.run {
26 | specieTitle.isVisible = state.showTitle
27 | specieList.isVisible = state.showSpecies
28 | emptyView.isVisible = state.showEmpty
29 | specieLoadingView.root.isVisible = state.isLoading
30 | specieErrorState.isVisible = state.showError
31 | specieErrorState.setCaption(state.errorMessage)
32 | }
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/character-detail/src/main/java/com/ezike/tobenna/starwarssearch/characterdetail/ui/views/specie/SpecieViewState.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.characterdetail.ui.views.specie
2 |
3 | import com.ezike.tobenna.starwarssearch.characterdetail.model.SpecieModel
4 | import com.ezike.tobenna.starwarssearch.presentation.base.ViewState
5 |
6 | data class SpecieViewState(
7 | val species: List = emptyList(),
8 | val errorMessage: String? = null,
9 | val isLoading: Boolean = false,
10 | val showEmpty: Boolean = false,
11 | val showError: Boolean = false,
12 | val showTitle: Boolean = false,
13 | val showSpecies: Boolean = false
14 | ) : ViewState
15 |
--------------------------------------------------------------------------------
/character-detail/src/main/java/com/ezike/tobenna/starwarssearch/characterdetail/ui/views/specie/SpecieViewStateFactory.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.characterdetail.ui.views.specie
2 |
3 | import com.ezike.tobenna.starwarssearch.characterdetail.model.SpecieModel
4 |
5 | inline fun SpecieViewState.state(
6 | transform: SpecieViewStateFactory.() -> SpecieViewState
7 | ): SpecieViewState = transform(SpecieViewStateFactory(this))
8 |
9 | object SpecieViewStateFactory {
10 |
11 | private lateinit var state: SpecieViewState
12 |
13 | operator fun invoke(viewState: SpecieViewState): SpecieViewStateFactory {
14 | state = viewState
15 | return this
16 | }
17 |
18 | val initialState: SpecieViewState
19 | get() = SpecieViewState()
20 |
21 | val Loading: SpecieViewState
22 | get() = state.copy(
23 | isLoading = true,
24 | showEmpty = false,
25 | showError = false,
26 | showSpecies = false,
27 | showTitle = true,
28 | errorMessage = null
29 | )
30 |
31 | val Hide: SpecieViewState
32 | get() = SpecieViewState()
33 |
34 | fun Error(message: String): SpecieViewState =
35 | state.copy(
36 | isLoading = false,
37 | showEmpty = false,
38 | showError = true,
39 | showSpecies = false,
40 | showTitle = true,
41 | errorMessage = message
42 | )
43 |
44 | fun DataLoaded(species: List): SpecieViewState =
45 | state.copy(
46 | species = species,
47 | isLoading = false,
48 | showTitle = true,
49 | showEmpty = species.isEmpty(),
50 | showSpecies = species.isNotEmpty(),
51 | showError = false,
52 | errorMessage = null
53 | )
54 | }
55 |
--------------------------------------------------------------------------------
/character-detail/src/main/res/drawable/arrow_back.xml:
--------------------------------------------------------------------------------
1 |
7 |
10 |
11 |
--------------------------------------------------------------------------------
/character-detail/src/main/res/layout/detail_loading_layout.xml:
--------------------------------------------------------------------------------
1 |
2 |
10 |
11 |
16 |
17 |
21 |
22 |
28 |
29 |
30 |
31 |
32 |
--------------------------------------------------------------------------------
/character-detail/src/main/res/layout/film_view_layout.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
18 |
19 |
29 |
30 |
36 |
37 |
45 |
46 |
63 |
64 |
65 |
--------------------------------------------------------------------------------
/character-detail/src/main/res/layout/fragment_character_detail.xml:
--------------------------------------------------------------------------------
1 |
2 |
10 |
11 |
16 |
17 |
24 |
25 |
32 |
33 |
40 |
41 |
47 |
48 |
56 |
57 |
58 |
59 |
60 |
--------------------------------------------------------------------------------
/character-detail/src/main/res/layout/item_film.xml:
--------------------------------------------------------------------------------
1 |
2 |
15 |
16 |
22 |
23 |
34 |
35 |
45 |
46 |
47 |
48 |
49 |
--------------------------------------------------------------------------------
/character-detail/src/main/res/layout/specie_view_layout.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
18 |
19 |
28 |
29 |
35 |
36 |
44 |
45 |
58 |
59 |
60 |
--------------------------------------------------------------------------------
/character-detail/src/main/res/navigation/detail_nav_graph.xml:
--------------------------------------------------------------------------------
1 |
2 |
6 |
10 |
11 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/character-detail/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #48636389
4 | #bdbdbd
5 | #FFBB86FC
6 | #FF6200EE
7 | #FF3700B3
8 | #FF03DAC5
9 | #FF018786
10 | #FF000000
11 | #FFFFFFFF
12 |
13 |
--------------------------------------------------------------------------------
/character-detail/src/main/res/values/dimens.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | 18dp
4 | 50dp
5 | 8dp
6 | 18sp
7 | 16dp
8 | 150dp
9 | 2dp
10 | 16dp
11 | 16dp
12 | 20dp
13 | 8dp
14 | 4dp
15 | 24dp
16 | 16dp
17 |
18 |
--------------------------------------------------------------------------------
/character-detail/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | An error occurred
3 | Planet
4 | Species
5 | Films
6 | Name: %s
7 | Birth year: %s
8 | Height: %1$s cm, %2$s inches
9 | %s\'s profile
10 | Error fetching %s\'s detail
11 | Name: %s
12 | Population: %,d
13 | Population: N/A
14 | Name: %s
15 | Language: %s
16 | Home world: %s
17 | Home world: N/A
18 | Species information is currently unavailable
19 | Film information is currently available
20 | Unable to fetch species information
21 | Unable to fetch planet information
22 | Unable to fetch film information
23 | Height: N/A
24 | back
25 |
26 |
27 |
--------------------------------------------------------------------------------
/character-detail/src/test/java/com/ezike/tobenna/starwarssearch/characterdetail/data/DummyData.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.characterdetail.data
2 |
3 | import com.ezike.tobenna.starwarssearch.characterdetail.model.CharacterDetailModel
4 | import com.ezike.tobenna.starwarssearch.characterdetail.model.FilmModel
5 | import com.ezike.tobenna.starwarssearch.characterdetail.model.PlanetModel
6 | import com.ezike.tobenna.starwarssearch.characterdetail.model.SpecieModel
7 | import com.ezike.tobenna.starwarssearch.libcharactersearch.domain.model.Character
8 | import com.ezike.tobenna.starwarssearch.libcharactersearch.domain.model.CharacterDetail
9 | import com.ezike.tobenna.starwarssearch.libcharactersearch.domain.model.Film
10 | import com.ezike.tobenna.starwarssearch.libcharactersearch.domain.model.Planet
11 | import com.ezike.tobenna.starwarssearch.libcharactersearch.domain.model.Specie
12 | import com.ezike.tobenna.starwarssearch.testutils.ERROR_MSG
13 | import java.net.SocketTimeoutException
14 |
15 | internal object DummyData {
16 | val characterModel = CharacterDetailModel(
17 | "Many men",
18 | "34.BBY",
19 | "143",
20 | "https://swapi.dev/people/21"
21 | )
22 |
23 | val character = Character(
24 | "Many men",
25 | "34.BBY",
26 | "143",
27 | "https://swapi.dev/people/21"
28 | )
29 |
30 | val characterDetail = CharacterDetail(
31 | listOf("www.url.com"),
32 | "http://swapi.dev/planet",
33 | listOf("https://swapi.dev.people"),
34 | "https://swapi.dev/people/12/"
35 | )
36 |
37 | val film = Film(
38 | "Some title",
39 | "An opening crawl"
40 | )
41 |
42 | val films: List = listOf(
43 | Film(
44 | "Some title",
45 | "An opening crawl"
46 | )
47 | )
48 |
49 | val planet = Planet(
50 | "tatooine",
51 | "1000000"
52 | )
53 |
54 | val specie = Specie(
55 | "Iroko",
56 | "Yoruba",
57 | "Enugu"
58 | )
59 |
60 | val species = listOf(
61 | Specie(
62 | "Iroko",
63 | "Yoruba",
64 | "Enugu"
65 | )
66 | )
67 | val filmModel = FilmModel(
68 | "Some title",
69 | "An opening crawl"
70 | )
71 |
72 | val planetModel = PlanetModel(
73 | "tatooine",
74 | "1000000"
75 | )
76 |
77 | val specieModel = SpecieModel(
78 | "Iroko",
79 | "Yoruba",
80 | "Enugu"
81 | )
82 |
83 | val exception: SocketTimeoutException
84 | get() = SocketTimeoutException(ERROR_MSG)
85 | }
86 |
--------------------------------------------------------------------------------
/character-detail/src/test/java/com/ezike/tobenna/starwarssearch/characterdetail/fakes/TestPostExecutionThread.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.characterdetail.fakes
2 |
3 | import com.ezike.tobenna.starwarssearch.libcharactersearch.domain.executor.PostExecutionThread
4 | import kotlinx.coroutines.CoroutineDispatcher
5 | import kotlinx.coroutines.test.TestCoroutineDispatcher
6 |
7 | class TestPostExecutionThread : PostExecutionThread {
8 |
9 | override val main: CoroutineDispatcher
10 | get() = TestCoroutineDispatcher()
11 |
12 | override val io: CoroutineDispatcher
13 | get() = TestCoroutineDispatcher()
14 |
15 | override val default: CoroutineDispatcher
16 | get() = TestCoroutineDispatcher()
17 | }
18 |
--------------------------------------------------------------------------------
/character-detail/src/test/java/com/ezike/tobenna/starwarssearch/characterdetail/mapper/CharacterDetailModelMapperTest.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.characterdetail.mapper
2 |
3 | import com.ezike.tobenna.starwarssearch.characterdetail.data.DummyData
4 | import com.ezike.tobenna.starwarssearch.characterdetail.model.CharacterDetailModel
5 | import com.ezike.tobenna.starwarssearch.libcharactersearch.domain.model.Character
6 | import com.google.common.truth.Truth.assertThat
7 | import org.junit.Test
8 |
9 | class CharacterDetailModelMapperTest {
10 |
11 | private val characterModelMapper = CharacterDetailModelMapper()
12 |
13 | @Test
14 | fun `check that mapToModel returns correct data`() {
15 | val character: Character = DummyData.character
16 | val model: CharacterDetailModel = characterModelMapper.mapToModel(character)
17 | assertThat(character.name).isEqualTo(model.name)
18 | assertThat(character.birthYear).isEqualTo(model.birthYear)
19 | assertThat(character.height).isEqualTo(model.heightCm)
20 | assertThat(character.url).isEqualTo(model.url)
21 | }
22 |
23 | @Test
24 | fun `check that mapToDomain returns correct data`() {
25 | val model: CharacterDetailModel = DummyData.characterModel
26 | val characterDomain: Character = characterModelMapper.mapToDomain(model)
27 | assertThat(model.name).isEqualTo(characterDomain.name)
28 | assertThat(model.birthYear).isEqualTo(characterDomain.birthYear)
29 | assertThat(model.heightCm).isEqualTo(characterDomain.height)
30 | assertThat(model.url).isEqualTo(characterDomain.url)
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/character-detail/src/test/java/com/ezike/tobenna/starwarssearch/characterdetail/mapper/FilmModelMapperTest.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.characterdetail.mapper
2 |
3 | import com.ezike.tobenna.starwarssearch.characterdetail.data.DummyData
4 | import com.ezike.tobenna.starwarssearch.characterdetail.model.FilmModel
5 | import com.ezike.tobenna.starwarssearch.libcharactersearch.domain.model.Film
6 | import com.google.common.truth.Truth.assertThat
7 | import org.junit.Test
8 |
9 | class FilmModelMapperTest {
10 |
11 | private val mapper = FilmModelMapper()
12 |
13 | @Test
14 | fun mapToModel() {
15 | val film: Film = DummyData.film
16 | val model: FilmModel = mapper.mapToModel(film)
17 | assertThat(film.openingCrawl).isEqualTo(model.openingCrawl)
18 | assertThat(film.title).isEqualTo(model.title)
19 | }
20 |
21 | @Test
22 | fun mapToModelList() {
23 | val films: List = DummyData.films
24 | val model: List = mapper.mapToModelList(films)
25 | assertThat(model).isNotEmpty()
26 | assertThat(films[0].openingCrawl).isEqualTo(model[0].openingCrawl)
27 | assertThat(films[0].title).isEqualTo(model[0].title)
28 | }
29 |
30 | @Test
31 | fun mapToDomainList() {
32 | val model: List = listOf(DummyData.filmModel)
33 | val films: List = mapper.mapToDomainList(model)
34 | assertThat(films).isNotEmpty()
35 | assertThat(model[0].openingCrawl).isEqualTo(films[0].openingCrawl)
36 | assertThat(model[0].title).isEqualTo(films[0].title)
37 | }
38 |
39 | @Test
40 | fun mapToDomain() {
41 | val model: FilmModel = DummyData.filmModel
42 | val film: Film = mapper.mapToDomain(model)
43 | assertThat(model.openingCrawl).isEqualTo(film.openingCrawl)
44 | assertThat(model.title).isEqualTo(film.title)
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/character-detail/src/test/java/com/ezike/tobenna/starwarssearch/characterdetail/mapper/PlanetModelMapperTest.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.characterdetail.mapper
2 |
3 | import com.ezike.tobenna.starwarssearch.characterdetail.data.DummyData
4 | import com.ezike.tobenna.starwarssearch.characterdetail.model.PlanetModel
5 | import com.ezike.tobenna.starwarssearch.libcharactersearch.domain.model.Planet
6 | import com.google.common.truth.Truth.assertThat
7 | import org.junit.Test
8 |
9 | class PlanetModelMapperTest {
10 |
11 | private val mapper = PlanetModelMapper()
12 |
13 | @Test
14 | fun mapToModel() {
15 | val planet: Planet = DummyData.planet
16 | val model: PlanetModel = mapper.mapToModel(planet)
17 | assertThat(planet.name).isEqualTo(model.name)
18 | assertThat(planet.population).isEqualTo(model.population)
19 | }
20 |
21 | @Test
22 | fun mapToDomain() {
23 | val model: PlanetModel = DummyData.planetModel
24 | val planet: Planet = mapper.mapToDomain(model)
25 | assertThat(model.name).isEqualTo(planet.name)
26 | assertThat(model.population).isEqualTo(planet.population)
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/character-detail/src/test/java/com/ezike/tobenna/starwarssearch/characterdetail/mapper/SpecieModelMapperTest.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.characterdetail.mapper
2 |
3 | import com.ezike.tobenna.starwarssearch.characterdetail.data.DummyData
4 | import com.ezike.tobenna.starwarssearch.characterdetail.model.SpecieModel
5 | import com.ezike.tobenna.starwarssearch.libcharactersearch.domain.model.Specie
6 | import com.google.common.truth.Truth.assertThat
7 | import org.junit.Test
8 |
9 | class SpecieModelMapperTest {
10 |
11 | private val mapper = SpecieModelMapper()
12 |
13 | @Test
14 | fun mapToModel() {
15 | val specie: Specie = DummyData.specie
16 | val model: SpecieModel = mapper.mapToModel(specie)
17 | assertThat(specie.name).isEqualTo(model.name)
18 | assertThat(specie.language).isEqualTo(model.language)
19 | assertThat(specie.homeWorld).isEqualTo(model.homeWorld)
20 | }
21 |
22 | @Test
23 | fun mapToModelList() {
24 | val species: List = DummyData.species
25 | val model: List = mapper.mapToModelList(species)
26 | assertThat(model).isNotEmpty()
27 | assertThat(species[0].name).isEqualTo(model[0].name)
28 | assertThat(species[0].language).isEqualTo(model[0].language)
29 | assertThat(species[0].homeWorld).isEqualTo(model[0].homeWorld)
30 | }
31 |
32 | @Test
33 | fun mapToDomain() {
34 | val specie: SpecieModel = DummyData.specieModel
35 | val domain: Specie = mapper.mapToDomain(specie)
36 | assertThat(specie.name).isEqualTo(domain.name)
37 | assertThat(specie.language).isEqualTo(domain.language)
38 | assertThat(specie.homeWorld).isEqualTo(domain.homeWorld)
39 | }
40 |
41 | @Test
42 | fun mapToDomainList() {
43 | val species: List = listOf(DummyData.specieModel)
44 | val domain: List = mapper.mapToDomainList(species)
45 | assertThat(domain).isNotEmpty()
46 | assertThat(species[0].name).isEqualTo(domain[0].name)
47 | assertThat(species[0].language).isEqualTo(domain[0].language)
48 | assertThat(species[0].homeWorld).isEqualTo(domain[0].homeWorld)
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/character_search/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/character_search/consumer-rules.pro:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ezike/StarWarsSearch-MVI/f091dc9b19b0a4b5b38d27c5331a4d70388f970d/character_search/consumer-rules.pro
--------------------------------------------------------------------------------
/character_search/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
--------------------------------------------------------------------------------
/character_search/src/androidTest/java/com/ezike/tobenna/starwarssearch/charactersearch/CustomTestRunner.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.charactersearch
2 |
3 | import android.app.Application
4 | import android.content.Context
5 | import androidx.test.runner.AndroidJUnitRunner
6 | import dagger.hilt.android.testing.HiltTestApplication
7 |
8 | class CustomTestRunner : AndroidJUnitRunner() {
9 |
10 | override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application {
11 | return super.newApplication(cl, HiltTestApplication::class.java.name, context)
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/character_search/src/androidTest/java/com/ezike/tobenna/starwarssearch/charactersearch/di/TestModule.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.charactersearch.di
2 |
3 | import com.ezike.tobenna.starwarssearch.charactersearch.TestPostExecutionThread
4 | import com.ezike.tobenna.starwarssearch.charactersearch.di.fakes.FakeCharacterDetailRepository
5 | import com.ezike.tobenna.starwarssearch.charactersearch.di.fakes.FakeSearchHistoryRepository
6 | import com.ezike.tobenna.starwarssearch.charactersearch.di.fakes.FakeSearchRepository
7 | import com.ezike.tobenna.starwarssearch.libcharactersearch.domain.executor.PostExecutionThread
8 | import com.ezike.tobenna.starwarssearch.libcharactersearch.domain.repository.CharacterDetailRepository
9 | import dagger.Module
10 | import dagger.Provides
11 | import dagger.hilt.InstallIn
12 | import dagger.hilt.components.SingletonComponent
13 |
14 | @Module
15 | @InstallIn(SingletonComponent::class)
16 | class TestModule {
17 |
18 | @Provides
19 | fun searchHistoryRepository(): SearchHistoryRepository = FakeSearchHistoryRepository()
20 |
21 | @Provides
22 | fun characterDetailRepository(): CharacterDetailRepository = FakeCharacterDetailRepository()
23 |
24 | @Provides
25 | fun searchRepository(): SearchRepository = FakeSearchRepository()
26 |
27 | @Provides
28 | fun executionThread(): PostExecutionThread = TestPostExecutionThread()
29 | }
30 |
--------------------------------------------------------------------------------
/character_search/src/androidTest/java/com/ezike/tobenna/starwarssearch/charactersearch/di/fakes/FakeSearchHistoryRepository.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.charactersearch.di.fakes
2 |
3 | import kotlinx.coroutines.flow.Flow
4 | import kotlinx.coroutines.flow.flowOf
5 | import javax.inject.Inject
6 |
7 | class FakeSearchHistoryRepository @Inject constructor() : SearchHistoryRepository {
8 |
9 | private val cache = LinkedHashMap()
10 |
11 | override suspend fun saveSearch(character: Character) {
12 | cache[character.url] = character
13 | }
14 |
15 | override fun getSearchHistory(): Flow> {
16 | return flowOf(cache.values.toList().reversed())
17 | }
18 |
19 | override suspend fun clearSearchHistory() {
20 | cache.clear()
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/character_search/src/androidTest/java/com/ezike/tobenna/starwarssearch/charactersearch/di/fakes/FakeSearchRepository.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.charactersearch.di.fakes
2 |
3 | import com.ezike.tobenna.starwarssearch.charactersearch.ui.DummyData
4 | import com.ezike.tobenna.starwarssearch.testutils.ERROR_MSG
5 | import com.ezike.tobenna.starwarssearch.testutils.ResponseType
6 | import kotlinx.coroutines.flow.Flow
7 | import kotlinx.coroutines.flow.flow
8 | import kotlinx.coroutines.flow.flowOf
9 | import java.net.SocketTimeoutException
10 |
11 | class FakeSearchRepository : SearchRepository {
12 |
13 | private var charactersFlow: Flow> = flowOf(DummyData.characterList)
14 |
15 | var responseType: ResponseType = ResponseType.DATA
16 | set(value) {
17 | field = value
18 | charactersFlow = makeResponse(value)
19 | }
20 |
21 | private fun makeResponse(type: ResponseType): Flow> {
22 | return when (type) {
23 | ResponseType.DATA -> flowOf(listOf(DummyData.character))
24 | ResponseType.EMPTY -> flowOf(listOf())
25 | ResponseType.ERROR -> flow { throw SocketTimeoutException(ERROR_MSG) }
26 | }
27 | }
28 |
29 | override fun searchCharacters(characterName: String): Flow> {
30 | return charactersFlow
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/character_search/src/androidTest/java/com/ezike/tobenna/starwarssearch/charactersearch/ui/DummyData.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.charactersearch.ui
2 |
3 | import com.ezike.tobenna.starwarssearch.charactersearch.model.CharacterModel
4 | import com.ezike.tobenna.starwarssearch.libcharactersearch.domain.model.Character
5 | import com.ezike.tobenna.starwarssearch.libcharactersearch.domain.model.CharacterDetail
6 | import com.ezike.tobenna.starwarssearch.libcharactersearch.domain.model.Film
7 | import com.ezike.tobenna.starwarssearch.libcharactersearch.domain.model.Planet
8 | import com.ezike.tobenna.starwarssearch.libcharactersearch.domain.model.Specie
9 | import com.ezike.tobenna.starwarssearch.testutils.ERROR_MSG
10 | import java.net.SocketTimeoutException
11 |
12 | internal object DummyData {
13 | val characterModel = CharacterModel(
14 | "Many men",
15 | "34.BBY",
16 | "143",
17 | "https://swapi.dev/people/21"
18 | )
19 |
20 | val character = Character(
21 | "Many men",
22 | "34.BBY",
23 | "143",
24 | "https://swapi.dev/people/21"
25 | )
26 |
27 | val characterList: List = listOf(character)
28 |
29 | const val query = "Luke"
30 |
31 | val characterDetail = CharacterDetail(
32 | listOf("www.url.com"),
33 | "http://swapi.dev/planet",
34 | listOf("https://swapi.dev.people"),
35 | "https://swapi.dev/people/12/"
36 | )
37 |
38 | val film = Film(
39 | "Some title",
40 | "An opening crawl"
41 | )
42 |
43 | val films: List = listOf(
44 | Film(
45 | "Some title",
46 | "An opening crawl"
47 | )
48 | )
49 |
50 | val planet = Planet(
51 | "tatooine",
52 | "1000000"
53 | )
54 |
55 | val specie = Specie(
56 | "Iroko",
57 | "Yoruba",
58 | "Enugu"
59 | )
60 |
61 | val species = listOf(
62 | Specie(
63 | "Iroko",
64 | "Yoruba",
65 | "Enugu"
66 | )
67 | )
68 | val filmModel = FilmModel(
69 | "Some title",
70 | "An opening crawl"
71 | )
72 |
73 | val planetModel = PlanetModel(
74 | "tatooine",
75 | "1000000"
76 | )
77 |
78 | val specieModel = SpecieModel(
79 | "Iroko",
80 | "Yoruba",
81 | "Enugu"
82 | )
83 |
84 | val exception: SocketTimeoutException
85 | get() = SocketTimeoutException(ERROR_MSG)
86 | }
87 |
--------------------------------------------------------------------------------
/character_search/src/androidTest/java/com/ezike/tobenna/starwarssearch/charactersearch/ui/SearchFragmentTest.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.charactersearch.ui
2 |
3 | import androidx.test.espresso.Espresso.onView
4 | import androidx.test.espresso.action.ViewActions
5 | import androidx.test.espresso.assertion.ViewAssertions.matches
6 | import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
7 | import androidx.test.espresso.matcher.ViewMatchers.withId
8 | import androidx.test.espresso.matcher.ViewMatchers.withText
9 | import androidx.test.ext.junit.rules.ActivityScenarioRule
10 | import androidx.test.ext.junit.runners.AndroidJUnit4
11 | import com.ezike.tobenna.starwarssearch.charactersearch.R
12 | import com.ezike.tobenna.starwarssearch.core.di.ExecutorModule
13 | import com.ezike.tobenna.starwarssearch.remote.di.RemoteModule
14 | import dagger.hilt.android.testing.HiltAndroidRule
15 | import dagger.hilt.android.testing.HiltAndroidTest
16 | import dagger.hilt.android.testing.UninstallModules
17 | import org.hamcrest.Matchers.not
18 | import org.junit.Rule
19 | import org.junit.Test
20 | import org.junit.runner.RunWith
21 |
22 | @UninstallModules(
23 | ExecutorModule::class,
24 | RemoteModule::class
25 | )
26 | @HiltAndroidTest
27 | @RunWith(AndroidJUnit4::class)
28 | class SearchFragmentTest {
29 |
30 | @get:Rule(order = 0)
31 | val hiltRule = HiltAndroidRule(this)
32 |
33 | @get:Rule(order = 1)
34 | val activityRule: ActivityScenarioRule =
35 | ActivityScenarioRule(com.ezike.tobenna.starwarssearch.MainActivity::class.java)
36 |
37 | @Test
38 | fun should_show_initial_state() {
39 | onView(withId(R.id.search_bar)).check(matches(isDisplayed()))
40 | onView(withId(R.id.clear_history)).check(matches(not(isDisplayed())))
41 | onView(withId(R.id.search_history_prompt)).check(matches(isDisplayed())).check(
42 | matches(withText("Your recent searches will appear here"))
43 | )
44 | }
45 |
46 | @Test
47 | fun show_data_when_search_is_done() {
48 | onView(withId(R.id.search_bar)).perform(ViewActions.typeText(DummyData.query))
49 | onView(withId(R.id.search_result)).check(matches(isDisplayed()))
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/character_search/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/character_search/src/main/java/com/ezike/tobenna/starwarssearch/charactersearch/data/ApiService.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.charactersearch.data
2 |
3 | import retrofit2.http.GET
4 | import retrofit2.http.Query
5 | import retrofit2.http.Url
6 |
7 | internal interface ApiService {
8 |
9 | @GET("people/")
10 | suspend fun searchCharacters(@Query("search") params: String): CharacterSearchResponse
11 |
12 | @GET
13 | suspend fun nextSearchPage(@Url url: String): CharacterSearchResponse
14 | }
15 |
16 | internal data class CharacterRemoteModel(
17 | val name: String,
18 | val birth_year: String,
19 | val height: String,
20 | val films: List,
21 | val homeworld: String,
22 | val species: List,
23 | val url: String
24 | )
25 |
26 | internal data class CharacterSearchResponse(
27 | val results: List,
28 | val next: String?
29 | )
30 |
--------------------------------------------------------------------------------
/character_search/src/main/java/com/ezike/tobenna/starwarssearch/charactersearch/data/CharacterEntity.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.charactersearch.data
2 |
3 | internal data class CharacterEntity(
4 | val name: String,
5 | val birthYear: String,
6 | val height: String,
7 | val url: String
8 | )
9 |
--------------------------------------------------------------------------------
/character_search/src/main/java/com/ezike/tobenna/starwarssearch/charactersearch/data/DataModule.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.charactersearch.data
2 |
3 | import dagger.Binds
4 | import dagger.Module
5 | import dagger.Provides
6 | import dagger.hilt.InstallIn
7 | import dagger.hilt.android.components.ViewModelComponent
8 | import retrofit2.Retrofit
9 |
10 | @InstallIn(ViewModelComponent::class)
11 | @Module
12 | internal interface DataModule {
13 |
14 | @get:Binds
15 | val SearchRepositoryImpl.searchRepository: SearchRepository
16 |
17 | companion object {
18 | @Provides
19 | fun apiService(retrofit: Retrofit): ApiService =
20 | retrofit.create(ApiService::class.java)
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/character_search/src/main/java/com/ezike/tobenna/starwarssearch/charactersearch/di/SearchCharacterModule.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.charactersearch.di
2 |
3 | import com.ezike.tobenna.starwarssearch.charactersearch.presentation.SearchIntentProcessor
4 | import com.ezike.tobenna.starwarssearch.charactersearch.presentation.SearchScreenIntentProcessor
5 | import com.ezike.tobenna.starwarssearch.charactersearch.presentation.SearchScreenStateMachine
6 | import com.ezike.tobenna.starwarssearch.charactersearch.presentation.SearchScreenStateReducer
7 | import com.ezike.tobenna.starwarssearch.charactersearch.presentation.SearchStateMachine
8 | import com.ezike.tobenna.starwarssearch.charactersearch.presentation.SearchStateReducer
9 | import dagger.Binds
10 | import dagger.Module
11 | import dagger.hilt.InstallIn
12 | import dagger.hilt.android.components.ViewModelComponent
13 |
14 | @InstallIn(ViewModelComponent::class)
15 | @Module
16 | internal interface SearchCharacterModule {
17 |
18 | @get:Binds
19 | val SearchScreenIntentProcessor.intentProcessor: SearchIntentProcessor
20 |
21 | @get:Binds
22 | val SearchScreenStateReducer.reducer: SearchStateReducer
23 |
24 | @get:Binds
25 | val SearchScreenStateMachine.stateMachine: SearchStateMachine
26 | }
27 |
--------------------------------------------------------------------------------
/character_search/src/main/java/com/ezike/tobenna/starwarssearch/charactersearch/mapper/CharacterModelMapper.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.charactersearch.mapper
2 |
3 | import com.ezike.tobenna.starwarssearch.charactersearch.data.CharacterEntity
4 | import com.ezike.tobenna.starwarssearch.charactersearch.model.CharacterModel
5 | import com.ezike.tobenna.starwarssearch.presentation.mapper.ModelMapper
6 | import javax.inject.Inject
7 |
8 | internal class CharacterModelMapper @Inject constructor() :
9 | ModelMapper {
10 |
11 | override fun mapToModel(domain: CharacterEntity): CharacterModel =
12 | CharacterModel(
13 | domain.name,
14 | domain.birthYear,
15 | domain.height,
16 | domain.url
17 | )
18 |
19 | override fun mapToDomain(model: CharacterModel): CharacterEntity =
20 | CharacterEntity(
21 | model.name,
22 | model.birthYear,
23 | model.heightCm,
24 | model.url
25 | )
26 | }
27 |
--------------------------------------------------------------------------------
/character_search/src/main/java/com/ezike/tobenna/starwarssearch/charactersearch/model/CharacterModel.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.charactersearch.model
2 |
3 | import android.os.Parcelable
4 | import kotlinx.parcelize.Parcelize
5 |
6 | @Parcelize
7 | data class CharacterModel(
8 | val name: String,
9 | val birthYear: String,
10 | val heightCm: String,
11 | val url: String
12 | ) : Parcelable
13 |
--------------------------------------------------------------------------------
/character_search/src/main/java/com/ezike/tobenna/starwarssearch/charactersearch/navigation/Navigator.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.charactersearch.navigation
2 |
3 | import com.ezike.tobenna.starwarssearch.charactersearch.model.CharacterModel
4 |
5 | interface Navigator {
6 | fun openCharacterDetail(model: CharacterModel)
7 | }
8 |
--------------------------------------------------------------------------------
/character_search/src/main/java/com/ezike/tobenna/starwarssearch/charactersearch/presentation/Alias.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.charactersearch.presentation
2 |
3 | import com.ezike.tobenna.starwarssearch.charactersearch.presentation.viewstate.SearchScreenState
4 | import com.ezike.tobenna.starwarssearch.presentation.base.IntentProcessor
5 | import com.ezike.tobenna.starwarssearch.presentation.base.StateReducer
6 | import com.ezike.tobenna.starwarssearch.presentation.stateMachine.StateMachine
7 | import com.ezike.tobenna.starwarssearch.presentation_android.ComponentManager
8 |
9 | internal typealias SearchIntentProcessor =
10 | @JvmSuppressWildcards IntentProcessor
11 |
12 | internal typealias SearchStateReducer =
13 | @JvmSuppressWildcards StateReducer
14 |
15 | internal typealias SearchStateMachine =
16 | @JvmSuppressWildcards StateMachine
17 |
18 | internal typealias SearchComponentManager =
19 | @JvmSuppressWildcards ComponentManager
20 |
--------------------------------------------------------------------------------
/character_search/src/main/java/com/ezike/tobenna/starwarssearch/charactersearch/presentation/SearchScreenIntent.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.charactersearch.presentation
2 |
3 | import com.ezike.tobenna.starwarssearch.charactersearch.model.CharacterModel
4 | import com.ezike.tobenna.starwarssearch.presentation.base.ViewIntent
5 |
6 | sealed interface SearchScreenIntent : ViewIntent
7 | data class RetrySearchIntent(val query: String) : SearchScreenIntent
8 | data class SaveSearchIntent(val character: CharacterModel) : SearchScreenIntent
9 | data class SearchIntent(val query: String) : SearchScreenIntent
10 | object ClearSearchHistoryIntent : SearchScreenIntent
11 | data class UpdateHistoryIntent(val character: CharacterModel) : SearchScreenIntent
12 | object LoadSearchHistory : SearchScreenIntent
13 |
--------------------------------------------------------------------------------
/character_search/src/main/java/com/ezike/tobenna/starwarssearch/charactersearch/presentation/SearchScreenResult.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.charactersearch.presentation
2 |
3 | import com.ezike.tobenna.starwarssearch.charactersearch.data.CharacterEntity
4 | import com.ezike.tobenna.starwarssearch.presentation.base.ViewResult
5 |
6 | internal sealed class SearchScreenResult : ViewResult {
7 | data class LoadedHistory(val searchHistory: List) : SearchScreenResult()
8 | sealed class SearchCharacterResult : SearchScreenResult() {
9 | object Searching : SearchCharacterResult()
10 | data class SearchError(val throwable: Throwable) : SearchCharacterResult()
11 | data class LoadedSearchResult(val characters: List) :
12 | SearchCharacterResult()
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/character_search/src/main/java/com/ezike/tobenna/starwarssearch/charactersearch/presentation/SearchScreenStateMachine.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.charactersearch.presentation
2 |
3 | import com.ezike.tobenna.starwarssearch.charactersearch.presentation.viewstate.SearchScreenState
4 | import com.ezike.tobenna.starwarssearch.presentation.stateMachine.RenderStrategy
5 | import javax.inject.Inject
6 |
7 | internal class SearchScreenStateMachine @Inject constructor(
8 | intentProcessor: SearchIntentProcessor,
9 | reducer: SearchStateReducer
10 | ) : SearchStateMachine(
11 | intentProcessor = intentProcessor,
12 | reducer = reducer,
13 | initialState = SearchScreenState.Initial,
14 | initialIntent = LoadSearchHistory,
15 | renderStrategy = RenderStrategy.Latest
16 | )
17 |
--------------------------------------------------------------------------------
/character_search/src/main/java/com/ezike/tobenna/starwarssearch/charactersearch/presentation/SearchScreenStateReducer.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.charactersearch.presentation
2 |
3 | import com.ezike.tobenna.starwarssearch.charactersearch.mapper.CharacterModelMapper
4 | import com.ezike.tobenna.starwarssearch.charactersearch.presentation.SearchScreenResult.LoadedHistory
5 | import com.ezike.tobenna.starwarssearch.charactersearch.presentation.SearchScreenResult.SearchCharacterResult.LoadedSearchResult
6 | import com.ezike.tobenna.starwarssearch.charactersearch.presentation.SearchScreenResult.SearchCharacterResult.SearchError
7 | import com.ezike.tobenna.starwarssearch.charactersearch.presentation.SearchScreenResult.SearchCharacterResult.Searching
8 | import com.ezike.tobenna.starwarssearch.charactersearch.presentation.viewstate.SearchScreenState
9 | import com.ezike.tobenna.starwarssearch.charactersearch.ui.views.history.SearchHistoryViewState
10 | import com.ezike.tobenna.starwarssearch.charactersearch.ui.views.result.SearchResultViewState
11 | import com.ezike.tobenna.starwarssearch.core.ext.errorMessage
12 | import javax.inject.Inject
13 |
14 | internal class SearchScreenStateReducer @Inject constructor(
15 | private val characterModelMapper: CharacterModelMapper
16 | ) : SearchStateReducer {
17 |
18 | override fun reduce(
19 | oldState: SearchScreenState,
20 | result: SearchScreenResult
21 | ): SearchScreenState = when (result) {
22 | is LoadedHistory -> {
23 | val data =
24 | characterModelMapper.mapToModelList(result.searchHistory)
25 | val state = SearchHistoryViewState.DataLoaded(data = data)
26 | SearchScreenState.HistoryView(state)
27 | }
28 | Searching -> {
29 | val state = SearchResultViewState.Searching(
30 | data = oldState.searchResultState.resultState.data
31 | )
32 | SearchScreenState.ResultView(state = state)
33 | }
34 | is SearchError -> {
35 | val state = SearchResultViewState.Error(
36 | message = result.throwable.errorMessage
37 | )
38 | SearchScreenState.ResultView(state = state)
39 | }
40 | is LoadedSearchResult -> {
41 | val data =
42 | characterModelMapper.mapToModelList(result.characters)
43 | val state = SearchResultViewState.DataLoaded(data = data)
44 | SearchScreenState.ResultView(state = state)
45 | }
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/character_search/src/main/java/com/ezike/tobenna/starwarssearch/charactersearch/presentation/viewstate/SearchScreenState.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.charactersearch.presentation.viewstate
2 |
3 | import com.ezike.tobenna.starwarssearch.charactersearch.ui.views.history.SearchHistoryViewState
4 | import com.ezike.tobenna.starwarssearch.charactersearch.ui.views.result.SearchResultViewState
5 | import com.ezike.tobenna.starwarssearch.presentation.base.ScreenState
6 |
7 | sealed class SearchScreenState(
8 | val searchHistoryState: SearchHistoryViewState,
9 | val searchResultState: SearchResultViewState
10 | ) : ScreenState {
11 |
12 | object Initial : SearchScreenState(
13 | searchHistoryState = SearchHistoryViewState.Initial,
14 | searchResultState = SearchResultViewState.Initial
15 | )
16 |
17 | data class HistoryView(
18 | val state: SearchHistoryViewState
19 | ) : SearchScreenState(
20 | searchHistoryState = state,
21 | searchResultState = SearchResultViewState.Hide
22 | )
23 |
24 | data class ResultView(
25 | val state: SearchResultViewState
26 | ) : SearchScreenState(
27 | searchHistoryState = SearchHistoryViewState.Hide,
28 | searchResultState = state
29 | )
30 | }
31 |
--------------------------------------------------------------------------------
/character_search/src/main/java/com/ezike/tobenna/starwarssearch/charactersearch/ui/CharacterSearchViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.charactersearch.ui
2 |
3 | import com.ezike.tobenna.starwarssearch.charactersearch.presentation.SearchComponentManager
4 | import com.ezike.tobenna.starwarssearch.charactersearch.presentation.SearchStateMachine
5 | import dagger.hilt.android.lifecycle.HiltViewModel
6 | import javax.inject.Inject
7 |
8 | @HiltViewModel
9 | internal class CharacterSearchViewModel @Inject constructor(
10 | searchStateMachine: SearchStateMachine
11 | ) : SearchComponentManager(searchStateMachine)
12 |
--------------------------------------------------------------------------------
/character_search/src/main/java/com/ezike/tobenna/starwarssearch/charactersearch/ui/adapter/SearchHistoryAdapter.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.charactersearch.ui.adapter
2 |
3 | import android.view.ViewGroup
4 | import androidx.recyclerview.widget.DiffUtil
5 | import androidx.recyclerview.widget.ListAdapter
6 | import androidx.recyclerview.widget.RecyclerView
7 | import com.ezike.tobenna.starwarssearch.charactersearch.R
8 | import com.ezike.tobenna.starwarssearch.charactersearch.databinding.SearchHistoryBinding
9 | import com.ezike.tobenna.starwarssearch.charactersearch.model.CharacterModel
10 | import com.ezike.tobenna.starwarssearch.core.ext.inflate
11 |
12 | typealias RecentSearchClickListener = (CharacterModel) -> Unit
13 |
14 | class SearchHistoryAdapter(private val onClick: RecentSearchClickListener) :
15 | ListAdapter(diffUtilCallback) {
16 |
17 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SearchHistoryViewHolder {
18 | return SearchHistoryViewHolder(SearchHistoryBinding.bind(parent.inflate(R.layout.search_history)))
19 | }
20 |
21 | override fun onBindViewHolder(holder: SearchHistoryViewHolder, position: Int) {
22 | holder.bind(getItem(position), onClick)
23 | }
24 |
25 | class SearchHistoryViewHolder(private val binding: SearchHistoryBinding) :
26 | RecyclerView.ViewHolder(binding.root) {
27 |
28 | fun bind(character: CharacterModel, clickListener: RecentSearchClickListener) {
29 | binding.name.text = character.name
30 | binding.name.setOnClickListener {
31 | clickListener(character)
32 | }
33 | }
34 | }
35 |
36 | companion object {
37 | val diffUtilCallback: DiffUtil.ItemCallback
38 | get() = object : DiffUtil.ItemCallback() {
39 | override fun areItemsTheSame(
40 | oldItem: CharacterModel,
41 | newItem: CharacterModel
42 | ): Boolean {
43 | return oldItem.url == newItem.url
44 | }
45 |
46 | override fun areContentsTheSame(
47 | oldItem: CharacterModel,
48 | newItem: CharacterModel
49 | ): Boolean {
50 | return oldItem == newItem
51 | }
52 | }
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/character_search/src/main/java/com/ezike/tobenna/starwarssearch/charactersearch/ui/adapter/SearchResultAdapter.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.charactersearch.ui.adapter
2 |
3 | import android.view.ViewGroup
4 | import androidx.recyclerview.widget.DiffUtil
5 | import androidx.recyclerview.widget.ListAdapter
6 | import androidx.recyclerview.widget.RecyclerView
7 | import com.ezike.tobenna.starwarssearch.charactersearch.R
8 | import com.ezike.tobenna.starwarssearch.charactersearch.databinding.SearchResultBinding
9 | import com.ezike.tobenna.starwarssearch.charactersearch.model.CharacterModel
10 | import com.ezike.tobenna.starwarssearch.charactersearch.ui.adapter.SearchResultAdapter.SearchResultViewHolder
11 | import com.ezike.tobenna.starwarssearch.core.ext.inflate
12 |
13 | typealias SearchResultClickListener = (CharacterModel) -> Unit
14 |
15 | class SearchResultAdapter(private val onClick: SearchResultClickListener) :
16 | ListAdapter(diffUtilCallback) {
17 |
18 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SearchResultViewHolder {
19 | return SearchResultViewHolder(SearchResultBinding.bind(parent.inflate(R.layout.search_result)))
20 | }
21 |
22 | override fun onBindViewHolder(holder: SearchResultViewHolder, position: Int) {
23 | holder.bind(getItem(position), onClick)
24 | }
25 |
26 | class SearchResultViewHolder(private val binding: SearchResultBinding) :
27 | RecyclerView.ViewHolder(binding.root) {
28 |
29 | fun bind(character: CharacterModel, onClick: SearchResultClickListener) {
30 | binding.character.text = character.name
31 | binding.character.setOnClickListener {
32 | onClick(character)
33 | }
34 | }
35 | }
36 |
37 | companion object {
38 | val diffUtilCallback: DiffUtil.ItemCallback
39 | get() = object : DiffUtil.ItemCallback() {
40 | override fun areItemsTheSame(
41 | oldItem: CharacterModel,
42 | newItem: CharacterModel
43 | ): Boolean {
44 | return oldItem.url == newItem.url
45 | }
46 |
47 | override fun areContentsTheSame(
48 | oldItem: CharacterModel,
49 | newItem: CharacterModel
50 | ): Boolean {
51 | return oldItem == newItem
52 | }
53 | }
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/character_search/src/main/java/com/ezike/tobenna/starwarssearch/charactersearch/ui/views/history/SearchHistoryView.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.charactersearch.ui.views.history
2 |
3 | import androidx.core.view.isVisible
4 | import com.ezike.tobenna.starwarssearch.charactersearch.databinding.LayoutSearchHistoryBinding
5 | import com.ezike.tobenna.starwarssearch.charactersearch.model.CharacterModel
6 | import com.ezike.tobenna.starwarssearch.charactersearch.presentation.ClearSearchHistoryIntent
7 | import com.ezike.tobenna.starwarssearch.charactersearch.presentation.UpdateHistoryIntent
8 | import com.ezike.tobenna.starwarssearch.charactersearch.ui.adapter.SearchHistoryAdapter
9 | import com.ezike.tobenna.starwarssearch.core.ext.init
10 | import com.ezike.tobenna.starwarssearch.core.ext.show
11 | import com.ezike.tobenna.starwarssearch.presentation_android.UIComponent
12 |
13 | class SearchHistoryView(
14 | private val view: LayoutSearchHistoryBinding,
15 | navigationAction: (CharacterModel) -> Unit
16 | ) : UIComponent() {
17 |
18 | private val searchHistoryAdapter: SearchHistoryAdapter by init {
19 | SearchHistoryAdapter { model ->
20 | sendIntent(UpdateHistoryIntent(model))
21 | navigationAction(model)
22 | }
23 | }
24 |
25 | init {
26 | view.clearHistory.setOnClickListener { sendIntent(ClearSearchHistoryIntent) }
27 | view.searchHistoryRv.adapter = searchHistoryAdapter
28 | }
29 |
30 | override fun render(state: SearchHistoryViewState) {
31 | searchHistoryAdapter.submitList(state.historyState.data)
32 | view.run {
33 | recentSearchGroup.isVisible = state.showRecentSearchGroup
34 | searchHistoryRv.show = state.historyState.showHistory
35 | searchHistoryPrompt.isVisible = state.showHistoryPrompt
36 | }
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/character_search/src/main/java/com/ezike/tobenna/starwarssearch/charactersearch/ui/views/history/SearchHistoryViewState.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.charactersearch.ui.views.history
2 |
3 | import com.ezike.tobenna.starwarssearch.charactersearch.model.CharacterModel
4 | import com.ezike.tobenna.starwarssearch.presentation.base.ViewState
5 |
6 | data class HistoryState(
7 | val data: List,
8 | val showHistory: Boolean
9 | )
10 |
11 | sealed class SearchHistoryViewState(
12 | val historyState: HistoryState,
13 | val showRecentSearchGroup: Boolean,
14 | val showHistoryPrompt: Boolean
15 | ) : ViewState {
16 |
17 | object Initial : SearchHistoryViewState(
18 | historyState = HistoryState(
19 | data = emptyList(),
20 | showHistory = false
21 | ),
22 | showRecentSearchGroup = false,
23 | showHistoryPrompt = false
24 | )
25 |
26 | data class DataLoaded(
27 | val data: List
28 | ) : SearchHistoryViewState(
29 | historyState = HistoryState(
30 | data = data,
31 | showHistory = data.isNotEmpty()
32 | ),
33 | showRecentSearchGroup = data.isNotEmpty(),
34 | showHistoryPrompt = data.isEmpty()
35 | )
36 |
37 | companion object {
38 | val Hide: SearchHistoryViewState
39 | get() = Initial
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/character_search/src/main/java/com/ezike/tobenna/starwarssearch/charactersearch/ui/views/result/SearchResultView.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.charactersearch.ui.views.result
2 |
3 | import androidx.core.view.isVisible
4 | import com.ezike.tobenna.starwarssearch.charactersearch.databinding.LayoutSearchResultBinding
5 | import com.ezike.tobenna.starwarssearch.charactersearch.model.CharacterModel
6 | import com.ezike.tobenna.starwarssearch.charactersearch.presentation.RetrySearchIntent
7 | import com.ezike.tobenna.starwarssearch.charactersearch.presentation.SaveSearchIntent
8 | import com.ezike.tobenna.starwarssearch.charactersearch.ui.adapter.SearchResultAdapter
9 | import com.ezike.tobenna.starwarssearch.core.ext.init
10 | import com.ezike.tobenna.starwarssearch.core.ext.show
11 | import com.ezike.tobenna.starwarssearch.presentation_android.UIComponent
12 |
13 | class SearchResultView(
14 | private val view: LayoutSearchResultBinding,
15 | query: () -> String,
16 | navigationAction: (CharacterModel) -> Unit
17 | ) : UIComponent() {
18 |
19 | private val searchResultAdapter: SearchResultAdapter by init {
20 | SearchResultAdapter { model ->
21 | sendIntent(SaveSearchIntent(model))
22 | navigationAction(model)
23 | }
24 | }
25 |
26 | init {
27 | view.charactersRv.adapter = searchResultAdapter
28 | view.errorState.onRetry { sendIntent(RetrySearchIntent(query())) }
29 | }
30 |
31 | override fun render(state: SearchResultViewState) {
32 | searchResultAdapter.submitList(state.resultState.data)
33 | view.run {
34 | charactersRv.show = state.resultState.showResult
35 | progressBar.isVisible = state.showProgress
36 | emptyState.isVisible = state.showEmpty
37 | errorState.isVisible = state.errorState.showError
38 | errorState.setCaption(state.errorState.error)
39 | }
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/character_search/src/main/java/com/ezike/tobenna/starwarssearch/charactersearch/ui/views/result/SearchResultViewState.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.charactersearch.ui.views.result
2 |
3 | import com.ezike.tobenna.starwarssearch.charactersearch.model.CharacterModel
4 | import com.ezike.tobenna.starwarssearch.presentation.base.ViewState
5 |
6 | data class ResultState(
7 | val data: List,
8 | val showResult: Boolean
9 | )
10 |
11 | data class ErrorState(
12 | val showError: Boolean,
13 | val error: String?
14 | )
15 |
16 | sealed class SearchResultViewState(
17 | val resultState: ResultState,
18 | val showProgress: Boolean,
19 | val showEmpty: Boolean,
20 | val errorState: ErrorState
21 | ) : ViewState {
22 |
23 | object Initial : SearchResultViewState(
24 | resultState = ResultState(
25 | data = emptyList(),
26 | showResult = false
27 | ),
28 | showProgress = false,
29 | showEmpty = false,
30 | errorState = ErrorState(
31 | showError = false,
32 | error = null
33 | )
34 | )
35 |
36 | data class Searching(
37 | val data: List
38 | ) : SearchResultViewState(
39 | resultState = ResultState(
40 | data = data,
41 | showResult = data.isNotEmpty()
42 | ),
43 | showProgress = data.isEmpty(),
44 | showEmpty = false,
45 | errorState = ErrorState(
46 | showError = false,
47 | error = null
48 | )
49 | )
50 |
51 | data class Error(
52 | val message: String
53 | ) : SearchResultViewState(
54 | resultState = ResultState(
55 | data = emptyList(),
56 | showResult = false
57 | ),
58 | showProgress = false,
59 | showEmpty = false,
60 | errorState = ErrorState(
61 | showError = true,
62 | error = message
63 | )
64 | )
65 |
66 | data class DataLoaded(
67 | val data: List
68 | ) : SearchResultViewState(
69 | resultState = ResultState(
70 | data = data,
71 | showResult = data.isNotEmpty()
72 | ),
73 | showProgress = false,
74 | showEmpty = data.isEmpty(),
75 | errorState = ErrorState(
76 | showError = false,
77 | error = null
78 | )
79 | )
80 |
81 | companion object {
82 | val Hide: SearchResultViewState
83 | get() = Initial
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/character_search/src/main/java/com/ezike/tobenna/starwarssearch/charactersearch/ui/views/search/SearchBarView.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.charactersearch.ui.views.search
2 |
3 | import android.widget.EditText
4 | import androidx.lifecycle.LifecycleCoroutineScope
5 | import com.ezike.tobenna.starwarssearch.charactersearch.presentation.SearchIntent
6 | import com.ezike.tobenna.starwarssearch.presentation_android.StatelessUIComponent
7 | import kotlinx.coroutines.flow.launchIn
8 | import kotlinx.coroutines.flow.onEach
9 |
10 | class SearchBarView(
11 | searchBar: EditText,
12 | coroutineScope: LifecycleCoroutineScope
13 | ) : StatelessUIComponent() {
14 |
15 | init {
16 | searchBar.textChanges
17 | .onEach { query -> sendIntent(SearchIntent(query)) }
18 | .launchIn(coroutineScope)
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/character_search/src/main/java/com/ezike/tobenna/starwarssearch/charactersearch/ui/views/search/SearchExtensions.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.charactersearch.ui.views.search
2 |
3 | import android.widget.EditText
4 | import kotlinx.coroutines.flow.Flow
5 | import kotlinx.coroutines.flow.conflate
6 | import kotlinx.coroutines.flow.debounce
7 | import kotlinx.coroutines.flow.filter
8 | import kotlinx.coroutines.flow.map
9 | import kotlinx.coroutines.flow.onEach
10 | import reactivecircus.flowbinding.android.widget.textChanges
11 |
12 | private val Flow.checkDistinct: Flow
13 | get() {
14 | return this.filter { string ->
15 | DistinctText.text != string
16 | }.onEach { value ->
17 | DistinctText.text = value
18 | }
19 | }
20 |
21 | const val DEBOUNCE_PERIOD: Long = 150L
22 |
23 | internal val EditText.textChanges: Flow
24 | get() = this.textChanges()
25 | .skipInitialValue()
26 | .debounce(DEBOUNCE_PERIOD)
27 | .map { char -> char.toString().trim() }
28 | .conflate()
29 | .checkDistinct
30 |
31 | /**
32 | * Object to hold last search query
33 | * Prevents emission of search results on config change,
34 | or each time the search bar is created
35 | */
36 | private object DistinctText {
37 | var text: String = Integer.MIN_VALUE.toString()
38 | }
39 |
--------------------------------------------------------------------------------
/character_search/src/main/res/drawable/ic_baseline_access_time_24.xml:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/character_search/src/main/res/drawable/ic_baseline_keyboard_arrow_right_24.xml:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/character_search/src/main/res/drawable/ic_baseline_search_24.xml:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/character_search/src/main/res/drawable/search_bar_bg.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | -
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/character_search/src/main/res/layout/fragment_search.xml:
--------------------------------------------------------------------------------
1 |
2 |
9 |
10 |
28 |
29 |
38 |
39 |
48 |
49 |
58 |
59 |
60 |
--------------------------------------------------------------------------------
/character_search/src/main/res/layout/search_history.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
14 |
--------------------------------------------------------------------------------
/character_search/src/main/res/layout/search_result.xml:
--------------------------------------------------------------------------------
1 |
2 |
13 |
--------------------------------------------------------------------------------
/character_search/src/main/res/navigation/search_nav_graph.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/character_search/src/main/res/values/attrs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/character_search/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #48636389
4 | #bdbdbd
5 | #FFBB86FC
6 | #FF6200EE
7 | #FF3700B3
8 | #FF03DAC5
9 | #FF018786
10 | #FF000000
11 | #FFFFFFFF
12 |
13 |
--------------------------------------------------------------------------------
/character_search/src/main/res/values/dimens.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | 16dp
4 | 16sp
5 | 16dp
6 | 18dp
7 | 12dp
8 | 16dp
9 | 16dp
10 | 18sp
11 | 30dp
12 | 50dp
13 | 8dp
14 | 18sp
15 | 16dp
16 | 150dp
17 | 16dp
18 | 36dp
19 | 2dp
20 | 16dp
21 | 16dp
22 | 20dp
23 | 8dp
24 | 4dp
25 | 24dp
26 | 16dp
27 |
28 |
--------------------------------------------------------------------------------
/character_search/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | Search …
3 | Clear history
4 | Your recent searches will appear here
5 | Recent searches
6 | Retry
7 | Empty state icon
8 | An error occurred
9 | No characters found
10 |
11 |
--------------------------------------------------------------------------------
/character_search/src/main/res/values/styles.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
6 |
7 |
10 |
11 |
18 |
21 |
22 |
--------------------------------------------------------------------------------
/character_search/src/main/res/values/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
16 |
17 |
--------------------------------------------------------------------------------
/character_search/src/sharedTest/java/charactersearch/TestPostExecutionThread.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.charactersearch
2 |
3 | import com.ezike.tobenna.starwarssearch.libcharactersearch.domain.executor.PostExecutionThread
4 | import kotlinx.coroutines.CoroutineDispatcher
5 | import kotlinx.coroutines.test.TestCoroutineDispatcher
6 |
7 | class TestPostExecutionThread : PostExecutionThread {
8 |
9 | override val main: CoroutineDispatcher
10 | get() = TestCoroutineDispatcher()
11 |
12 | override val io: CoroutineDispatcher
13 | get() = TestCoroutineDispatcher()
14 |
15 | override val default: CoroutineDispatcher
16 | get() = TestCoroutineDispatcher()
17 | }
18 |
--------------------------------------------------------------------------------
/character_search/src/test/java/com/ezike/tobenna/starwarssearch/charactersearch/data/DummyData.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.charactersearch.data
2 |
3 | import com.ezike.tobenna.starwarssearch.charactersearch.model.CharacterModel
4 | import com.ezike.tobenna.starwarssearch.libcharactersearch.domain.model.Character
5 | import com.ezike.tobenna.starwarssearch.libcharactersearch.domain.model.CharacterDetail
6 | import com.ezike.tobenna.starwarssearch.testutils.ERROR_MSG
7 | import java.net.SocketTimeoutException
8 |
9 | internal object DummyData {
10 | val characterModel = CharacterModel(
11 | "Many men",
12 | "34.BBY",
13 | "143",
14 | "https://swapi.dev/people/21"
15 | )
16 |
17 | val character = Character(
18 | "Many men",
19 | "34.BBY",
20 | "143",
21 | "https://swapi.dev/people/21"
22 | )
23 |
24 | val characterList: List = listOf(character)
25 |
26 | const val query = "Luke"
27 |
28 | val characterDetail = CharacterDetail(
29 | listOf("www.url.com"),
30 | "http://swapi.dev/planet",
31 | listOf("https://swapi.dev.people"),
32 | "https://swapi.dev/people/12/"
33 | )
34 |
35 | val exception: SocketTimeoutException
36 | get() = SocketTimeoutException(ERROR_MSG)
37 | }
38 |
--------------------------------------------------------------------------------
/character_search/src/test/java/com/ezike/tobenna/starwarssearch/charactersearch/fakes/FakeSearchHistoryRepository.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.charactersearch.fakes
2 |
3 | import kotlinx.coroutines.flow.Flow
4 | import kotlinx.coroutines.flow.flowOf
5 |
6 | class FakeSearchHistoryRepository : SearchHistoryRepository {
7 |
8 | private val cache = LinkedHashMap()
9 |
10 | override suspend fun saveSearch(character: Character) {
11 | cache[character.url] = character
12 | }
13 |
14 | override fun getSearchHistory(): Flow> {
15 | return flowOf(cache.values.toList().reversed())
16 | }
17 |
18 | override suspend fun clearSearchHistory() {
19 | cache.clear()
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/character_search/src/test/java/com/ezike/tobenna/starwarssearch/charactersearch/fakes/FakeSearchRepository.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.charactersearch.fakes
2 |
3 | import com.ezike.tobenna.starwarssearch.charactersearch.data.DummyData
4 | import com.ezike.tobenna.starwarssearch.testutils.ERROR_MSG
5 | import com.ezike.tobenna.starwarssearch.testutils.ResponseType
6 | import kotlinx.coroutines.flow.Flow
7 | import kotlinx.coroutines.flow.flow
8 | import kotlinx.coroutines.flow.flowOf
9 | import java.net.SocketTimeoutException
10 |
11 | class FakeSearchRepository : SearchRepository {
12 |
13 | private var charactersFlow: Flow> =
14 | flowOf(DummyData.characterList)
15 |
16 | var responseType: ResponseType = ResponseType.DATA
17 | set(value) {
18 | field = value
19 | charactersFlow = makeResponse(value)
20 | }
21 |
22 | private fun makeResponse(type: ResponseType): Flow> {
23 | return when (type) {
24 | ResponseType.DATA -> flowOf(listOf(DummyData.character))
25 | ResponseType.EMPTY -> flowOf(listOf())
26 | ResponseType.ERROR -> flow { throw SocketTimeoutException(ERROR_MSG) }
27 | }
28 | }
29 |
30 | override fun searchCharacters(characterName: String): Flow> {
31 | return charactersFlow
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/character_search/src/test/java/com/ezike/tobenna/starwarssearch/charactersearch/mapper/CharacterModelMapperTest.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.charactersearch.mapper
2 |
3 | import com.ezike.tobenna.starwarssearch.charactersearch.data.DummyData
4 | import com.ezike.tobenna.starwarssearch.charactersearch.model.CharacterModel
5 | import com.ezike.tobenna.starwarssearch.libcharactersearch.domain.model.Character
6 | import com.google.common.truth.Truth.assertThat
7 | import org.junit.Test
8 |
9 | class CharacterModelMapperTest {
10 |
11 | private val characterModelMapper = CharacterModelMapper()
12 |
13 | @Test
14 | fun `check that mapToModel returns correct data`() {
15 | val character: Character = DummyData.character
16 | val model: CharacterModel = characterModelMapper.mapToModel(character)
17 | assertThat(character.name).isEqualTo(model.name)
18 | assertThat(character.birthYear).isEqualTo(model.birthYear)
19 | assertThat(character.height).isEqualTo(model.heightCm)
20 | assertThat(character.url).isEqualTo(model.url)
21 | }
22 |
23 | @Test
24 | fun `check that mapToDomain returns correct data`() {
25 | val model: CharacterModel = DummyData.characterModel
26 | val characterDomain: Character = characterModelMapper.mapToDomain(model)
27 | assertThat(model.name).isEqualTo(characterDomain.name)
28 | assertThat(model.birthYear).isEqualTo(characterDomain.birthYear)
29 | assertThat(model.heightCm).isEqualTo(characterDomain.height)
30 | assertThat(model.url).isEqualTo(characterDomain.url)
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/core/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/core/build.gradle.kts:
--------------------------------------------------------------------------------
1 | import Dependencies.AndroidX
2 | import Dependencies.Coroutines
3 | import Dependencies.DI
4 | import Dependencies.Network
5 | import Dependencies.View
6 |
7 | plugins {
8 | androidLibrary
9 | kotlin(kotlinAndroid)
10 | kotlin(kotlinKapt)
11 | daggerHilt
12 | }
13 |
14 | android {
15 | namespace = "com.ezike.tobenna.starwarssearch.core"
16 | compileSdkVersion(Config.Version.compileSdkVersion)
17 | defaultConfig {
18 | minSdkVersion(Config.Version.minSdkVersion)
19 | targetSdkVersion(Config.Version.targetSdkVersion)
20 | }
21 |
22 | compileOptions {
23 | sourceCompatibility = JavaVersion.VERSION_11
24 | targetCompatibility = JavaVersion.VERSION_11
25 | }
26 |
27 | buildTypes {
28 | named(BuildType.DEBUG) {
29 | isMinifyEnabled = BuildTypeDebug.isMinifyEnabled
30 | // versionNameSuffix = BuildTypeDebug.versionNameSuffix
31 | }
32 | }
33 | }
34 |
35 | dependencies {
36 | implementation(AndroidX.lifeCycleCommon)
37 | implementation(View.appCompat)
38 | implementation(View.materialComponent)
39 | implementation(View.fragment)
40 | implementation(DI.daggerHiltAndroid)
41 | implementation(Network.moshi)
42 | implementation(Coroutines.core)
43 |
44 | kapt(DI.AnnotationProcessor.daggerHilt)
45 | }
46 |
--------------------------------------------------------------------------------
/core/consumer-rules.pro:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ezike/StarWarsSearch-MVI/f091dc9b19b0a4b5b38d27c5331a4d70388f970d/core/consumer-rules.pro
--------------------------------------------------------------------------------
/core/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
--------------------------------------------------------------------------------
/core/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/core/src/main/java/com/ezike/tobenna/starwarssearch/core/AppString.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.core
2 |
3 | import android.content.Context
4 | import android.widget.TextView
5 | import androidx.annotation.StringRes
6 |
7 | sealed interface AppString
8 |
9 | @JvmInline
10 | value class StringLiteral(
11 | val value: String
12 | ) : AppString
13 |
14 | @JvmInline
15 | value class StringResource(
16 | @StringRes val res: Int
17 | ) : AppString
18 |
19 | data class ParamString(
20 | @StringRes val res: Int,
21 | val params: List
22 | ) : AppString {
23 | constructor(
24 | @StringRes res: Int,
25 | vararg param: Any
26 | ) : this(
27 | res = res,
28 | params = param.toList()
29 | )
30 | }
31 |
32 | var TextView.string: AppString
33 | get() = StringLiteral(
34 | value = this.text.toString()
35 | )
36 | set(appString) {
37 | text = string(context, appString)
38 | }
39 |
40 | private fun string(
41 | context: Context,
42 | appString: AppString
43 | ): String = when (appString) {
44 | is ParamString -> context.getString(
45 | appString.res,
46 | *appString.params.toTypedArray()
47 | )
48 | is StringResource -> context.getString(
49 | appString.res
50 | )
51 | is StringLiteral -> appString.value
52 | }
53 |
--------------------------------------------------------------------------------
/core/src/main/java/com/ezike/tobenna/starwarssearch/core/ext/Extensions.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.core.ext
2 |
3 | val Throwable.errorMessage: String
4 | get() = message ?: localizedMessage ?: "An error occurred"
5 |
6 | fun init(initializer: () -> T): Lazy =
7 | lazy(mode = LazyThreadSafetyMode.NONE, initializer)
8 |
--------------------------------------------------------------------------------
/core/src/main/java/com/ezike/tobenna/starwarssearch/core/ext/NavigateBack.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.core.ext
2 |
3 | typealias NavigateBack = () -> Unit
4 |
--------------------------------------------------------------------------------
/core/src/main/java/com/ezike/tobenna/starwarssearch/core/ext/ViewExt.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.core.ext
2 |
3 | import android.content.Context
4 | import android.view.LayoutInflater
5 | import android.view.View
6 | import android.view.ViewGroup
7 | import android.widget.EditText
8 | import androidx.activity.OnBackPressedCallback
9 | import androidx.activity.addCallback
10 | import androidx.fragment.app.Fragment
11 |
12 | fun ViewGroup.inflate(layout: Int): View {
13 | val layoutInflater: LayoutInflater =
14 | context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
15 | return layoutInflater.inflate(layout, this, false)
16 | }
17 |
18 | fun Fragment.onBackPress(onBackPressed: OnBackPressedCallback.() -> Unit) {
19 | requireActivity().onBackPressedDispatcher.addCallback(
20 | viewLifecycleOwner,
21 | onBackPressed = onBackPressed
22 | )
23 | }
24 |
25 | inline var View.show: Boolean
26 | get() = visibility == View.VISIBLE
27 | set(shouldShow) {
28 | visibility = if (shouldShow) View.VISIBLE else View.INVISIBLE
29 | }
30 |
31 | inline val EditText.lazyText: () -> String
32 | get() = { this.text.trim().toString() }
33 |
--------------------------------------------------------------------------------
/core/src/main/res/drawable/ic_error_page_2.xml:
--------------------------------------------------------------------------------
1 |
7 |
9 |
10 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/core/src/main/res/layout/simple_empty_state_view_layout.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
16 |
17 |
25 |
26 |
36 |
37 |
43 |
44 |
45 |
--------------------------------------------------------------------------------
/core/src/main/res/values/attrs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/core/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #48636389
4 | #bdbdbd
5 | #FFBB86FC
6 | #FF6200EE
7 | #FF3700B3
8 | #FF03DAC5
9 | #FF018786
10 | #FF000000
11 | #FFFFFFFF
12 |
13 |
--------------------------------------------------------------------------------
/core/src/main/res/values/dimens.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | 16dp
4 | 16sp
5 | 16dp
6 | 18dp
7 | 12dp
8 | 16dp
9 | 16dp
10 | 18sp
11 | 30dp
12 | 50dp
13 | 8dp
14 | 18sp
15 | 16dp
16 | 150dp
17 | 16dp
18 | 36dp
19 | 2dp
20 | 16dp
21 | 16dp
22 | 20dp
23 | 8dp
24 | 4dp
25 | 24dp
26 | 16dp
27 |
28 |
--------------------------------------------------------------------------------
/core/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | Empty state image
4 | Retry
5 |
6 |
--------------------------------------------------------------------------------
/core/src/main/res/values/styles.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
6 |
7 |
10 |
11 |
18 |
19 |
--------------------------------------------------------------------------------
/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 | android.defaults.buildfeatures.viewbinding=true
23 | # Enable Kapt Incremental annotation processing requeste
24 | kapt.incremental.apt=true
25 | # turn off AP discovery in compile path, and therefore turn on Compile Avoidance
26 | kapt.include.compile.classpath=false
27 | android.databinding.incremental=true
28 | org.gradle.unsafe.configuration-cache=true
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ezike/StarWarsSearch-MVI/f091dc9b19b0a4b5b38d27c5331a4d70388f970d/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | distributionBase=GRADLE_USER_HOME
2 | distributionPath=wrapper/dists
3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-bin.zip
4 | networkTimeout=10000
5 | validateDistributionUrl=true
6 | zipStoreBase=GRADLE_USER_HOME
7 | zipStorePath=wrapper/dists
8 |
--------------------------------------------------------------------------------
/libraries/cache/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/libraries/cache/build.gradle.kts:
--------------------------------------------------------------------------------
1 | import Dependencies.Cache
2 | import Dependencies.DI
3 | import Dependencies.Network
4 | import ProjectLib.cache
5 |
6 | plugins {
7 | androidLibrary
8 | kotlin(kotlinAndroid)
9 | kotlin(kotlinKapt)
10 | daggerHilt
11 | }
12 |
13 | android {
14 | namespace = "com.ezike.tobenna.starwarssearch.cache"
15 | compileSdk = Config.Version.compileSdkVersion
16 | defaultConfig {
17 | minSdk = Config.Version.minSdkVersion
18 | targetSdk = Config.Version.targetSdkVersion
19 |
20 | javaCompileOptions {
21 | annotationProcessorOptions {
22 | arguments += Pair("room.incremental", "true")
23 | }
24 | }
25 | buildFeatures.buildConfig = true
26 | buildConfigField("int", "databaseVersion", 1.toString())
27 | }
28 |
29 | compileOptions {
30 | sourceCompatibility = JavaVersion.VERSION_11
31 | targetCompatibility = JavaVersion.VERSION_11
32 | }
33 |
34 | buildTypes {
35 | named(BuildType.DEBUG) {
36 | isMinifyEnabled = BuildTypeDebug.isMinifyEnabled
37 | }
38 | }
39 | }
40 |
41 | dependencies {
42 | testImplementation(project(cache))
43 |
44 | implementation(DI.daggerHiltAndroid)
45 | implementation(Network.moshi)
46 | implementation(Cache.room)
47 |
48 | kapt(Cache.AnnotationProcessor.room)
49 | kapt(DI.AnnotationProcessor.daggerHilt)
50 | }
51 |
--------------------------------------------------------------------------------
/libraries/cache/consumer-rules.pro:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ezike/StarWarsSearch-MVI/f091dc9b19b0a4b5b38d27c5331a4d70388f970d/libraries/cache/consumer-rules.pro
--------------------------------------------------------------------------------
/libraries/cache/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
--------------------------------------------------------------------------------
/libraries/cache/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/libraries/cache/src/main/java/com/ezike/tobenna/starwarssearch/cache/di/CacheModule.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.cache.di
2 |
3 | import android.content.Context
4 | import com.ezike.tobenna.starwarssearch.cache.room.CharacterDetailDao
5 | import com.ezike.tobenna.starwarssearch.cache.room.SearchHistoryDao
6 | import com.ezike.tobenna.starwarssearch.cache.room.StarWarsDatabase
7 | import dagger.Module
8 | import dagger.Provides
9 | import dagger.hilt.InstallIn
10 | import dagger.hilt.android.qualifiers.ApplicationContext
11 | import dagger.hilt.components.SingletonComponent
12 | import javax.inject.Singleton
13 |
14 | @[Module InstallIn(SingletonComponent::class)]
15 | internal object CacheModule {
16 | @[Provides Singleton]
17 | fun provideDatabase(@ApplicationContext context: Context): StarWarsDatabase {
18 | return StarWarsDatabase.build(context)
19 | }
20 |
21 | @[Provides Singleton]
22 | fun provideSearchHistoryDao(starWarsDatabase: StarWarsDatabase): SearchHistoryDao {
23 | return starWarsDatabase.searchHistoryDao
24 | }
25 |
26 | @[Provides Singleton]
27 | fun provideCharacterDetailDao(starWarsDatabase: StarWarsDatabase): CharacterDetailDao {
28 | return starWarsDatabase.characterDetailDao
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/libraries/cache/src/main/java/com/ezike/tobenna/starwarssearch/cache/mapper/CacheModelMapper.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.cache.mapper
2 |
3 | interface CacheModelMapper {
4 |
5 | fun mapToModel(entity: E): M
6 |
7 | fun mapToEntity(model: M): E
8 | }
9 |
--------------------------------------------------------------------------------
/libraries/cache/src/main/java/com/ezike/tobenna/starwarssearch/cache/model/CharacterCacheModel.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.cache.model
2 |
3 | import androidx.room.Entity
4 | import androidx.room.PrimaryKey
5 |
6 | @Entity(tableName = "SEARCH_HISTORY")
7 | data class CharacterCacheModel(
8 | val name: String,
9 | val birthYear: String,
10 | val height: String,
11 | @PrimaryKey
12 | val url: String
13 | ) {
14 | var lastUpdated: Long = 0L
15 | }
16 |
--------------------------------------------------------------------------------
/libraries/cache/src/main/java/com/ezike/tobenna/starwarssearch/cache/model/CharacterDetailCacheModel.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.cache.model
2 |
3 | import androidx.room.Entity
4 | import androidx.room.PrimaryKey
5 |
6 | @Entity(tableName = "CHARACTER_DETAIL")
7 | data class CharacterDetailCacheModel(
8 | val filmUrls: List,
9 | val planetUrl: String,
10 | val speciesUrls: List,
11 | @PrimaryKey
12 | val url: String
13 | )
14 |
--------------------------------------------------------------------------------
/libraries/cache/src/main/java/com/ezike/tobenna/starwarssearch/cache/room/CharacterDetailDao.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.cache.room
2 |
3 | import androidx.room.Dao
4 | import androidx.room.Insert
5 | import androidx.room.OnConflictStrategy
6 | import androidx.room.Query
7 | import com.ezike.tobenna.starwarssearch.cache.model.CharacterDetailCacheModel
8 |
9 | @Dao
10 | interface CharacterDetailDao {
11 |
12 | @Insert(onConflict = OnConflictStrategy.REPLACE)
13 | suspend fun insertCharacter(characterDetailCacheModel: CharacterDetailCacheModel)
14 |
15 | @Query("SELECT * FROM CHARACTER_DETAIL WHERE url = :characterUrl")
16 | suspend fun fetchCharacter(characterUrl: String): CharacterDetailCacheModel?
17 |
18 | @Query("DELETE FROM CHARACTER_DETAIL")
19 | suspend fun clearData()
20 | }
21 |
--------------------------------------------------------------------------------
/libraries/cache/src/main/java/com/ezike/tobenna/starwarssearch/cache/room/SearchHistoryDao.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.cache.room
2 |
3 | import androidx.room.Dao
4 | import androidx.room.Insert
5 | import androidx.room.OnConflictStrategy
6 | import androidx.room.Query
7 | import com.ezike.tobenna.starwarssearch.cache.model.CharacterCacheModel
8 |
9 | @Dao
10 | interface SearchHistoryDao {
11 |
12 | @Insert(onConflict = OnConflictStrategy.REPLACE)
13 | suspend fun insertSearch(characterCacheModel: CharacterCacheModel)
14 |
15 | @Query("SELECT * FROM SEARCH_HISTORY ORDER BY lastUpdated DESC")
16 | suspend fun recentSearches(): List
17 |
18 | @Query("DELETE FROM SEARCH_HISTORY")
19 | suspend fun clearHistory()
20 | }
21 |
--------------------------------------------------------------------------------
/libraries/cache/src/main/java/com/ezike/tobenna/starwarssearch/cache/room/StarWarsDatabase.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.cache.room
2 |
3 | import android.content.Context
4 | import androidx.room.Database
5 | import androidx.room.Room
6 | import androidx.room.RoomDatabase
7 | import androidx.room.TypeConverters
8 | import com.ezike.tobenna.starwarssearch.cache.BuildConfig
9 | import com.ezike.tobenna.starwarssearch.cache.model.CharacterCacheModel
10 | import com.ezike.tobenna.starwarssearch.cache.model.CharacterDetailCacheModel
11 |
12 | @Database(
13 | entities = [CharacterCacheModel::class, CharacterDetailCacheModel::class],
14 | version = BuildConfig.databaseVersion,
15 | exportSchema = false
16 | )
17 | @TypeConverters(TypeConverter::class)
18 | abstract class StarWarsDatabase : RoomDatabase() {
19 |
20 | abstract val searchHistoryDao: SearchHistoryDao
21 |
22 | abstract val characterDetailDao: CharacterDetailDao
23 |
24 | companion object {
25 | private const val DATABASE_NAME: String = "star_wars_db"
26 | fun build(context: Context): StarWarsDatabase = Room.databaseBuilder(
27 | context.applicationContext,
28 | StarWarsDatabase::class.java,
29 | DATABASE_NAME
30 | ).fallbackToDestructiveMigration().build()
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/libraries/cache/src/main/java/com/ezike/tobenna/starwarssearch/cache/room/TypeConverter.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.cache.room
2 |
3 | import androidx.room.TypeConverter
4 | import com.squareup.moshi.JsonAdapter
5 | import com.squareup.moshi.Moshi
6 | import com.squareup.moshi.Types
7 | import java.lang.reflect.ParameterizedType
8 |
9 | internal class TypeConverter {
10 |
11 | private val moshi: Moshi = Moshi.Builder().build()
12 |
13 | private val adapter: JsonAdapter> by lazy {
14 | val type: ParameterizedType =
15 | Types.newParameterizedType(List::class.java, String::class.java)
16 | moshi.adapter(type)
17 | }
18 |
19 | @TypeConverter
20 | fun toList(value: String): List? {
21 | return adapter.fromJson(value)
22 | }
23 |
24 | @TypeConverter
25 | fun fromList(value: List): String {
26 | return adapter.toJson(value)
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/libraries/remote/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/libraries/remote/build.gradle.kts:
--------------------------------------------------------------------------------
1 | import Dependencies.DI
2 | import Dependencies.Network
3 |
4 | plugins {
5 | kotlinLibrary
6 | kotlin(kotlinKapt)
7 | }
8 |
9 | dependencies {
10 | implementAll(Network.components)
11 | implementation(DI.hiltCore)
12 | kapt(DI.AnnotationProcessor.daggerHilt)
13 | }
14 |
--------------------------------------------------------------------------------
/libraries/remote/src/main/java/com/ezike/tobenna/starwarssearch/remote/RemoteFactory.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.remote
2 |
3 | import com.ezike.tobenna.starwarssearch.remote.interceptor.HttpsInterceptor
4 | import com.ezike.tobenna.starwarssearch.remote.interceptor.NoInternetInterceptor
5 | import com.squareup.moshi.Moshi
6 | import okhttp3.OkHttpClient
7 | import okhttp3.logging.HttpLoggingInterceptor
8 | import retrofit2.Retrofit
9 | import retrofit2.converter.moshi.MoshiConverterFactory
10 | import java.util.concurrent.TimeUnit
11 | import javax.inject.Inject
12 |
13 | internal class RemoteFactory @Inject constructor(private val moshi: Moshi) {
14 |
15 | val retrofit: Retrofit by lazy {
16 | val client: OkHttpClient = makeOkHttpClient(
17 | makeLoggingInterceptor()
18 | )
19 | Retrofit.Builder()
20 | .baseUrl("https://swapi.dev/api/")
21 | .delegatingCallFactory { client }
22 | .addConverterFactory(MoshiConverterFactory.create(moshi))
23 | .build()
24 | }
25 |
26 | private fun makeOkHttpClient(httpLoggingInterceptor: HttpLoggingInterceptor): OkHttpClient {
27 | return OkHttpClient.Builder()
28 | .addInterceptor(HttpsInterceptor)
29 | .addInterceptor(NoInternetInterceptor)
30 | .addInterceptor(httpLoggingInterceptor)
31 | .connectTimeout(10, TimeUnit.SECONDS)
32 | .readTimeout(15, TimeUnit.SECONDS)
33 | .build()
34 | }
35 |
36 | private fun makeLoggingInterceptor(): HttpLoggingInterceptor =
37 | HttpLoggingInterceptor().apply {
38 | level = HttpLoggingInterceptor.Level.BODY
39 | }
40 |
41 | @Suppress("NOTHING_TO_INLINE")
42 | private inline fun Retrofit.Builder.delegatingCallFactory(
43 | delegate: dagger.Lazy
44 | ): Retrofit.Builder = callFactory {
45 | delegate.get().newCall(it)
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/libraries/remote/src/main/java/com/ezike/tobenna/starwarssearch/remote/di/RemoteModule.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.remote.di
2 |
3 | import com.ezike.tobenna.starwarssearch.remote.RemoteFactory
4 | import com.squareup.moshi.Moshi
5 | import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
6 | import dagger.Module
7 | import dagger.Provides
8 | import dagger.hilt.InstallIn
9 | import dagger.hilt.components.SingletonComponent
10 | import retrofit2.Retrofit
11 | import javax.inject.Singleton
12 |
13 | @Module
14 | @InstallIn(SingletonComponent::class)
15 | internal object RemoteModule {
16 |
17 | val provideMoshi: Moshi
18 | @[Provides Singleton] get() = Moshi.Builder()
19 | .add(KotlinJsonAdapterFactory()).build()
20 |
21 | @Provides
22 | fun provideRetrofit(
23 | remoteFactory: RemoteFactory
24 | ): Retrofit = remoteFactory.retrofit
25 | }
26 |
--------------------------------------------------------------------------------
/libraries/remote/src/main/java/com/ezike/tobenna/starwarssearch/remote/interceptor/HttpsInterceptor.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.remote.interceptor
2 |
3 | import okhttp3.HttpUrl
4 | import okhttp3.Interceptor
5 | import okhttp3.Request
6 | import okhttp3.Response
7 |
8 | internal object HttpsInterceptor : Interceptor {
9 |
10 | override fun intercept(chain: Interceptor.Chain): Response {
11 | val request: Request = chain.request()
12 | val requestBuilder: Request.Builder = request.newBuilder()
13 |
14 | if (!request.url.isHttps) {
15 | val newUrl: HttpUrl = request.url.newBuilder()
16 | .scheme("https")
17 | .host(request.url.host)
18 | .build()
19 | requestBuilder.url(newUrl)
20 | }
21 | val newRequest: Request = requestBuilder.build()
22 | val response: Response?
23 |
24 | try {
25 | response = chain.proceed(newRequest)
26 | } catch (e: Exception) {
27 | e.printStackTrace()
28 | throw e
29 | }
30 | return response
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/libraries/remote/src/main/java/com/ezike/tobenna/starwarssearch/remote/interceptor/NoInternetInterceptor.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.remote.interceptor
2 |
3 | import okhttp3.Interceptor
4 | import okhttp3.Response
5 | import java.io.IOException
6 | import java.net.ConnectException
7 | import java.net.SocketTimeoutException
8 | import java.net.UnknownHostException
9 | import kotlin.reflect.KClass
10 |
11 | internal object NoInternetInterceptor : Interceptor {
12 |
13 | override fun intercept(chain: Interceptor.Chain): Response {
14 |
15 | val serverResponse: Response
16 | try {
17 | serverResponse = chain.proceed(chain.request())
18 | } catch (e: Exception) {
19 | throw noNetworkException(e)
20 | }
21 |
22 | return serverResponse
23 | }
24 |
25 | private fun noNetworkException(exception: Throwable): Throwable {
26 | val networkExceptions: List> =
27 | listOf(
28 | SocketTimeoutException::class,
29 | ConnectException::class,
30 | UnknownHostException::class
31 | )
32 | return if (exception::class in networkExceptions) {
33 | IOException("Please check your internet connection and retry")
34 | } else exception
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/libraries/remote/src/main/java/com/ezike/tobenna/starwarssearch/remote/mapper/RemoteModelMapper.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.remote.mapper
2 |
3 | public interface RemoteModelMapper {
4 |
5 | public fun mapFromModel(model: M): E
6 |
7 | public fun mapModelList(models: List): List {
8 | return models.mapTo(mutableListOf(), ::mapFromModel)
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/libraries/testUtils/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/libraries/testUtils/build.gradle.kts:
--------------------------------------------------------------------------------
1 | import Dependencies.Test
2 |
3 | plugins {
4 | kotlinLibrary
5 | }
6 |
7 | dependencies {
8 | api(Test.junit)
9 | api(Test.truth)
10 | api(Test.coroutinesTest)
11 | }
12 |
--------------------------------------------------------------------------------
/libraries/testUtils/src/main/java/com/ezike/tobenna/starwarssearch/testutils/Extensions.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.testutils
2 |
3 | import com.google.common.truth.IterableSubject
4 | import kotlinx.coroutines.Deferred
5 | import kotlinx.coroutines.async
6 | import kotlinx.coroutines.test.TestCoroutineScope
7 | import org.junit.Assert
8 | import org.junit.function.ThrowingRunnable
9 |
10 | public inline fun TestCoroutineScope.assertThrows(
11 | crossinline runnable: suspend () -> Unit
12 | ): T {
13 | val throwingRunnable = ThrowingRunnable {
14 | val job: Deferred = async { runnable() }
15 | job.getCompletionExceptionOrNull()?.run { throw this }
16 | job.cancel()
17 | }
18 | return Assert.assertThrows(T::class.java, throwingRunnable)
19 | }
20 |
21 | public inline fun IterableSubject.containsElements(vararg instance: T) {
22 | containsExactlyElementsIn(instance).inOrder()
23 | }
24 |
--------------------------------------------------------------------------------
/libraries/testUtils/src/main/java/com/ezike/tobenna/starwarssearch/testutils/FlowRecorder.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.testutils
2 |
3 | import kotlinx.coroutines.CoroutineScope
4 | import kotlinx.coroutines.flow.Flow
5 | import kotlinx.coroutines.flow.launchIn
6 | import kotlinx.coroutines.flow.onEach
7 |
8 | /**
9 | * source:
10 | * https://github.com/ReactiveCircus/streamlined/blob/main/libraries/coroutines-test-ext
11 | */
12 |
13 | public fun Flow.recordWith(recorder: FlowRecorder) {
14 | onEach { recorder += it }.launchIn(recorder.coroutineScope)
15 | }
16 |
17 | @OptIn(ExperimentalStdlibApi::class)
18 | public class FlowRecorder(internal val coroutineScope: CoroutineScope) {
19 |
20 | private val values: MutableList = mutableListOf()
21 |
22 | internal operator fun plusAssign(t: T) {
23 | values += t
24 | }
25 |
26 | /**
27 | * Takes the first [numberOfValues] recorded values emitted by the [Flow].
28 | */
29 | public fun take(numberOfValues: Int): List {
30 | require(numberOfValues > 0) {
31 | "Least number of values to take is 1."
32 | }
33 | require(numberOfValues <= values.size) {
34 | "Taking $numberOfValues but only ${values.size} value(s) have been recorded."
35 | }
36 | val drainedValues: MutableList = mutableListOf()
37 | while (drainedValues.size < numberOfValues) {
38 | drainedValues += values.removeFirst()
39 | }
40 | return drainedValues
41 | }
42 |
43 | /**
44 | * Takes all recorded values emitted by the [Flow].
45 | */
46 | public fun takeAll(): List {
47 | val drainedValues: List = buildList { addAll(values) }
48 | values.clear()
49 | return drainedValues
50 | }
51 |
52 | /**
53 | * returns the number of items emitted by the [Flow]
54 | */
55 | public val size: Int
56 | get() = values.size
57 |
58 | /**
59 | * Clears all recorded values emitted by the [Flow].
60 | */
61 | public fun reset() {
62 | values.clear()
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/libraries/testUtils/src/main/java/com/ezike/tobenna/starwarssearch/testutils/MainCoroutineRule.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.testutils
2 |
3 | import kotlinx.coroutines.Dispatchers
4 | import kotlinx.coroutines.test.TestDispatcher
5 | import kotlinx.coroutines.test.UnconfinedTestDispatcher
6 | import kotlinx.coroutines.test.resetMain
7 | import kotlinx.coroutines.test.setMain
8 | import org.junit.rules.TestWatcher
9 | import org.junit.runner.Description
10 |
11 | public class MainCoroutineRule(
12 | private val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
13 | ) : TestWatcher() {
14 | override fun starting(description: Description) {
15 | Dispatchers.setMain(testDispatcher)
16 | }
17 |
18 | override fun finished(description: Description) {
19 | Dispatchers.resetMain()
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/libraries/testUtils/src/main/java/com/ezike/tobenna/starwarssearch/testutils/ResponseType.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.testutils
2 |
3 | public enum class ResponseType {
4 | DATA,
5 | EMPTY,
6 | ERROR
7 | }
8 |
9 | public const val ERROR_MSG: String = "No network"
10 |
--------------------------------------------------------------------------------
/navigation/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/navigation/build.gradle.kts:
--------------------------------------------------------------------------------
1 | import Dependencies.AndroidX
2 | import Dependencies.DI
3 | import Dependencies.View
4 | import ProjectLib.characterDetail
5 | import ProjectLib.characterSearch
6 | import ProjectLib.core
7 |
8 | plugins {
9 | androidLibrary
10 | kotlin(kotlinAndroid)
11 | parcelize
12 | kotlin(kotlinKapt)
13 | safeArgs
14 | daggerHilt
15 | }
16 |
17 | android {
18 | namespace = "com.ezike.tobenna.starwarssearch.navigation"
19 | defaultConfig {
20 | compileSdk = Config.Version.compileSdkVersion
21 | minSdk = Config.Version.minSdkVersion
22 | targetSdk = Config.Version.targetSdkVersion
23 | testInstrumentationRunner = Config.Android.testInstrumentationRunner
24 | }
25 |
26 | compileOptions {
27 | sourceCompatibility = JavaVersion.VERSION_11
28 | targetCompatibility = JavaVersion.VERSION_11
29 | }
30 |
31 | buildTypes {
32 | named(BuildType.DEBUG) {
33 | isMinifyEnabled = BuildTypeDebug.isMinifyEnabled
34 | // versionNameSuffix(BuildTypeDebug.versionNameSuffix)
35 | }
36 | }
37 |
38 | packagingOptions {
39 | exclude("META-INF/DEPENDENCIES")
40 | exclude("META-INF/LICENSE")
41 | exclude("META-INF/LICENSE.txt")
42 | exclude("META-INF/license.txt")
43 | exclude("META-INF/NOTICE")
44 | exclude("META-INF/NOTICE.txt")
45 | exclude("META-INF/notice.txt")
46 | exclude("META-INF/AL2.0")
47 | exclude("META-INF/LGPL2.1")
48 | exclude("META-INF/*.kotlin_module")
49 | }
50 | }
51 |
52 | dependencies {
53 | implementation(project(core))
54 | implementation(project(characterSearch))
55 | implementation(project(characterDetail))
56 |
57 | implementation(DI.daggerHiltAndroid)
58 | implementation(View.fragment)
59 | implementation(AndroidX.navigationFragmentKtx)
60 |
61 | kapt(DI.AnnotationProcessor.daggerHilt)
62 | }
63 |
--------------------------------------------------------------------------------
/navigation/consumer-rules.pro:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ezike/StarWarsSearch-MVI/f091dc9b19b0a4b5b38d27c5331a4d70388f970d/navigation/consumer-rules.pro
--------------------------------------------------------------------------------
/navigation/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
--------------------------------------------------------------------------------
/navigation/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/navigation/src/main/java/com/ezike/tobenna/starwarssearch/navigation/SearchScreenNavigator.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.navigation
2 |
3 | import androidx.navigation.NavController
4 | import com.ezike.tobenna.starwarssearch.characterdetail.model.CharacterDetailModel
5 | import com.ezike.tobenna.starwarssearch.charactersearch.model.CharacterModel
6 | import com.ezike.tobenna.starwarssearch.charactersearch.navigation.Navigator
7 | import javax.inject.Inject
8 |
9 | internal class SearchScreenNavigator @Inject constructor(
10 | private val navController: NavController
11 | ) : Navigator {
12 |
13 | override fun openCharacterDetail(model: CharacterModel) {
14 | navController.navigate(
15 | NavigationRootDirections.openDetail(
16 | character = model.toDetail()
17 | )
18 | )
19 | }
20 | }
21 |
22 | internal fun CharacterModel.toDetail() =
23 | CharacterDetailModel(
24 | name = name,
25 | birthYear = birthYear,
26 | heightCm = heightCm,
27 | url = url
28 | )
29 |
--------------------------------------------------------------------------------
/navigation/src/main/java/com/ezike/tobenna/starwarssearch/navigation/di/NavigationModule.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.navigation.di
2 |
3 | import androidx.navigation.NavController
4 | import com.ezike.tobenna.starwarssearch.charactersearch.navigation.Navigator
5 | import com.ezike.tobenna.starwarssearch.core.ext.NavigateBack
6 | import com.ezike.tobenna.starwarssearch.navigation.SearchScreenNavigator
7 | import dagger.Binds
8 | import dagger.Module
9 | import dagger.Provides
10 | import dagger.hilt.InstallIn
11 | import dagger.hilt.android.components.FragmentComponent
12 |
13 | @InstallIn(FragmentComponent::class)
14 | @Module
15 | internal interface NavigationModule {
16 |
17 | @get:Binds
18 | val SearchScreenNavigator.searchScreenNavigator: Navigator
19 |
20 | companion object {
21 | @Provides
22 | fun provideBackNav(
23 | navController: NavController
24 | ): NavigateBack = {
25 | navController.navigateUp()
26 | }
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/navigation/src/main/res/navigation/navigation_root.xml:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
10 |
11 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/presentation-android/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/presentation-android/build.gradle.kts:
--------------------------------------------------------------------------------
1 | import Dependencies.AndroidX
2 | import Dependencies.View
3 | import ProjectLib.presentation
4 |
5 | plugins {
6 | androidLibrary
7 | kotlin(kotlinAndroid)
8 | }
9 |
10 | android {
11 | namespace = "com.ezike.tobenna.starwarssearch.presentation_android"
12 | compileSdkVersion(Config.Version.compileSdkVersion)
13 | defaultConfig {
14 | minSdkVersion(Config.Version.minSdkVersion)
15 | targetSdkVersion(Config.Version.targetSdkVersion)
16 | }
17 |
18 | compileOptions {
19 | sourceCompatibility = JavaVersion.VERSION_11
20 | targetCompatibility = JavaVersion.VERSION_11
21 | }
22 |
23 | buildTypes {
24 | named(BuildType.DEBUG) {
25 | isMinifyEnabled = BuildTypeDebug.isMinifyEnabled
26 | // versionNameSuffix = BuildTypeDebug.versionNameSuffix
27 | }
28 | }
29 | }
30 |
31 | dependencies {
32 | implementation(project(presentation))
33 | implementation(AndroidX.viewModel)
34 | implementation(AndroidX.lifeCycleCommon)
35 | implementation(View.fragment)
36 | }
37 |
--------------------------------------------------------------------------------
/presentation-android/consumer-rules.pro:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ezike/StarWarsSearch-MVI/f091dc9b19b0a4b5b38d27c5331a4d70388f970d/presentation-android/consumer-rules.pro
--------------------------------------------------------------------------------
/presentation-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
--------------------------------------------------------------------------------
/presentation-android/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/presentation-android/src/main/java/com/ezike/tobenna/starwarssearch/presentation_android/AssistedCreator.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.presentation_android
2 |
3 | import androidx.lifecycle.ViewModel
4 | import androidx.lifecycle.ViewModelProvider
5 |
6 | interface AssistedCreator {
7 | operator fun invoke(data: D): T
8 | }
9 |
10 | fun assistedFactory(
11 | creator: AssistedCreator,
12 | data: D
13 | ) = object : ViewModelProvider.Factory {
14 | @Suppress("UNCHECKED_CAST")
15 | override fun create(modelClass: Class): T {
16 | val clazz: ViewModel = creator(data)
17 | if (!modelClass.isAssignableFrom(clazz::class.java))
18 | throw IllegalArgumentException("Unknown model class $modelClass")
19 | try {
20 | return clazz as T
21 | } catch (e: Exception) {
22 | throw RuntimeException(e)
23 | }
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/presentation-android/src/main/java/com/ezike/tobenna/starwarssearch/presentation_android/ComponentManager.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.presentation_android
2 |
3 | import androidx.annotation.MainThread
4 | import androidx.lifecycle.LifecycleOwner
5 | import androidx.lifecycle.ViewModel
6 | import com.ezike.tobenna.starwarssearch.presentation.base.BaseComponentManager
7 | import com.ezike.tobenna.starwarssearch.presentation.base.NoOpTransform
8 | import com.ezike.tobenna.starwarssearch.presentation.base.ScreenState
9 | import com.ezike.tobenna.starwarssearch.presentation.base.StateTransform
10 | import com.ezike.tobenna.starwarssearch.presentation.base.Subscriber
11 | import com.ezike.tobenna.starwarssearch.presentation.base.ViewResult
12 | import com.ezike.tobenna.starwarssearch.presentation.base.ViewState
13 | import com.ezike.tobenna.starwarssearch.presentation.stateMachine.StateMachine
14 |
15 | @MainThread
16 | abstract class ComponentManager(
17 | private val stateMachine: StateMachine
18 | ) : ViewModel(), BaseComponentManager {
19 |
20 | override fun subscribe(
21 | component: Subscriber,
22 | stateTransform: StateTransform
23 | ) {
24 | stateMachine.subscribe(component, stateTransform)
25 | }
26 |
27 | override fun subscribe(component: Subscriber) {
28 | stateMachine.subscribe(component, NoOpTransform())
29 | }
30 |
31 | fun disposeAll(owner: LifecycleOwner) {
32 | dispose(
33 | lifecycleOwner = owner,
34 | action = stateMachine::unSubscribeComponents
35 | )
36 | }
37 |
38 | override fun onCleared() {
39 | stateMachine.unSubscribe()
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/presentation-android/src/main/java/com/ezike/tobenna/starwarssearch/presentation_android/Disposer.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.presentation_android
2 |
3 | import androidx.lifecycle.DefaultLifecycleObserver
4 | import androidx.lifecycle.LifecycleOwner
5 |
6 | internal inline fun dispose(
7 | lifecycleOwner: LifecycleOwner,
8 | crossinline action: () -> Unit
9 | ) {
10 | object : DefaultLifecycleObserver {
11 | init {
12 | lifecycleOwner.lifecycle.removeObserver(this)
13 | lifecycleOwner.lifecycle.addObserver(this)
14 | }
15 |
16 | override fun onDestroy(owner: LifecycleOwner) {
17 | action()
18 | owner.lifecycle.removeObserver(this)
19 | super.onDestroy(owner)
20 | }
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/presentation-android/src/main/java/com/ezike/tobenna/starwarssearch/presentation_android/UIComponent.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.presentation_android
2 |
3 | import androidx.annotation.UiThread
4 | import com.ezike.tobenna.starwarssearch.presentation.base.DispatchIntent
5 | import com.ezike.tobenna.starwarssearch.presentation.base.Subscriber
6 | import com.ezike.tobenna.starwarssearch.presentation.base.ViewIntent
7 | import com.ezike.tobenna.starwarssearch.presentation.base.ViewState
8 |
9 | /**
10 | * Represents a basic UI component that can be part of a screen
11 | */
12 | abstract class UIComponent : Subscriber {
13 |
14 | @UiThread
15 | protected abstract fun render(state: ComponentState)
16 |
17 | @UiThread
18 | protected fun sendIntent(intent: ViewIntent) {
19 | dispatchIntent.invoke(intent)
20 | }
21 |
22 | override var dispatchIntent: DispatchIntent = NoOpIntentDispatcher
23 |
24 | override fun onNewState(state: ComponentState) {
25 | render(state)
26 | }
27 | }
28 |
29 | private val NoOpIntentDispatcher: DispatchIntent
30 | get() = {}
31 |
32 | abstract class StatelessUIComponent : UIComponent() {
33 | override fun render(state: ViewState) {}
34 | }
35 |
--------------------------------------------------------------------------------
/presentation/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/presentation/build.gradle.kts:
--------------------------------------------------------------------------------
1 | import Dependencies.Test
2 |
3 | plugins {
4 | kotlinLibrary
5 | }
6 |
7 | dependencies {
8 | testImplementation(Test.junit)
9 | testImplementation(Test.truth)
10 | }
11 |
--------------------------------------------------------------------------------
/presentation/src/main/java/com/ezike/tobenna/starwarssearch/presentation/base/BaseComponentManager.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.presentation.base
2 |
3 | public interface BaseComponentManager {
4 | public fun subscribe(
5 | component: Subscriber,
6 | stateTransform: StateTransform
7 | )
8 | public fun subscribe(
9 | component: Subscriber
10 | )
11 | }
12 |
--------------------------------------------------------------------------------
/presentation/src/main/java/com/ezike/tobenna/starwarssearch/presentation/base/IntentProcessor.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.presentation.base
2 |
3 | import kotlinx.coroutines.flow.Flow
4 |
5 | public interface IntentProcessor {
6 | public fun intentToResult(viewIntent: ViewIntent): Flow
7 | }
8 |
9 | public class InvalidViewIntentException(
10 | private val viewIntent: ViewIntent
11 | ) : IllegalArgumentException() {
12 | override val message: String
13 | get() = "Invalid intent $viewIntent"
14 | }
15 |
--------------------------------------------------------------------------------
/presentation/src/main/java/com/ezike/tobenna/starwarssearch/presentation/base/StateReducer.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.presentation.base
2 |
3 | public interface StateReducer {
4 | public fun reduce(oldState: S, result: R): S
5 | }
6 |
--------------------------------------------------------------------------------
/presentation/src/main/java/com/ezike/tobenna/starwarssearch/presentation/base/Subscriber.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.presentation.base
2 |
3 | /**
4 | * Represents an object that wants to subscribe to state updates from the [StateMachine]
5 | */
6 | public interface Subscriber {
7 | public fun onNewState(state: State)
8 | public var dispatchIntent: DispatchIntent
9 | }
10 |
--------------------------------------------------------------------------------
/presentation/src/main/java/com/ezike/tobenna/starwarssearch/presentation/base/ViewIntent.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.presentation.base
2 |
3 | public interface ViewIntent
4 | internal object NoOpIntent : ViewIntent
5 |
6 | public typealias DispatchIntent = (ViewIntent) -> Unit
7 |
--------------------------------------------------------------------------------
/presentation/src/main/java/com/ezike/tobenna/starwarssearch/presentation/base/ViewResult.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.presentation.base
2 |
3 | public interface ViewResult
4 |
--------------------------------------------------------------------------------
/presentation/src/main/java/com/ezike/tobenna/starwarssearch/presentation/base/ViewState.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.presentation.base
2 |
3 | /**
4 | * Represents the state of a screen that may comprise of several [UIComponent]
5 | */
6 | public interface ScreenState
7 |
8 | /**
9 | * Represents the state of a [UIComponent]
10 | */
11 | public interface ViewState
12 | private object NoOpViewState : ViewState
13 |
14 | /**
15 | * Function to map from a [ScreenState] to [ViewState]
16 | */
17 | public typealias StateTransform = (S) -> V
18 |
19 | @Suppress("UNCHECKED_CAST", "FunctionName")
20 | public fun NoOpTransform(): StateTransform =
21 | { NoOpViewState as V }
22 |
--------------------------------------------------------------------------------
/presentation/src/main/java/com/ezike/tobenna/starwarssearch/presentation/mapper/ModelMapper.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.presentation.mapper
2 |
3 | public interface ModelMapper {
4 |
5 | public fun mapToModel(domain: D): M
6 | public fun mapToDomain(model: M): D
7 |
8 | public fun mapToModelList(domainList: List): List {
9 | return domainList.mapTo(mutableListOf(), ::mapToModel)
10 | }
11 |
12 | public fun mapToDomainList(modelList: List): List {
13 | return modelList.mapTo(mutableListOf(), ::mapToDomain)
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/presentation/src/main/java/com/ezike/tobenna/starwarssearch/presentation/stateMachine/RenderStrategy.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.presentation.stateMachine
2 |
3 | import kotlinx.coroutines.flow.Flow
4 | import kotlinx.coroutines.flow.flatMapLatest
5 | import kotlinx.coroutines.flow.flatMapMerge
6 |
7 | /**
8 | * This sets whether the object consuming state from the [StateMachine] will get
9 | * intermediate state updates or just the latest state (intermediate values are dropped)
10 | **/
11 |
12 | public sealed interface RenderStrategy {
13 | public object Latest : RenderStrategy
14 | public object Intermediate : RenderStrategy
15 | }
16 |
17 | internal inline fun Flow.renderWith(
18 | config: RenderStrategy,
19 | crossinline transform: suspend (T) -> Flow
20 | ): Flow = when (config) {
21 | RenderStrategy.Latest -> flatMapLatest(transform = transform)
22 | RenderStrategy.Intermediate -> flatMapMerge { transform(it) }
23 | }
24 |
--------------------------------------------------------------------------------
/presentation/src/main/java/com/ezike/tobenna/starwarssearch/presentation/stateMachine/Subscription.kt:
--------------------------------------------------------------------------------
1 | package com.ezike.tobenna.starwarssearch.presentation.stateMachine
2 |
3 | import com.ezike.tobenna.starwarssearch.presentation.base.DispatchIntent
4 | import com.ezike.tobenna.starwarssearch.presentation.base.ScreenState
5 | import com.ezike.tobenna.starwarssearch.presentation.base.StateTransform
6 | import com.ezike.tobenna.starwarssearch.presentation.base.Subscriber
7 | import com.ezike.tobenna.starwarssearch.presentation.base.ViewState
8 |
9 | internal class Subscription(
10 | private var subscriber: Subscriber?,
11 | private val transform: StateTransform
12 | ) {
13 |
14 | private var oldState: V? = null
15 |
16 | fun updateState(state: S) {
17 | val newState: V = transform(state)
18 | if (oldState == null || oldState != newState) {
19 | oldState = newState
20 | subscriber?.onNewState(newState)
21 | }
22 | }
23 |
24 | fun onIntentDispatch(dispatchIntent: DispatchIntent) {
25 | subscriber?.dispatchIntent = dispatchIntent
26 | }
27 |
28 | fun dispose() {
29 | subscriber = null
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | rootProject.name = "Star wars search"
2 | include(
3 | ":app",
4 | ":libraries:remote",
5 | ":core",
6 | ":presentation",
7 | ":character_search",
8 | ":libraries:cache",
9 | ":libraries:testUtils",
10 | ":presentation-android",
11 | ":character-detail",
12 | ":navigation"
13 | )
14 |
--------------------------------------------------------------------------------
/setup.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | ./.scripts/install_ktlint.sh $1
3 |
--------------------------------------------------------------------------------