├── .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 | --------------------------------------------------------------------------------