├── .editorconfig ├── .github └── workflows │ └── android.yml ├── .gitignore ├── README.md ├── app ├── .gitignore ├── build.gradle ├── multidex-config.pro ├── proguard-rules.pro └── src │ ├── debug │ └── res │ │ └── values │ │ └── strings.xml │ └── main │ ├── AndroidManifest.xml │ ├── ic_launcher-playstore.png │ ├── kotlin │ └── tm │ │ └── alashow │ │ └── rickmorty │ │ ├── App.kt │ │ ├── Config.kt │ │ ├── di │ │ ├── AppModule.kt │ │ └── NetworkModule.kt │ │ └── ui │ │ ├── AppNavigation.kt │ │ ├── MainActivity.kt │ │ ├── RickAndMortyApp.kt │ │ ├── home │ │ ├── Home.kt │ │ ├── HomeBottomNavigation.kt │ │ ├── HomeNavigationItem.kt │ │ ├── HomeNavigationItemIcon.kt │ │ └── HomeNavigationItems.kt │ │ └── snackbar │ │ ├── SnackbarListenerViewModel.kt │ │ └── SnackbarMessagesListener.kt │ ├── res-drawable │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ ├── mipmap-hdpi │ │ └── ic_launcher_foreground.png │ ├── mipmap-mdpi │ │ └── ic_launcher_foreground.png │ ├── mipmap-xhdpi │ │ └── ic_launcher_foreground.png │ ├── mipmap-xxhdpi │ │ └── ic_launcher_foreground.png │ ├── mipmap-xxxhdpi │ │ └── ic_launcher_foreground.png │ └── values │ │ └── ic_launcher_background.xml │ └── res │ ├── values-night │ └── themes.xml │ └── values │ ├── colors.xml │ └── themes.xml ├── art ├── project-dependency-graph.png ├── rick-face.psd └── screenshots │ └── phone │ ├── 1.png │ ├── 2.png │ ├── 3.png │ └── 4.png ├── build.gradle ├── buildSrc ├── build.gradle.kts ├── settings.gradle └── src │ └── main │ └── java │ └── tm │ └── alashow │ └── buildSrc │ ├── App.kt │ └── Deps.kt ├── gradle.properties ├── gradle ├── .run │ └── Tests in 'tm.alashow'.run.xml ├── projectDependencyGraph.gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── modules ├── base-android │ ├── build.gradle │ └── src │ │ └── main │ │ ├── AndroidManifest.xml │ │ ├── java │ │ └── tm │ │ │ └── alashow │ │ │ └── base │ │ │ ├── BaseApp.kt │ │ │ ├── imageloading │ │ │ ├── CoilAppInitializer.kt │ │ │ ├── ImageLoading.kt │ │ │ └── ImageLoadingModule.kt │ │ │ ├── inititializer │ │ │ ├── AppInitializers.kt │ │ │ ├── ThreeTenAbpInitializer.kt │ │ │ └── TimberInitializer.kt │ │ │ ├── ui │ │ │ ├── SnackbarManager.kt │ │ │ └── ThemeState.kt │ │ │ └── util │ │ │ ├── DateUtil.kt │ │ │ ├── ThrowableExtensions.kt │ │ │ └── UiMessage.kt │ │ └── res │ │ ├── values-night │ │ ├── colors.xml │ │ ├── environment.xml │ │ └── system_ui.xml │ │ ├── values-notnight-v27 │ │ └── sys_ui.xml │ │ └── values │ │ ├── colors.xml │ │ ├── donottranslate.xml │ │ ├── environment.xml │ │ └── system_ui.xml ├── base │ ├── build.gradle │ └── src │ │ └── main │ │ ├── AndroidManifest.xml │ │ └── java │ │ └── tm │ │ └── alashow │ │ └── base │ │ ├── di │ │ ├── BaseModule.kt │ │ └── TestAppModule.kt │ │ └── util │ │ ├── CoroutineDispatchers.kt │ │ ├── date │ │ ├── ApiDateUtil.kt │ │ ├── CalendarRange.kt │ │ ├── CalendarUtils.kt │ │ ├── DateTimeUtils.kt │ │ └── LocalDateRange.kt │ │ ├── extensions │ │ ├── CoroutineExtensions.kt │ │ └── StateFlowExtensions.kt │ │ └── serializer │ │ └── LocalDateTimeSerializer.kt ├── common-compose │ ├── build.gradle │ └── src │ │ └── main │ │ ├── AndroidManifest.xml │ │ └── java │ │ └── tm │ │ └── alashow │ │ └── common │ │ └── compose │ │ ├── CompositionLocals.kt │ │ ├── Debug.kt │ │ ├── Flow.kt │ │ └── NavigationExtensions.kt ├── common-data │ ├── build.gradle │ └── src │ │ └── main │ │ ├── AndroidManifest.xml │ │ └── java │ │ └── tm │ │ └── alashow │ │ └── data │ │ ├── Interactor.kt │ │ ├── LastRequests.kt │ │ ├── PaginatedEntryRemoteMediator.kt │ │ ├── PreferencesStore.kt │ │ ├── RetrofitExtensions.kt │ │ ├── StoreExtensions.kt │ │ └── db │ │ ├── EntityDaos.kt │ │ ├── NukeDatabase.kt │ │ ├── RoomRepo.kt │ │ └── RoomTransactionRunner.kt ├── common-domain │ ├── build.gradle │ └── src │ │ └── main │ │ ├── AndroidManifest.xml │ │ └── java │ │ └── tm │ │ └── alashow │ │ └── domain │ │ ├── extensions │ │ ├── Extensions.kt │ │ └── StringExtensions.kt │ │ └── models │ │ ├── Async.kt │ │ ├── BaseEntity.kt │ │ ├── BaseTypeConverters.kt │ │ ├── InvokeStatus.kt │ │ ├── Json.kt │ │ ├── Optional.kt │ │ ├── Params.kt │ │ └── SortOption.kt ├── common-testing │ ├── build.gradle │ └── src │ │ └── main │ │ ├── AndroidManifest.xml │ │ └── java │ │ └── tm │ │ └── alashow │ │ └── base │ │ └── testing │ │ ├── BaseTest.kt │ │ ├── TestImageModule.kt │ │ └── TurbineExtensions.kt ├── common-ui-components │ ├── build.gradle │ └── src │ │ └── main │ │ ├── AndroidManifest.xml │ │ ├── java │ │ └── tm │ │ │ └── alashow │ │ │ └── ui │ │ │ ├── Clickable.kt │ │ │ ├── Dismissable.kt │ │ │ ├── DismissableSnackbar.kt │ │ │ ├── Lists.kt │ │ │ ├── TimedVisibility.kt │ │ │ └── components │ │ │ ├── AppBars.kt │ │ │ ├── Button.kt │ │ │ ├── CoverImage.kt │ │ │ ├── DropdownMenu.kt │ │ │ ├── Error.kt │ │ │ ├── IconButton.kt │ │ │ ├── Placeholder.kt │ │ │ └── ProgressIndicator.kt │ │ └── res │ │ └── drawable │ │ └── morty_face.png ├── common-ui-theme │ ├── build.gradle │ └── src │ │ └── main │ │ ├── AndroidManifest.xml │ │ ├── java │ │ └── tm │ │ │ └── alashow │ │ │ └── ui │ │ │ ├── ThemeViewModel.kt │ │ │ └── theme │ │ │ ├── AppBarAlphas.kt │ │ │ ├── AppTheme.kt │ │ │ ├── Color.kt │ │ │ ├── Shape.kt │ │ │ ├── Specs.kt │ │ │ ├── Styles.kt │ │ │ ├── Theme.kt │ │ │ └── Type.kt │ │ └── res │ │ └── font │ │ ├── circular_black.otf │ │ ├── circular_bold.otf │ │ ├── circular_regular.otf │ │ └── montserrat_light.ttf ├── core-characters │ ├── build.gradle │ └── src │ │ ├── main │ │ ├── AndroidManifest.xml │ │ └── java │ │ │ └── tm │ │ │ └── alashow │ │ │ └── rickmorty │ │ │ └── data │ │ │ ├── interactors │ │ │ └── character │ │ │ │ ├── GetCharacterDetails.kt │ │ │ │ ├── GetCharacterWithLocationResidents.kt │ │ │ │ ├── GetCharacters.kt │ │ │ │ ├── GetCharactersByUrls.kt │ │ │ │ └── GetCharactersPagingSourceWithFilters.kt │ │ │ ├── observers │ │ │ └── character │ │ │ │ ├── ObserveCharacter.kt │ │ │ │ ├── ObserveCharacterDetails.kt │ │ │ │ ├── ObserveCharactersFilterOptions.kt │ │ │ │ └── ObservePagedCharacters.kt │ │ │ └── repos │ │ │ └── character │ │ │ ├── CharacaterStores.kt │ │ │ ├── CharacterDetailDataSource.kt │ │ │ └── CharactersDataSource.kt │ │ └── test │ │ └── kotlin │ │ └── tm │ │ └── alashow │ │ └── rickmorty │ │ └── data │ │ ├── TestModule.kt │ │ ├── interactors │ │ ├── GetCharacterDetailsTest.kt │ │ └── GetCharactersTest.kt │ │ └── observers │ │ ├── ObserveCharacterDetailsTest.kt │ │ └── ObserveCharacterTest.kt ├── core-data │ ├── build.gradle │ ├── schemas │ │ └── tm.alashow.rickmorty.data.db.AppDatabase │ │ │ └── 1.json │ └── src │ │ ├── main │ │ ├── AndroidManifest.xml │ │ └── java │ │ │ └── tm │ │ │ └── alashow │ │ │ └── rickmorty │ │ │ └── data │ │ │ ├── CharacterParams.kt │ │ │ ├── CharactersParams.kt │ │ │ ├── SampleData.kt │ │ │ ├── SearchParams.kt │ │ │ ├── api │ │ │ ├── ApiModule.kt │ │ │ └── RickAndMortyEndpoints.kt │ │ │ └── db │ │ │ ├── AppDatabase.kt │ │ │ ├── AppTypeConverters.kt │ │ │ ├── DatabaseModule.kt │ │ │ └── daos │ │ │ ├── CharactersDao.kt │ │ │ └── DaosModule.kt │ │ └── test │ │ └── kotlin │ │ └── tm │ │ └── alashow │ │ └── rickmorty │ │ └── data │ │ ├── TestModule.kt │ │ └── db │ │ └── daos │ │ └── CharactersDaoTest.kt ├── core-domain │ ├── build.gradle │ └── src │ │ └── main │ │ ├── AndroidManifest.xml │ │ └── java │ │ └── tm │ │ └── alashow │ │ ├── Config.kt │ │ └── rickmorty │ │ └── domain │ │ ├── Constants.kt │ │ ├── entities │ │ ├── Character.kt │ │ └── Location.kt │ │ └── models │ │ ├── CharactersApiResponse.kt │ │ ├── PaginatedApiResponse.kt │ │ └── errors │ │ ├── ApiErrorException.kt │ │ └── CommonApiExceptions.kt ├── i18n │ ├── .gitignore │ ├── build.gradle │ └── src │ │ └── main │ │ ├── AndroidManifest.xml │ │ ├── java │ │ └── tm │ │ │ └── alashow │ │ │ └── i18n │ │ │ ├── CommonValidationErrors.kt │ │ │ ├── TextCreator.kt │ │ │ ├── UiMessage.kt │ │ │ └── Validation.kt │ │ └── res │ │ └── values │ │ ├── app_strings.xml │ │ ├── base.xml │ │ └── donottranslate.xml ├── ui-characters │ ├── build.gradle │ └── src │ │ └── main │ │ ├── AndroidManifest.xml │ │ └── java │ │ └── tm │ │ └── alashow │ │ └── rickmorty │ │ └── ui │ │ └── character │ │ ├── components │ │ ├── CharacterColumn.kt │ │ ├── CharacterRow.kt │ │ └── CharacterStatusDot.kt │ │ ├── detail │ │ ├── CharacterDetail.kt │ │ ├── CharacterDetailLocation.kt │ │ ├── CharacterDetailRow.kt │ │ ├── CharacterDetailViewModel.kt │ │ └── CharacterDetailViewState.kt │ │ └── list │ │ ├── Characters.kt │ │ ├── CharactersFiltersRow.kt │ │ ├── CharactersViewModel.kt │ │ └── CharactersViewState.kt └── ui-navigation │ ├── build.gradle │ └── src │ └── main │ ├── AndroidManifest.xml │ └── java │ └── tm │ └── alashow │ └── navigation │ ├── BottomSheetNavigator.kt │ ├── NavigationModule.kt │ ├── Navigator.kt │ ├── NavigatorExtensions.kt │ ├── NavigatorViewModel.kt │ └── screens │ └── Screens.kt ├── settings.gradle ├── signing └── alashov-debug.jks └── spotless └── copyright.kt /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{kt,kts}] 2 | disabled_rules = no-wildcard-imports 3 | indent_size = 4 4 | insert_final_newline = true 5 | max_line_length = 150 6 | 7 | -------------------------------------------------------------------------------- /.github/workflows/android.yml: -------------------------------------------------------------------------------- 1 | name: Android CI 2 | on: [push, pull_request] 3 | 4 | concurrency: 5 | group: ci-${{ github.ref }} 6 | cancel-in-progress: true 7 | 8 | jobs: 9 | build: 10 | name: Run Tests & Build apk 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v1 15 | - name: Set up JDK 11 16 | uses: actions/setup-java@v1 17 | with: 18 | java-version: 11 19 | 20 | - name: Setup Android SDK 21 | uses: android-actions/setup-android@v2 22 | 23 | - name: Run Tests & Build apk 24 | uses: burrunan/gradle-cache-action@v1 25 | with: 26 | gradle-dependencies-cache-key: | 27 | buildSrc/**/Deps.kt 28 | concurrent: true 29 | arguments: | 30 | spotlessCheck 31 | testDebug 32 | assembleRelease 33 | 34 | - name: Copy build reports 35 | if: always() 36 | run: | 37 | mkdir -p build-reports 38 | find . -name "reports" -exec cp -r {} build-reports/ \; 39 | - name: Upload build reports 40 | if: always() 41 | uses: actions/upload-artifact@v1 42 | with: 43 | name: build-reports 44 | path: build-reports 45 | 46 | - name: Copy test results 47 | if: always() 48 | run: | 49 | mkdir -p junit 50 | find . -type f -regex ".*/build/test-results/.*xml" -exec cp {} junit/ \; 51 | - name: Upload test results 52 | if: always() 53 | uses: actions/upload-artifact@v1 54 | with: 55 | name: junit-results 56 | path: junit 57 | 58 | - name: Upload app-release 59 | if: always() 60 | uses: actions/upload-artifact@v1 61 | with: 62 | name: app-release.apk 63 | path: app/build/outputs/apk/release/app-release.apk -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # built application files 2 | *.apk 3 | *.ap_ 4 | 5 | # files for the dex VM 6 | *.dex 7 | 8 | # Java class files 9 | *.class 10 | 11 | # generated files 12 | bin/ 13 | gen/ 14 | out/ 15 | build/ 16 | 17 | # Local configuration file (sdk path, etc) 18 | local.properties 19 | 20 | # Eclipse project files 21 | .classpath 22 | .settings 23 | .project 24 | 25 | # Windows thumbnail db 26 | .DS_Store 27 | 28 | # IDEA/Android Studio project files, because 29 | # the project can be imported from settings.gradle 30 | .idea 31 | *.iml 32 | 33 | # Old-style IDEA project files 34 | *.ipr 35 | *.iws 36 | 37 | # Local IDEA workspace 38 | .idea/workspace.xml 39 | 40 | # Gradle cache 41 | .gradle 42 | 43 | # Sandbox stuff 44 | _sandbox 45 | 46 | captures/ 47 | 48 | # Do not commit plain signing files 49 | signing/*-release.jks 50 | signing/play-account.json -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Rick and Morty 2 | 3 | Rick and Morty app using [rickandmortyapi.com](https://rickandmortyapi.com/) 4 | 5 | Current Screens: 6 | - Characters list - paginated list of characters 7 | - Character details - detailed information about a character, including last seen location and origin location's residents 8 | 9 | Libraries used: 10 | - Jetpack Compose UI with Material 2 11 | - Kotlin Coroutines 12 | - Paging 3 library 13 | - Dagger & Hilt for DI 14 | - OkHttp & Retrofit for network 15 | - Coil for image loading 16 | - Room & [Store](https://github.com/dropbox/Store) for offline-first 17 | - JUnit & Robolectric & Mockk 18 | 19 | ### Screenshots 20 | 21 | | | | | 22 | |:-------------------------:|:-------------------------:|:-------------------------:| 23 | |![Image](/art/screenshots/phone/1.png?raw=true) | ![Image](/art/screenshots/phone/2.png?raw=true)| 24 | |![Image](/art/screenshots/phone/3.png?raw=true) | ![Image](/art/screenshots/phone/4.png?raw=true)| 25 | 26 | 27 | # Project dependency graph 28 | 29 | ![Project dependency graph](/art/project-dependency-graph.png?raw=true) 30 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | /release 3 | mapping.txt 4 | -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | 6 | import tm.alashow.buildSrc.App 7 | import tm.alashow.buildSrc.Deps 8 | 9 | plugins { 10 | id "com.android.application" 11 | id "dagger.hilt.android.plugin" 12 | id "kotlin-android" 13 | id "kotlin-kapt" 14 | id "kotlin-parcelize" 15 | id "org.jetbrains.kotlin.plugin.serialization" 16 | id "androidx.navigation.safeargs.kotlin" 17 | } 18 | 19 | 20 | def gitSha = "git rev-parse --short HEAD".execute([], project.rootDir).text.trim() 21 | 22 | android { 23 | compileSdkVersion App.compileSdkVersion 24 | 25 | defaultConfig { 26 | applicationId App.id 27 | targetSdkVersion App.targetSdkVersion 28 | minSdkVersion App.minSdkVersion 29 | versionCode App.versionCode 30 | versionName "${App.versionName}-${gitSha}" 31 | 32 | multiDexEnabled true 33 | vectorDrawables.useSupportLibrary = true 34 | } 35 | 36 | sourceSets { 37 | main.java.srcDirs += "src/main/kotlin" 38 | test.java.srcDirs += "src/test/kotlin" 39 | 40 | main.res.srcDirs += "src/main/res-drawable" 41 | } 42 | 43 | compileOptions { 44 | sourceCompatibility 1.8 45 | targetCompatibility 1.8 46 | } 47 | 48 | lintOptions { 49 | abortOnError false 50 | } 51 | 52 | buildFeatures { 53 | compose = true 54 | } 55 | 56 | composeOptions { 57 | kotlinCompilerExtensionVersion Deps.Android.Compose.compilerVersion 58 | } 59 | 60 | signingConfigs { 61 | debug { 62 | storeFile rootProject.file("signing/alashov-debug.jks") 63 | storePassword "alashov" 64 | keyPassword "alashov" 65 | keyAlias "alashov" 66 | } 67 | 68 | release { 69 | storeFile rootProject.file("signing/alashov-debug.jks") 70 | storePassword "alashov" 71 | keyPassword "alashov" 72 | keyAlias "alashov" 73 | } 74 | } 75 | 76 | buildTypes { 77 | debug { 78 | signingConfig signingConfigs.debug 79 | versionNameSuffix "-DEBUG" 80 | applicationIdSuffix ".debug" 81 | } 82 | 83 | release { 84 | signingConfig signingConfigs.release 85 | minifyEnabled true 86 | shrinkResources true 87 | proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro" 88 | } 89 | } 90 | 91 | kotlinOptions { 92 | jvmTarget = "1.8" 93 | useIR = true 94 | } 95 | } 96 | 97 | repositories { 98 | mavenCentral() 99 | maven { url "https://jitpack.io" } 100 | maven { url "https://oss.jfrog.org/artifactory/oss-snapshot-local/" } 101 | maven { url "https://oss.sonatype.org/content/repositories/snapshots/" } 102 | } 103 | 104 | dependencies { 105 | implementation project(":modules:common-compose") 106 | implementation project(":modules:common-ui-theme") 107 | implementation project(":modules:common-ui-components") 108 | implementation project(":modules:core-domain") 109 | implementation project(":modules:ui-navigation") 110 | implementation project(":modules:ui-characters") 111 | 112 | implementation Deps.Kotlin.coroutinesAndroid 113 | implementation Deps.Utils.proguardSnippets 114 | 115 | kapt Deps.Android.Lifecycle.compiler 116 | 117 | implementation Deps.Dagger.hilt 118 | kapt Deps.Dagger.compiler 119 | kapt Deps.Dagger.hiltCompiler 120 | 121 | implementation Deps.Android.multiDex 122 | } 123 | 124 | apply plugin: "kotlinx-serialization" -------------------------------------------------------------------------------- /app/multidex-config.pro: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alashow/rick-and-morty/976d1ccd21a81a1219852ac6ba6928aa272dab9a/app/multidex-config.pro -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # okhttp 2 | -dontwarn okhttp3.** 3 | -dontwarn okio.** 4 | -dontwarn javax.annotation.** 5 | -dontwarn org.conscrypt.** 6 | -keepnames class okhttp3.internal.publicsuffix.PublicSuffixDatabase 7 | 8 | # serializer 9 | 10 | -keepattributes *Annotation*, InnerClasses 11 | -dontnote kotlinx.serialization.SerializationKt 12 | -keep,includedescriptorclasses class tm.alashow.**$$serializer { *; } 13 | -keepclassmembers class tm.alashow.** { 14 | *** Companion; 15 | } 16 | -keepclasseswithmembers class tm.alashow.** { 17 | kotlinx.serialization.KSerializer serializer(...); 18 | } 19 | 20 | -keep @kotlinx.serialization.Serializer public class * 21 | 22 | -keep class kotlin.Metadata { *; } 23 | 24 | # end serializer 25 | 26 | # web 27 | -keepattributes JavascriptInterface 28 | 29 | -keepclassmembers class * { 30 | @android.webkit.JavascriptInterface ; 31 | } 32 | 33 | # crashlytics recommendations 34 | -keepattributes *Annotation* 35 | -keepattributes LineNumberTable 36 | -keep public class * extends java.lang.Exception 37 | 38 | # fetch 39 | -keep class com.tonyodev.fetch2.** {*;} 40 | -keep class com.tonyodev.fetch2core.** {*;} 41 | -keep interface com.tonyodev.fetch2.** {*;} 42 | -keep interface com.tonyodev.fetch2core.** {*;} 43 | 44 | # project 45 | -keep class tm.alashow.*.domain.entities.** { *; } 46 | -keep class tm.alashow.*.domain.models.** { *; } -------------------------------------------------------------------------------- /app/src/debug/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | [D] Rick and Morty 8 | [D] Rick and Morty 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 22 | 27 | 28 | 29 | 30 | 31 | 32 | 35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /app/src/main/ic_launcher-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alashow/rick-and-morty/976d1ccd21a81a1219852ac6ba6928aa272dab9a/app/src/main/ic_launcher-playstore.png -------------------------------------------------------------------------------- /app/src/main/kotlin/tm/alashow/rickmorty/App.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.rickmorty 6 | 7 | import android.content.Context 8 | import androidx.multidex.MultiDex 9 | import dagger.hilt.android.HiltAndroidApp 10 | import javax.inject.Inject 11 | import tm.alashow.base.BaseApp 12 | import tm.alashow.base.inititializer.AppInitializers 13 | 14 | @HiltAndroidApp 15 | class App : BaseApp() { 16 | 17 | @Inject 18 | lateinit var initializers: AppInitializers 19 | 20 | override fun onCreate() { 21 | super.onCreate() 22 | initializers.init(this) 23 | } 24 | 25 | override fun attachBaseContext(base: Context) { 26 | super.attachBaseContext(base) 27 | MultiDex.install(this) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /app/src/main/kotlin/tm/alashow/rickmorty/Config.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.rickmorty 6 | 7 | object Config { 8 | const val APP_USER_AGENT = "Rick and Morty App/${BuildConfig.VERSION_NAME}-${BuildConfig.VERSION_CODE}" 9 | 10 | val IS_DEBUG = BuildConfig.DEBUG 11 | } 12 | 13 | /** 14 | * Run [block] if app in debug mode. 15 | */ 16 | fun ifDebug(block: () -> Unit) { 17 | if (Config.IS_DEBUG) block() 18 | } 19 | -------------------------------------------------------------------------------- /app/src/main/kotlin/tm/alashow/rickmorty/di/AppModule.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2018, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.rickmorty.di 6 | 7 | import dagger.Module 8 | import dagger.Provides 9 | import dagger.hilt.InstallIn 10 | import dagger.hilt.components.SingletonComponent 11 | import javax.inject.Singleton 12 | import kotlinx.coroutines.Dispatchers 13 | import tm.alashow.base.imageloading.CoilAppInitializer 14 | import tm.alashow.base.inititializer.AppInitializers 15 | import tm.alashow.base.inititializer.ThreeTenAbpInitializer 16 | import tm.alashow.base.inititializer.TimberInitializer 17 | import tm.alashow.base.util.CoroutineDispatchers 18 | 19 | @InstallIn(SingletonComponent::class) 20 | @Module 21 | class AppModule { 22 | 23 | @Singleton 24 | @Provides 25 | fun coroutineDispatchers() = CoroutineDispatchers( 26 | network = Dispatchers.IO, 27 | io = Dispatchers.IO, 28 | computation = Dispatchers.Default, 29 | main = Dispatchers.Main 30 | ) 31 | 32 | @Provides 33 | fun appInitializers( 34 | timberManager: TimberInitializer, 35 | threeTen: ThreeTenAbpInitializer, 36 | coilAppInitializer: CoilAppInitializer, 37 | ): AppInitializers { 38 | return AppInitializers( 39 | timberManager, 40 | threeTen, 41 | coilAppInitializer, 42 | ) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /app/src/main/kotlin/tm/alashow/rickmorty/di/NetworkModule.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2018, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.rickmorty.di 6 | 7 | import android.app.Application 8 | import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory 9 | import dagger.Module 10 | import dagger.Provides 11 | import dagger.hilt.InstallIn 12 | import dagger.hilt.components.SingletonComponent 13 | import java.util.concurrent.TimeUnit 14 | import javax.inject.Singleton 15 | import kotlinx.serialization.ExperimentalSerializationApi 16 | import kotlinx.serialization.json.Json 17 | import okhttp3.Cache 18 | import okhttp3.MediaType.Companion.toMediaType 19 | import okhttp3.OkHttpClient 20 | import okhttp3.logging.HttpLoggingInterceptor 21 | import okhttp3.logging.HttpLoggingInterceptor.Level as LogLevel 22 | import retrofit2.Retrofit 23 | import tm.alashow.Config 24 | import tm.alashow.domain.models.DEFAULT_JSON_FORMAT 25 | 26 | @InstallIn(SingletonComponent::class) 27 | @Module 28 | class NetworkModule { 29 | 30 | private fun getBaseBuilder(cache: Cache): OkHttpClient.Builder { 31 | return OkHttpClient.Builder() 32 | .cache(cache) 33 | .readTimeout(Config.API_TIMEOUT, TimeUnit.MILLISECONDS) 34 | .writeTimeout(Config.API_TIMEOUT, TimeUnit.MILLISECONDS) 35 | .retryOnConnectionFailure(true) 36 | } 37 | 38 | @Provides 39 | @Singleton 40 | fun okHttpCache(app: Application) = Cache(app.cacheDir, (10 * 1024 * 1024).toLong()) 41 | 42 | @Provides 43 | @Singleton 44 | fun httpLoggingInterceptor(): HttpLoggingInterceptor { 45 | val interceptor = HttpLoggingInterceptor() 46 | interceptor.level = LogLevel.BASIC 47 | return interceptor 48 | } 49 | 50 | @Provides 51 | @Singleton 52 | fun okHttp( 53 | cache: Cache, 54 | loggingInterceptor: HttpLoggingInterceptor, 55 | ) = getBaseBuilder(cache) 56 | .addInterceptor(loggingInterceptor) 57 | .build() 58 | 59 | @Provides 60 | @Singleton 61 | fun jsonConfigured() = DEFAULT_JSON_FORMAT 62 | 63 | @Provides 64 | @Singleton 65 | @ExperimentalSerializationApi 66 | fun retrofit(client: OkHttpClient, json: Json): Retrofit = 67 | Retrofit.Builder() 68 | .baseUrl(Config.API_BASE_URL) 69 | .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) 70 | .client(client) 71 | .build() 72 | } 73 | -------------------------------------------------------------------------------- /app/src/main/kotlin/tm/alashow/rickmorty/ui/MainActivity.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.rickmorty.ui 6 | 7 | import android.os.Bundle 8 | import androidx.activity.ComponentActivity 9 | import androidx.activity.compose.setContent 10 | import androidx.core.view.WindowCompat 11 | import dagger.hilt.android.AndroidEntryPoint 12 | 13 | @AndroidEntryPoint 14 | class MainActivity : ComponentActivity() { 15 | override fun onCreate(savedInstanceState: Bundle?) { 16 | super.onCreate(savedInstanceState) 17 | WindowCompat.setDecorFitsSystemWindows(window, false) 18 | setContent { 19 | RickAndMortyApp() 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /app/src/main/kotlin/tm/alashow/rickmorty/ui/RickAndMortyApp.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.rickmorty.ui 6 | 7 | import androidx.compose.material.ScaffoldState 8 | import androidx.compose.material.rememberScaffoldState 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.runtime.CompositionLocalProvider 11 | import androidx.compose.runtime.collectAsState 12 | import androidx.compose.runtime.getValue 13 | import androidx.hilt.navigation.compose.hiltViewModel 14 | import androidx.navigation.NavHostController 15 | import androidx.navigation.compose.rememberNavController 16 | import androidx.navigation.plusAssign 17 | import com.google.accompanist.insets.ProvideWindowInsets 18 | import com.google.accompanist.navigation.material.ExperimentalMaterialNavigationApi 19 | import com.google.accompanist.navigation.material.ModalBottomSheetLayout 20 | import tm.alashow.common.compose.LocalScaffoldState 21 | import tm.alashow.common.compose.rememberFlowWithLifecycle 22 | import tm.alashow.navigation.NavigatorHost 23 | import tm.alashow.navigation.rememberBottomSheetNavigator 24 | import tm.alashow.rickmorty.ui.home.Home 25 | import tm.alashow.rickmorty.ui.snackbar.SnackbarMessagesListener 26 | import tm.alashow.ui.ThemeViewModel 27 | import tm.alashow.ui.theme.AppTheme 28 | import tm.alashow.ui.theme.DefaultThemeDark 29 | 30 | @OptIn(ExperimentalMaterialNavigationApi::class) 31 | @Composable 32 | fun RickAndMortyApp( 33 | scaffoldState: ScaffoldState = rememberScaffoldState(), 34 | navController: NavHostController = rememberNavController(), 35 | ) { 36 | CompositionLocalProvider( 37 | LocalScaffoldState provides scaffoldState, 38 | ) { 39 | ProvideWindowInsets(consumeWindowInsets = false) { 40 | AppCore { 41 | val bottomSheetNavigator = rememberBottomSheetNavigator() 42 | navController.navigatorProvider += bottomSheetNavigator 43 | ModalBottomSheetLayout(bottomSheetNavigator) { 44 | Home(navController) 45 | } 46 | } 47 | } 48 | } 49 | } 50 | 51 | @Composable 52 | private fun AppCore( 53 | themeViewModel: ThemeViewModel = hiltViewModel(), 54 | content: @Composable () -> Unit 55 | ) { 56 | SnackbarMessagesListener() 57 | val themeState by rememberFlowWithLifecycle(themeViewModel.themeState).collectAsState(DefaultThemeDark) 58 | AppTheme(theme = themeState) { 59 | NavigatorHost { 60 | content() 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /app/src/main/kotlin/tm/alashow/rickmorty/ui/home/Home.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.rickmorty.ui.home 6 | 7 | import androidx.compose.foundation.layout.fillMaxWidth 8 | import androidx.compose.material.ScaffoldState 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.runtime.getValue 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.unit.dp 13 | import androidx.navigation.NavController 14 | import androidx.navigation.NavDestination.Companion.hierarchy 15 | import androidx.navigation.NavGraph.Companion.findStartDestination 16 | import androidx.navigation.NavHostController 17 | import com.google.accompanist.insets.ui.Scaffold 18 | import tm.alashow.common.compose.LocalScaffoldState 19 | import tm.alashow.navigation.screens.RootScreen 20 | import tm.alashow.rickmorty.ui.AppNavigation 21 | import tm.alashow.rickmorty.ui.currentScreenAsState 22 | import tm.alashow.ui.DismissableSnackbarHost 23 | 24 | val HomeBottomNavigationHeight = 56.dp 25 | 26 | @Composable 27 | internal fun Home( 28 | navController: NavHostController, 29 | scaffoldState: ScaffoldState = LocalScaffoldState.current, 30 | ) { 31 | val selectedTab by navController.currentScreenAsState() 32 | Scaffold( 33 | scaffoldState = scaffoldState, 34 | snackbarHost = { DismissableSnackbarHost(it) }, 35 | bottomBar = { 36 | if (HomeNavigationItems.size > 1) 37 | HomeBottomNavigation( 38 | selectedTab = selectedTab, 39 | onNavigationSelected = { selected -> navController.selectRootScreen(selected) }, 40 | modifier = Modifier.fillMaxWidth(), 41 | ) 42 | } 43 | ) { 44 | AppNavigation(navController = navController) 45 | } 46 | } 47 | 48 | internal fun NavController.selectRootScreen(tab: RootScreen) { 49 | navigate(tab.route) { 50 | popUpTo(graph.findStartDestination().id) { 51 | saveState = true 52 | } 53 | launchSingleTop = true 54 | restoreState = true 55 | 56 | val currentEntry = currentBackStackEntry 57 | val currentDestination = currentEntry?.destination 58 | val isReselected = 59 | currentDestination?.hierarchy?.any { it.route == tab.route } == true 60 | val isRootReselected = 61 | currentDestination?.route == tab.startScreen.route 62 | 63 | if (isReselected && !isRootReselected) { 64 | navigateUp() 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /app/src/main/kotlin/tm/alashow/rickmorty/ui/home/HomeBottomNavigation.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.rickmorty.ui.home 6 | 7 | import androidx.compose.foundation.layout.Arrangement 8 | import androidx.compose.foundation.layout.Row 9 | import androidx.compose.foundation.layout.fillMaxWidth 10 | import androidx.compose.foundation.layout.height 11 | import androidx.compose.material.* 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.res.stringResource 15 | import androidx.compose.ui.unit.Dp 16 | import androidx.compose.ui.unit.dp 17 | import com.google.accompanist.insets.navigationBarsPadding 18 | import tm.alashow.navigation.screens.RootScreen 19 | import tm.alashow.ui.theme.translucentSurfaceColor 20 | 21 | @Composable 22 | internal fun HomeBottomNavigation( 23 | selectedTab: RootScreen, 24 | onNavigationSelected: (RootScreen) -> Unit, 25 | modifier: Modifier = Modifier, 26 | height: Dp = HomeBottomNavigationHeight, 27 | ) { 28 | Surface( 29 | elevation = 8.dp, 30 | color = translucentSurfaceColor(), 31 | contentColor = contentColorFor(MaterialTheme.colors.surface), 32 | modifier = modifier, 33 | ) { 34 | Row( 35 | horizontalArrangement = Arrangement.SpaceBetween, 36 | modifier = Modifier 37 | .navigationBarsPadding() 38 | .fillMaxWidth() 39 | .height(height), 40 | ) { 41 | HomeNavigationItems.forEach { item -> 42 | BottomNavigationItem( 43 | icon = { 44 | HomeNavigationItemIcon( 45 | item = item, 46 | selected = selectedTab == item.screen 47 | ) 48 | }, 49 | label = { Text(text = stringResource(item.labelRes)) }, 50 | selected = selectedTab == item.screen, 51 | onClick = { onNavigationSelected(item.screen) }, 52 | selectedContentColor = MaterialTheme.colors.secondary, 53 | unselectedContentColor = MaterialTheme.colors.onSurface 54 | ) 55 | } 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /app/src/main/kotlin/tm/alashow/rickmorty/ui/home/HomeNavigationItem.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.rickmorty.ui.home 6 | 7 | import androidx.annotation.DrawableRes 8 | import androidx.annotation.StringRes 9 | import androidx.compose.ui.graphics.vector.ImageVector 10 | import tm.alashow.navigation.screens.RootScreen 11 | 12 | internal sealed class HomeNavigationItem( 13 | val screen: RootScreen, 14 | @StringRes val labelRes: Int, 15 | @StringRes val contentDescriptionRes: Int, 16 | ) { 17 | class ResourceIcon( 18 | screen: RootScreen, 19 | @StringRes labelResId: Int, 20 | @StringRes contentDescriptionResId: Int, 21 | @DrawableRes val iconResId: Int, 22 | @DrawableRes val selectedIconRes: Int? = null, 23 | ) : HomeNavigationItem(screen, labelResId, contentDescriptionResId) 24 | 25 | class ImageVectorIcon( 26 | screen: RootScreen, 27 | @StringRes labelResId: Int, 28 | @StringRes contentDescriptionResId: Int, 29 | val iconImageVector: ImageVector, 30 | val selectedImageVector: ImageVector? = null, 31 | ) : HomeNavigationItem(screen, labelResId, contentDescriptionResId) 32 | } 33 | -------------------------------------------------------------------------------- /app/src/main/kotlin/tm/alashow/rickmorty/ui/home/HomeNavigationItemIcon.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.rickmorty.ui.home 6 | 7 | import androidx.compose.animation.Crossfade 8 | import androidx.compose.material.Icon 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.ui.graphics.vector.rememberVectorPainter 11 | import androidx.compose.ui.res.painterResource 12 | import androidx.compose.ui.res.stringResource 13 | 14 | @Composable 15 | internal fun HomeNavigationItemIcon(item: HomeNavigationItem, selected: Boolean) { 16 | val painter = when (item) { 17 | is HomeNavigationItem.ResourceIcon -> painterResource(item.iconResId) 18 | is HomeNavigationItem.ImageVectorIcon -> rememberVectorPainter(item.iconImageVector) 19 | } 20 | val selectedPainter = when (item) { 21 | is HomeNavigationItem.ResourceIcon -> item.selectedIconRes?.let { painterResource(it) } 22 | is HomeNavigationItem.ImageVectorIcon -> item.selectedImageVector?.let { 23 | rememberVectorPainter(it) 24 | } 25 | } 26 | 27 | if (selectedPainter != null) { 28 | Crossfade(targetState = selected) { 29 | Icon( 30 | painter = if (it) selectedPainter else painter, 31 | contentDescription = stringResource(item.contentDescriptionRes), 32 | ) 33 | } 34 | } else { 35 | Icon( 36 | painter = painter, 37 | contentDescription = stringResource(item.contentDescriptionRes), 38 | ) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /app/src/main/kotlin/tm/alashow/rickmorty/ui/home/HomeNavigationItems.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.rickmorty.ui.home 6 | 7 | import androidx.compose.material.icons.Icons 8 | import androidx.compose.material.icons.filled.* 9 | import androidx.compose.material.icons.outlined.* 10 | import tm.alashow.navigation.screens.RootScreen 11 | import tm.alashow.rickmorty.R 12 | 13 | internal val HomeNavigationItems = listOf( 14 | HomeNavigationItem.ImageVectorIcon( 15 | screen = RootScreen.Characters, 16 | labelResId = R.string.characters_title, 17 | contentDescriptionResId = R.string.characters_title, 18 | iconImageVector = Icons.Outlined.People, 19 | selectedImageVector = Icons.Filled.People, 20 | ), 21 | ) 22 | -------------------------------------------------------------------------------- /app/src/main/kotlin/tm/alashow/rickmorty/ui/snackbar/SnackbarListenerViewModel.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.rickmorty.ui.snackbar 6 | 7 | import androidx.lifecycle.SavedStateHandle 8 | import androidx.lifecycle.ViewModel 9 | import dagger.hilt.android.lifecycle.HiltViewModel 10 | import javax.inject.Inject 11 | import tm.alashow.base.ui.SnackbarManager 12 | import tm.alashow.base.ui.SnackbarMessage 13 | 14 | @HiltViewModel 15 | class SnackbarListenerViewModel @Inject constructor( 16 | private val handle: SavedStateHandle, 17 | private val snackbarManager: SnackbarManager, 18 | ) : ViewModel() { 19 | val messages = snackbarManager.messages 20 | 21 | fun onSnackbarActionPerformed(message: SnackbarMessage<*>) { 22 | snackbarManager.onMessageActionPerformed(message) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /app/src/main/kotlin/tm/alashow/rickmorty/ui/snackbar/SnackbarMessagesListener.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.rickmorty.ui.snackbar 6 | 7 | import androidx.compose.material.SnackbarHostState 8 | import androidx.compose.material.SnackbarResult 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.runtime.rememberCoroutineScope 11 | import androidx.compose.ui.platform.LocalContext 12 | import androidx.hilt.navigation.compose.hiltViewModel 13 | import kotlinx.coroutines.launch 14 | import timber.log.Timber 15 | import tm.alashow.base.util.asString 16 | import tm.alashow.common.compose.LocalScaffoldState 17 | import tm.alashow.common.compose.collectEvent 18 | 19 | @Composable 20 | internal fun SnackbarMessagesListener( 21 | snackbarHostState: SnackbarHostState = LocalScaffoldState.current.snackbarHostState, 22 | viewModel: SnackbarListenerViewModel = hiltViewModel() 23 | ) { 24 | val coroutine = rememberCoroutineScope() 25 | val context = LocalContext.current 26 | collectEvent(viewModel.messages) { 27 | coroutine.launch { 28 | val snackbarResult = snackbarHostState.showSnackbar(it.message.asString(context), it.action?.label?.asString(context)) 29 | when (snackbarResult) { 30 | SnackbarResult.ActionPerformed -> viewModel.onSnackbarActionPerformed(it) 31 | SnackbarResult.Dismissed -> Timber.d("Snackbar dismissed") 32 | } 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /app/src/main/res-drawable/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res-drawable/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res-drawable/mipmap-hdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alashow/rick-and-morty/976d1ccd21a81a1219852ac6ba6928aa272dab9a/app/src/main/res-drawable/mipmap-hdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res-drawable/mipmap-mdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alashow/rick-and-morty/976d1ccd21a81a1219852ac6ba6928aa272dab9a/app/src/main/res-drawable/mipmap-mdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res-drawable/mipmap-xhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alashow/rick-and-morty/976d1ccd21a81a1219852ac6ba6928aa272dab9a/app/src/main/res-drawable/mipmap-xhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res-drawable/mipmap-xxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alashow/rick-and-morty/976d1ccd21a81a1219852ac6ba6928aa272dab9a/app/src/main/res-drawable/mipmap-xxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res-drawable/mipmap-xxxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alashow/rick-and-morty/976d1ccd21a81a1219852ac6ba6928aa272dab9a/app/src/main/res-drawable/mipmap-xxxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res-drawable/values/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #94CECA 4 | -------------------------------------------------------------------------------- /app/src/main/res/values-night/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 5 | 6 | #103f46 7 | #93cdc9 8 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 13 | 14 | 17 | 18 | 22 | 23 | 29 | 30 | 37 | -------------------------------------------------------------------------------- /art/project-dependency-graph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alashow/rick-and-morty/976d1ccd21a81a1219852ac6ba6928aa272dab9a/art/project-dependency-graph.png -------------------------------------------------------------------------------- /art/rick-face.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alashow/rick-and-morty/976d1ccd21a81a1219852ac6ba6928aa272dab9a/art/rick-face.psd -------------------------------------------------------------------------------- /art/screenshots/phone/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alashow/rick-and-morty/976d1ccd21a81a1219852ac6ba6928aa272dab9a/art/screenshots/phone/1.png -------------------------------------------------------------------------------- /art/screenshots/phone/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alashow/rick-and-morty/976d1ccd21a81a1219852ac6ba6928aa272dab9a/art/screenshots/phone/2.png -------------------------------------------------------------------------------- /art/screenshots/phone/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alashow/rick-and-morty/976d1ccd21a81a1219852ac6ba6928aa272dab9a/art/screenshots/phone/3.png -------------------------------------------------------------------------------- /art/screenshots/phone/4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alashow/rick-and-morty/976d1ccd21a81a1219852ac6ba6928aa272dab9a/art/screenshots/phone/4.png -------------------------------------------------------------------------------- /buildSrc/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | `kotlin-dsl` 3 | } 4 | 5 | repositories { 6 | jcenter() 7 | } 8 | -------------------------------------------------------------------------------- /buildSrc/settings.gradle: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2019, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | 6 | -------------------------------------------------------------------------------- /buildSrc/src/main/java/tm/alashow/buildSrc/App.kt: -------------------------------------------------------------------------------- 1 | package tm.alashow.buildSrc 2 | 3 | object App { 4 | const val id = "tm.alashow.rickmorty" 5 | 6 | const val compileSdkVersion = 31 7 | const val targetSdkVersion = 31 8 | const val minSdkVersion = 21 9 | const val versionCode = 1 10 | const val versionName = "1.0.0" 11 | } 12 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (C) 2019, Alashov Berkeli 3 | # All rights reserved. 4 | # 5 | # Project-wide Gradle settings. 6 | # IDE (e.g. Android Studio) users: 7 | # Gradle settings configured through the IDE *will override* 8 | org.gradle.jvmargs=-Xmx8096m 9 | android.enableJetifier=false 10 | android.useAndroidX=true 11 | org.gradle.parallel=true 12 | org.gradle.configureondemand=true 13 | org.gradle.caching=true -------------------------------------------------------------------------------- /gradle/.run/Tests in 'tm.alashow'.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 17 | 19 | false 20 | true 21 | 22 | 23 | 26 | 27 | false 28 | 29 | 30 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alashow/rick-and-morty/976d1ccd21a81a1219852ac6ba6928aa272dab9a/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Mon Nov 29 20:03:54 CST 2021 2 | distributionBase=GRADLE_USER_HOME 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.3-bin.zip 4 | distributionPath=wrapper/dists 5 | zipStorePath=wrapper/dists 6 | zipStoreBase=GRADLE_USER_HOME 7 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS= 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /modules/base-android/build.gradle: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2019, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | 6 | import tm.alashow.buildSrc.App 7 | import tm.alashow.buildSrc.Deps 8 | 9 | plugins { 10 | id "com.android.library" 11 | id "kotlin-android" 12 | id "kotlin-kapt" 13 | id "kotlin-parcelize" 14 | id "org.jetbrains.kotlin.plugin.serialization" 15 | } 16 | 17 | android { 18 | compileSdkVersion App.compileSdkVersion 19 | 20 | defaultConfig { 21 | minSdkVersion App.minSdkVersion 22 | 23 | vectorDrawables.useSupportLibrary = true 24 | } 25 | 26 | lintOptions { 27 | disable "GradleCompatible" 28 | } 29 | 30 | compileOptions { 31 | sourceCompatibility JavaVersion.VERSION_1_8 32 | targetCompatibility JavaVersion.VERSION_1_8 33 | } 34 | } 35 | 36 | repositories { 37 | mavenCentral() 38 | maven { url "https://jitpack.io" } 39 | } 40 | 41 | dependencies { 42 | api Deps.Dagger.hilt 43 | 44 | // android 45 | api Deps.Android.activityKtx 46 | 47 | api Deps.Android.navigationFragment 48 | api Deps.Android.navigationUi 49 | 50 | api Deps.Android.Lifecycle.runtime 51 | api Deps.Android.Lifecycle.runtimeKtx 52 | api Deps.Android.Lifecycle.extensions 53 | api Deps.Android.Lifecycle.vmKotlin 54 | api Deps.Android.Lifecycle.vmSavedState 55 | 56 | api Deps.Android.Paging.runtime 57 | 58 | api Deps.Android.palette 59 | 60 | api project(":modules:base") 61 | implementation project(":modules:core-domain") 62 | } 63 | -------------------------------------------------------------------------------- /modules/base-android/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /modules/base-android/src/main/java/tm/alashow/base/BaseApp.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2019, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.base 6 | 7 | import android.app.Application 8 | 9 | /** 10 | * Just a wrapper app class to have base stuff. 11 | */ 12 | abstract class BaseApp : Application() 13 | -------------------------------------------------------------------------------- /modules/base-android/src/main/java/tm/alashow/base/imageloading/CoilAppInitializer.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2019, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.base.imageloading 6 | 7 | import android.app.Application 8 | import android.content.Context 9 | import coil.Coil 10 | import coil.ImageLoader 11 | import coil.annotation.ExperimentalCoilApi 12 | import coil.disk.DiskCache 13 | import dagger.hilt.android.qualifiers.ApplicationContext 14 | import java.io.File 15 | import javax.inject.Inject 16 | import okhttp3.OkHttpClient 17 | import tm.alashow.base.inititializer.AppInitializer 18 | import tm.alashow.base.util.CoroutineDispatchers 19 | 20 | @OptIn(ExperimentalCoilApi::class) 21 | class CoilAppInitializer 22 | @Inject constructor( 23 | @ApplicationContext private val context: Context, 24 | private val dispatchers: CoroutineDispatchers, 25 | private val okHttpClient: OkHttpClient, 26 | ) : AppInitializer { 27 | override fun init(application: Application) { 28 | Coil.setImageLoader { 29 | ImageLoader.Builder(application) 30 | .okHttpClient(okHttpClient) 31 | .dispatcher(dispatchers.io) 32 | .fetcherDispatcher(dispatchers.network) 33 | .diskCache(DiskCache.Builder(context).directory(File(context.cacheDir, "images_cache")).build()) 34 | .build() 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /modules/base-android/src/main/java/tm/alashow/base/imageloading/ImageLoading.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.base.imageloading 6 | 7 | import android.content.Context 8 | import android.graphics.Bitmap 9 | import android.graphics.drawable.BitmapDrawable 10 | import coil.imageLoader 11 | import coil.request.ErrorResult 12 | import coil.request.ImageRequest 13 | import coil.request.SuccessResult 14 | import coil.size.Precision 15 | import timber.log.Timber 16 | 17 | suspend fun Context.getBitmap(data: Any?, size: Int = Int.MAX_VALUE, allowHardware: Boolean = true): Bitmap? { 18 | val request = ImageRequest.Builder(this) 19 | .data(data) 20 | .size(size) 21 | .precision(Precision.INEXACT) 22 | .allowHardware(allowHardware) 23 | .build() 24 | 25 | return when (val result = imageLoader.execute(request)) { 26 | is SuccessResult -> (result.drawable as BitmapDrawable).bitmap 27 | is ErrorResult -> { 28 | Timber.e(result.throwable) 29 | null 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /modules/base-android/src/main/java/tm/alashow/base/imageloading/ImageLoadingModule.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.base.imageloading 6 | 7 | import dagger.Binds 8 | import dagger.Module 9 | import dagger.hilt.InstallIn 10 | import dagger.hilt.components.SingletonComponent 11 | import dagger.multibindings.IntoSet 12 | import tm.alashow.base.inititializer.AppInitializer 13 | 14 | @InstallIn(SingletonComponent::class) 15 | @Module 16 | abstract class ImageLoadingModule { 17 | @Binds 18 | @IntoSet 19 | abstract fun provideCoilInitializer(bind: CoilAppInitializer): AppInitializer 20 | } 21 | -------------------------------------------------------------------------------- /modules/base-android/src/main/java/tm/alashow/base/inititializer/AppInitializers.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2018, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.base.inititializer 6 | 7 | import android.app.Application 8 | 9 | class AppInitializers(private vararg val initializers: AppInitializer) : AppInitializer { 10 | override fun init(application: Application) { 11 | initializers.forEach { 12 | it.init(application) 13 | } 14 | } 15 | } 16 | 17 | interface AppInitializer { 18 | fun init(application: Application) 19 | } 20 | -------------------------------------------------------------------------------- /modules/base-android/src/main/java/tm/alashow/base/inititializer/ThreeTenAbpInitializer.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2018, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.base.inititializer 6 | 7 | import android.app.Application 8 | import com.jakewharton.threetenabp.AndroidThreeTen 9 | import javax.inject.Inject 10 | 11 | class ThreeTenAbpInitializer @Inject constructor() : AppInitializer { 12 | override fun init(application: Application) = AndroidThreeTen.init(application) 13 | } 14 | -------------------------------------------------------------------------------- /modules/base-android/src/main/java/tm/alashow/base/inititializer/TimberInitializer.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2018, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.base.inititializer 6 | 7 | import android.app.Application 8 | import javax.inject.Inject 9 | import timber.log.Timber 10 | import tm.alashow.baseAndroid.BuildConfig 11 | 12 | class TimberInitializer @Inject constructor() : AppInitializer { 13 | override fun init(application: Application) { 14 | if (BuildConfig.DEBUG) { 15 | Timber.plant(Timber.DebugTree()) 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /modules/base-android/src/main/java/tm/alashow/base/ui/ThemeState.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.base.ui 6 | 7 | import android.os.Parcelable 8 | import kotlinx.parcelize.Parcelize 9 | import kotlinx.serialization.SerialName 10 | import kotlinx.serialization.Serializable 11 | 12 | enum class DarkModePreference { ON, OFF, AUTO } 13 | enum class ColorPalettePreference { 14 | Default, 15 | Black, 16 | } 17 | 18 | /** 19 | * This should be located in app module, but for some ungodly reason kotlinx-serialization plugin isn't working for app module. 20 | */ 21 | @Parcelize 22 | @Serializable 23 | data class ThemeState( 24 | @SerialName("darkMode") 25 | var darkModePreference: DarkModePreference = DarkModePreference.AUTO, 26 | @SerialName("colorPalette") 27 | var colorPalettePreference: ColorPalettePreference = ColorPalettePreference.Default 28 | ) : Parcelable { 29 | val isDarkMode get() = darkModePreference == DarkModePreference.ON 30 | } 31 | -------------------------------------------------------------------------------- /modules/base-android/src/main/java/tm/alashow/base/util/ThrowableExtensions.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2018, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.base.util 6 | 7 | import android.content.res.Resources 8 | import androidx.annotation.StringRes 9 | import retrofit2.HttpException 10 | import tm.alashow.base.R 11 | import tm.alashow.domain.extensions.simpleName 12 | import tm.alashow.i18n.UiMessage 13 | import tm.alashow.i18n.UiMessageConvertable 14 | import tm.alashow.rickmorty.domain.models.errors.ApiErrorException 15 | import tm.alashow.rickmorty.domain.models.errors.EmptyResultException 16 | 17 | @StringRes 18 | fun Throwable?.localizedTitle(): Int = when (this) { 19 | is EmptyResultException -> R.string.error_empty_title 20 | else -> R.string.error_title 21 | } 22 | 23 | @StringRes 24 | fun Throwable?.localizedMessage(): Int = when (this) { 25 | is ApiErrorException -> localizeApiError() 26 | is EmptyResultException -> R.string.error_empty 27 | is HttpException -> { 28 | when (code()) { 29 | 404 -> R.string.error_notFound 30 | 500 -> R.string.error_server 31 | 403, 401 -> R.string.error_auth 32 | else -> R.string.error_unknown 33 | } 34 | } 35 | is AppError -> messageRes 36 | 37 | else -> R.string.error_unknown 38 | } 39 | 40 | fun Throwable?.toUiMessage() = when (this) { 41 | is UiMessageConvertable -> toUiMessage() 42 | else -> when (val message = localizedMessage()) { 43 | R.string.error_unknown -> UiMessage.Plain(this?.message ?: this?.simpleName ?: "") 44 | else -> UiMessage.Resource(message) 45 | } 46 | } 47 | 48 | fun ApiErrorException.localizeApiError(): Int = when (val errorRes = errorRes) { 49 | is Int -> errorRes 50 | else -> R.string.error_api 51 | } 52 | 53 | val localizedApiMessages = mapOf( 54 | "test" to R.string.error_errorLogOut 55 | ) 56 | 57 | fun String.hasLocalizeApiMessage(): Boolean = localizedApiMessages.containsKey(this) 58 | 59 | fun String.tryToLocalizeApiMessage(resources: Resources, overrideOnFail: Boolean = true): String = when { 60 | localizedApiMessages.containsKey(this) -> resources.getString(localizedApiMessages[this] ?: 0) 61 | else -> if (overrideOnFail) resources.getString(R.string.error_unknown) else this 62 | } 63 | 64 | data class ThrowableString(val value: String) : Throwable() 65 | 66 | data class AppError(val messageRes: Int = R.string.error_unknown) : Throwable() 67 | -------------------------------------------------------------------------------- /modules/base-android/src/main/java/tm/alashow/base/util/UiMessage.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.base.util 6 | 7 | import android.content.Context 8 | import tm.alashow.i18n.UiMessage 9 | import tm.alashow.i18n.UiMessage.* 10 | import tm.alashow.i18n.UiMessageConvertable 11 | 12 | fun UiMessage<*>.asString(context: Context): String = when (this) { 13 | is Plain -> value 14 | is Resource -> context.getString(value, *formatArgs.toTypedArray()) 15 | is Error -> context.getString(value.localizedMessage()) 16 | } 17 | 18 | fun UiMessageConvertable.asString(context: Context) = toUiMessage().asString(context) 19 | -------------------------------------------------------------------------------- /modules/base-android/src/main/res/values-night/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 9 | #2EFFFFFF 10 | #99000000 11 | -------------------------------------------------------------------------------- /modules/base-android/src/main/res/values-night/environment.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | true 9 | false 10 | -------------------------------------------------------------------------------- /modules/base-android/src/main/res/values-night/system_ui.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | @color/system_ui_scrim_dark 9 | false 10 | 11 | -------------------------------------------------------------------------------- /modules/base-android/src/main/res/values-notnight-v27/sys_ui.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | @color/system_ui_scrim_light 9 | true 10 | 11 | -------------------------------------------------------------------------------- /modules/base-android/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | #B3FFFFFF 9 | #30000000 10 | 11 | -------------------------------------------------------------------------------- /modules/base-android/src/main/res/values/donottranslate.xml: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | dd.MM.yyyy 8 | 9 | -------------------------------------------------------------------------------- /modules/base-android/src/main/res/values/environment.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | false 9 | true 10 | -------------------------------------------------------------------------------- /modules/base-android/src/main/res/values/system_ui.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | @color/system_ui_scrim_light 9 | true 10 | 11 | 12 | @color/system_ui_scrim_dark 13 | false 14 | 15 | -------------------------------------------------------------------------------- /modules/base/build.gradle: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2019, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | 6 | import tm.alashow.buildSrc.App 7 | import tm.alashow.buildSrc.Deps 8 | 9 | 10 | plugins { 11 | id "com.android.library" 12 | id "kotlin-android" 13 | id "kotlin-kapt" 14 | } 15 | 16 | android { 17 | compileSdkVersion App.compileSdkVersion 18 | 19 | defaultConfig { 20 | minSdkVersion App.minSdkVersion 21 | } 22 | 23 | lintOptions { 24 | disable "GradleCompatible" 25 | } 26 | 27 | compileOptions { 28 | sourceCompatibility JavaVersion.VERSION_1_8 29 | targetCompatibility JavaVersion.VERSION_1_8 30 | } 31 | } 32 | 33 | repositories { 34 | mavenCentral() 35 | maven { url "https://jitpack.io" } 36 | } 37 | 38 | dependencies { 39 | api Deps.Kotlin.stdlib 40 | api Deps.Kotlin.serializationJson 41 | api Deps.Kotlin.coroutinesCore 42 | 43 | api Deps.Utils.coil 44 | api Deps.Utils.timber 45 | api Deps.Utils.threeTenAbp 46 | 47 | api Deps.Dagger.dagger 48 | kapt Deps.Dagger.compiler 49 | api Deps.Dagger.hilt 50 | kapt Deps.Dagger.hiltCompiler 51 | 52 | api Deps.OkHttp.okhttp 53 | api Deps.OkHttp.logger 54 | api Deps.Retrofit.retrofit 55 | api Deps.Retrofit.kotlinSerializerConverter 56 | 57 | api Deps.Android.Paging.runtime 58 | api Deps.Android.Paging.common 59 | 60 | api project(":modules:i18n") 61 | } 62 | -------------------------------------------------------------------------------- /modules/base/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /modules/base/src/main/java/tm/alashow/base/di/BaseModule.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2018, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.base.di 6 | 7 | import android.app.Application 8 | import android.content.Context 9 | import android.content.res.Resources 10 | import dagger.Module 11 | import dagger.Provides 12 | import dagger.hilt.InstallIn 13 | import dagger.hilt.components.SingletonComponent 14 | 15 | @Module 16 | @InstallIn(SingletonComponent::class) 17 | object BaseModule { 18 | 19 | @Provides 20 | fun appContext(app: Application): Context = app.applicationContext 21 | 22 | @Provides 23 | fun appResources(app: Application): Resources = app.resources 24 | } 25 | -------------------------------------------------------------------------------- /modules/base/src/main/java/tm/alashow/base/di/TestAppModule.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.base.di 6 | 7 | import dagger.Module 8 | import dagger.Provides 9 | import dagger.hilt.migration.DisableInstallInCheck 10 | import javax.inject.Singleton 11 | import kotlinx.coroutines.Dispatchers 12 | import tm.alashow.base.util.CoroutineDispatchers 13 | 14 | @Module 15 | @DisableInstallInCheck 16 | class TestAppModule { 17 | 18 | @Singleton 19 | @Provides 20 | fun coroutineDispatchers() = CoroutineDispatchers( 21 | network = Dispatchers.Unconfined, 22 | io = Dispatchers.Unconfined, 23 | computation = Dispatchers.Unconfined, 24 | main = Dispatchers.Unconfined 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /modules/base/src/main/java/tm/alashow/base/util/CoroutineDispatchers.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2018, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.base.util 6 | 7 | import kotlinx.coroutines.CoroutineDispatcher 8 | 9 | data class CoroutineDispatchers( 10 | val network: CoroutineDispatcher, 11 | val io: CoroutineDispatcher, 12 | val computation: CoroutineDispatcher, 13 | val main: CoroutineDispatcher, 14 | ) 15 | -------------------------------------------------------------------------------- /modules/base/src/main/java/tm/alashow/base/util/date/ApiDateUtil.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2018, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.base.util.date 6 | 7 | import org.threeten.bp.OffsetDateTime 8 | import org.threeten.bp.ZoneId 9 | import org.threeten.bp.ZonedDateTime 10 | import org.threeten.bp.format.DateTimeFormatter 11 | 12 | val API_ZONE_ID: ZoneId = ZoneId.systemDefault() 13 | val HOUR_MINUTES_FORMAT: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm") 14 | 15 | fun apiNow() = ZonedDateTime.now(API_ZONE_ID) 16 | 17 | fun String?.apiDate(fallback: ZonedDateTime = ZonedDateTime.now()): ZonedDateTime { 18 | return try { 19 | OffsetDateTime.parse(this).atZoneSameInstant(API_ZONE_ID) 20 | } catch (e: Exception) { 21 | return fallback 22 | } 23 | } 24 | 25 | fun String?.toFormattedDateFromApi(dateFormat: DateTimeFormatter = DateTimeFormatter.BASIC_ISO_DATE): String = dateFormat.format(apiDate()) 26 | fun String?.toTimeFromApi(): String = HOUR_MINUTES_FORMAT.format(apiDate()) 27 | 28 | fun serverTime() = ZonedDateTime.now(API_ZONE_ID) 29 | -------------------------------------------------------------------------------- /modules/base/src/main/java/tm/alashow/base/util/date/CalendarRange.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2019, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.base.util.date 6 | 7 | import java.util.* 8 | 9 | class CalendarRange(override val start: Calendar, override val endInclusive: Calendar, val field: Int = Calendar.DAY_OF_YEAR) : 10 | ClosedRange, Iterable { 11 | override fun iterator(): Iterator { 12 | return CalendarIterator(start, endInclusive, field) 13 | } 14 | } 15 | 16 | open class CalendarIterator(start: Calendar, private val endInclusive: Calendar, private val field: Int = Calendar.DAY_OF_YEAR) : Iterator { 17 | private var current = start.let { 18 | if (field !in Calendar.AM_PM..Calendar.MILLISECOND) { 19 | it.timeless() 20 | } else it 21 | } 22 | 23 | override fun hasNext(): Boolean { 24 | return current <= endInclusive 25 | } 26 | 27 | override fun next(): Calendar { 28 | val current = current 29 | this.current = current.addPure(field, 1) 30 | 31 | return current 32 | } 33 | } 34 | 35 | class DateRange(override val start: Date, override val endInclusive: Date, val field: Int = Calendar.DAY_OF_YEAR) : 36 | ClosedRange, Iterable { 37 | override fun iterator(): Iterator = DateIterator(start, endInclusive, field) 38 | } 39 | 40 | class DateIterator(start: Date, private val endInclusive: Date, private val field: Int = Calendar.DAY_OF_YEAR) : Iterator { 41 | private var current = start.let { 42 | if (field !in Calendar.AM_PM..Calendar.MILLISECOND) { 43 | it.timeless() 44 | } else it 45 | } 46 | 47 | override fun hasNext(): Boolean { 48 | return current <= endInclusive 49 | } 50 | 51 | override fun next(): Date { 52 | val current = current 53 | this.current = current.addPure(field, 1) 54 | 55 | return current 56 | } 57 | } 58 | 59 | infix operator fun Calendar.rangeTo(that: Calendar) = CalendarRange(this, that) 60 | infix operator fun Date.rangeTo(that: Date) = DateRange(this, that) 61 | -------------------------------------------------------------------------------- /modules/base/src/main/java/tm/alashow/base/util/date/LocalDateRange.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2019, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.base.util.date 6 | 7 | import org.threeten.bp.LocalDate 8 | 9 | class LocalDateRange(override val start: LocalDate, override val endInclusive: LocalDate) : 10 | ClosedRange, Iterable { 11 | override fun iterator(): Iterator { 12 | return LocalDateIterator(start, endInclusive) 13 | } 14 | } 15 | 16 | open class LocalDateIterator(start: LocalDate, private val endInclusive: LocalDate) : Iterator { 17 | private var current = start 18 | 19 | override fun hasNext(): Boolean { 20 | return current <= endInclusive 21 | } 22 | 23 | override fun next(): LocalDate { 24 | val current = current 25 | this.current = current.plusDays(1) 26 | 27 | return current 28 | } 29 | } 30 | 31 | infix operator fun LocalDate.rangeTo(that: LocalDate) = LocalDateRange(this, that) 32 | -------------------------------------------------------------------------------- /modules/base/src/main/java/tm/alashow/base/util/extensions/CoroutineExtensions.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2018, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.base.util.extensions 6 | 7 | import java.util.concurrent.TimeUnit 8 | import kotlinx.coroutines.CoroutineScope 9 | import kotlinx.coroutines.CoroutineStart 10 | import kotlinx.coroutines.Deferred 11 | import kotlinx.coroutines.ExperimentalCoroutinesApi 12 | import kotlinx.coroutines.async 13 | import kotlinx.coroutines.delay 14 | import kotlinx.coroutines.flow.Flow 15 | import kotlinx.coroutines.flow.SharingStarted 16 | import kotlinx.coroutines.flow.channelFlow 17 | import kotlinx.coroutines.flow.flow 18 | import kotlinx.coroutines.flow.mapLatest 19 | import kotlinx.coroutines.flow.stateIn 20 | 21 | fun delayFlow(timeout: Long, value: T): Flow = flow { 22 | delay(timeout) 23 | emit(value) 24 | } 25 | 26 | fun flowInterval(interval: Long, timeUnit: TimeUnit = TimeUnit.MILLISECONDS): Flow { 27 | val delayMillis = timeUnit.toMillis(interval) 28 | return channelFlow { 29 | var tick = 0 30 | send(tick) 31 | while (true) { 32 | delay(delayMillis) 33 | send(++tick) 34 | } 35 | } 36 | } 37 | 38 | fun CoroutineScope.lazyAsync(block: suspend CoroutineScope.() -> T): Lazy> = lazy { 39 | async(start = CoroutineStart.LAZY) { 40 | block.invoke(this) 41 | } 42 | } 43 | 44 | /** 45 | * Alias to stateIn with defaults 46 | */ 47 | fun Flow.stateInDefault( 48 | scope: CoroutineScope, 49 | initialValue: T, 50 | started: SharingStarted = SharingStarted.WhileSubscribed(5000), 51 | ) = stateIn(scope, started, initialValue) 52 | 53 | /** 54 | * Delays given [target]'s emission for [timeMillis] 55 | * i.e skips emission of [target] if something else is emitted before [timeMillis] 56 | */ 57 | @OptIn(ExperimentalCoroutinesApi::class) 58 | fun Flow.delayItem(timeMillis: Long, target: T) = mapLatest { 59 | if (it == target) { 60 | delay(timeMillis) 61 | it 62 | } else it 63 | } 64 | -------------------------------------------------------------------------------- /modules/base/src/main/java/tm/alashow/base/util/extensions/StateFlowExtensions.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.base.util.extensions 6 | 7 | import androidx.lifecycle.Observer 8 | import androidx.lifecycle.SavedStateHandle 9 | import kotlinx.coroutines.CoroutineScope 10 | import kotlinx.coroutines.Dispatchers 11 | import kotlinx.coroutines.flow.MutableStateFlow 12 | import kotlinx.coroutines.flow.collectLatest 13 | import kotlinx.coroutines.flow.onCompletion 14 | import kotlinx.coroutines.launch 15 | import kotlinx.coroutines.withContext 16 | 17 | fun SavedStateHandle.getStateFlow( 18 | key: String, 19 | scope: CoroutineScope, 20 | initialValue: T = get(key) ?: error("No initial value for key $key") 21 | ): MutableStateFlow = this.let { handle -> 22 | val liveData = handle.getLiveData(key, initialValue).also { liveData -> 23 | if (liveData.value === initialValue) { 24 | liveData.value = initialValue 25 | } 26 | } 27 | val mutableStateFlow = MutableStateFlow(liveData.value ?: initialValue) 28 | 29 | val observer: Observer = Observer { value -> 30 | if (value != mutableStateFlow.value) { 31 | mutableStateFlow.value = value 32 | } 33 | } 34 | liveData.observeForever(observer) 35 | 36 | scope.launch { 37 | mutableStateFlow.also { flow -> 38 | flow.onCompletion { 39 | withContext(Dispatchers.Main.immediate) { 40 | liveData.removeObserver(observer) 41 | } 42 | }.collectLatest { value -> 43 | withContext(Dispatchers.Main.immediate) { 44 | if (liveData.value != value) { 45 | liveData.value = value 46 | } 47 | } 48 | } 49 | } 50 | } 51 | mutableStateFlow 52 | } 53 | -------------------------------------------------------------------------------- /modules/base/src/main/java/tm/alashow/base/util/serializer/LocalDateTimeSerializer.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2020, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.base.util.serializer 6 | 7 | import kotlinx.serialization.ExperimentalSerializationApi 8 | import kotlinx.serialization.KSerializer 9 | import kotlinx.serialization.Serializer 10 | import kotlinx.serialization.descriptors.PrimitiveKind 11 | import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor 12 | import kotlinx.serialization.descriptors.SerialDescriptor 13 | import kotlinx.serialization.encoding.Decoder 14 | import kotlinx.serialization.encoding.Encoder 15 | import org.threeten.bp.LocalDateTime 16 | import org.threeten.bp.format.DateTimeFormatter 17 | 18 | @OptIn(ExperimentalSerializationApi::class) 19 | @Serializer(forClass = LocalDateTime::class) 20 | object LocalDateTimeSerializer : KSerializer { 21 | private val DEFAULT_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE_TIME 22 | 23 | override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("localDateTime", PrimitiveKind.STRING) 24 | 25 | override fun serialize(encoder: Encoder, value: LocalDateTime) { 26 | encoder.encodeString(DEFAULT_FORMATTER.format(value)) 27 | } 28 | 29 | override fun deserialize(decoder: Decoder): LocalDateTime { 30 | val string = decoder.decodeString().trim { it <= ' ' } 31 | 32 | if (string.isNotEmpty()) { 33 | return LocalDateTime.parse(string, DEFAULT_FORMATTER) 34 | } 35 | return LocalDateTime.now() 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /modules/common-compose/build.gradle: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | 6 | import tm.alashow.buildSrc.App 7 | import tm.alashow.buildSrc.Deps 8 | 9 | plugins { 10 | id "com.android.library" 11 | id "kotlin-android" 12 | id "kotlin-kapt" 13 | } 14 | 15 | android { 16 | compileSdkVersion App.compileSdkVersion 17 | 18 | defaultConfig { 19 | minSdkVersion App.minSdkVersion 20 | 21 | vectorDrawables.useSupportLibrary = true 22 | } 23 | 24 | lintOptions { 25 | disable "GradleCompatible" 26 | } 27 | 28 | compileOptions { 29 | sourceCompatibility JavaVersion.VERSION_1_8 30 | targetCompatibility JavaVersion.VERSION_1_8 31 | } 32 | 33 | buildFeatures { 34 | compose = true 35 | } 36 | 37 | composeOptions { 38 | kotlinCompilerExtensionVersion Deps.Android.Compose.compilerVersion 39 | } 40 | } 41 | 42 | repositories { 43 | mavenCentral() 44 | } 45 | 46 | dependencies { 47 | api project(":modules:base-android") 48 | 49 | // Android 50 | api Deps.Android.navigationCompose 51 | api Deps.Android.navigationHiltCompose 52 | 53 | api Deps.Android.Compose.ui 54 | api Deps.Android.Compose.uiUtil 55 | api Deps.Android.Compose.uiTooling 56 | api Deps.Android.Compose.foundation 57 | api Deps.Android.Compose.material 58 | api Deps.Android.Compose.materialIcons 59 | api Deps.Android.Compose.materialIconsExtended 60 | api Deps.Android.Compose.constraintLayout 61 | api Deps.Android.Compose.activity 62 | api Deps.Android.Compose.viewModels 63 | api Deps.Android.Compose.liveData 64 | api Deps.Android.Compose.paging 65 | 66 | // Accompanist 67 | api Deps.Android.Accompanist.insets 68 | api Deps.Android.Accompanist.insetsUi 69 | api Deps.Android.Accompanist.pager 70 | api Deps.Android.Accompanist.permissions 71 | api Deps.Android.Accompanist.placeholder 72 | api Deps.Android.Accompanist.swiperefresh 73 | api Deps.Android.Accompanist.systemUiController 74 | api Deps.Android.Accompanist.navigationMaterial 75 | api Deps.Android.Accompanist.navigationFlowlayout 76 | 77 | // 3rd party 78 | api Deps.Android.Compose.coil 79 | } 80 | -------------------------------------------------------------------------------- /modules/common-compose/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /modules/common-compose/src/main/java/tm/alashow/common/compose/CompositionLocals.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.common.compose 6 | 7 | import androidx.compose.material.ScaffoldState 8 | import androidx.compose.runtime.staticCompositionLocalOf 9 | 10 | val LocalScaffoldState = staticCompositionLocalOf { error("No LocalScaffoldState provided") } 11 | -------------------------------------------------------------------------------- /modules/common-compose/src/main/java/tm/alashow/common/compose/Debug.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2020, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | @file:Suppress("NOTHING_TO_INLINE") 6 | 7 | package tm.alashow.common.compose 8 | 9 | import android.util.Log 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.runtime.SideEffect 12 | import androidx.compose.runtime.remember 13 | 14 | class Ref(var value: Int) 15 | 16 | const val EnableDebugCompositionLogs = true 17 | 18 | /** 19 | * An effect which longs the number compositions at the invoked point of the slot table. 20 | * Thanks to [objcode](https://github.com/objcode) for this code. 21 | * 22 | * This is an inline function to act as like a C-style #include to the host composable function. 23 | * That way we track it's compositions, not this function's compositions. 24 | * 25 | * @param tag Log tag used for [Log.d] 26 | */ 27 | @Composable 28 | inline fun LogCompositions(tag: String) { 29 | if (EnableDebugCompositionLogs && BuildConfig.DEBUG) { 30 | val ref = remember { Ref(0) } 31 | SideEffect { ref.value++ } 32 | Log.d(tag, "Compositions: ${ref.value}") 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /modules/common-compose/src/main/java/tm/alashow/common/compose/Flow.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.common.compose 6 | 7 | import android.annotation.SuppressLint 8 | import androidx.compose.runtime.* 9 | import androidx.compose.ui.platform.LocalLifecycleOwner 10 | import androidx.lifecycle.Lifecycle 11 | import androidx.lifecycle.flowWithLifecycle 12 | import androidx.lifecycle.repeatOnLifecycle 13 | import kotlinx.coroutines.flow.Flow 14 | import kotlinx.coroutines.flow.StateFlow 15 | import kotlinx.coroutines.flow.collectLatest 16 | 17 | @Composable 18 | fun rememberFlowWithLifecycle( 19 | flow: Flow, 20 | lifecycle: Lifecycle = LocalLifecycleOwner.current.lifecycle, 21 | minActiveState: Lifecycle.State = Lifecycle.State.STARTED 22 | ): Flow = remember(flow, lifecycle) { 23 | flow.flowWithLifecycle( 24 | lifecycle = lifecycle, 25 | minActiveState = minActiveState 26 | ) 27 | } 28 | 29 | @SuppressLint("StateFlowValueCalledInComposition") // only used as initial value 30 | @Composable 31 | fun rememberFlowWithLifecycle( 32 | stateFlow: StateFlow, 33 | lifecycle: Lifecycle = LocalLifecycleOwner.current.lifecycle, 34 | minActiveState: Lifecycle.State = Lifecycle.State.STARTED 35 | ): State = rememberFlowWithLifecycle( 36 | flow = stateFlow, 37 | lifecycle = lifecycle, 38 | minActiveState = minActiveState 39 | ).collectAsState(initial = stateFlow.value) 40 | 41 | @Composable 42 | fun collectEvent( 43 | flow: Flow, 44 | lifecycle: Lifecycle = LocalLifecycleOwner.current.lifecycle, 45 | minActiveState: Lifecycle.State = Lifecycle.State.STARTED, 46 | collector: suspend (T) -> Unit 47 | ): Unit = LaunchedEffect(lifecycle, flow) { 48 | lifecycle.repeatOnLifecycle(minActiveState) { 49 | flow.collectLatest { 50 | collector(it) 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /modules/common-compose/src/main/java/tm/alashow/common/compose/NavigationExtensions.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.common.compose 6 | 7 | import androidx.compose.runtime.Composable 8 | import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner 9 | import androidx.navigation.NavBackStackEntry 10 | 11 | @Composable 12 | fun getNavArgument(key: String): Any? { 13 | val owner = LocalViewModelStoreOwner.current 14 | return if (owner is NavBackStackEntry) owner.arguments?.get(key) 15 | else null 16 | } 17 | -------------------------------------------------------------------------------- /modules/common-data/build.gradle: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2019, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | 6 | import tm.alashow.buildSrc.App 7 | import tm.alashow.buildSrc.Deps 8 | 9 | plugins { 10 | id "com.android.library" 11 | id "kotlin-android" 12 | id "kotlin-kapt" 13 | id "kotlin-parcelize" 14 | } 15 | 16 | android { 17 | compileSdkVersion App.compileSdkVersion 18 | 19 | defaultConfig { 20 | minSdkVersion App.minSdkVersion 21 | } 22 | 23 | lintOptions { 24 | disable "GradleCompatible" 25 | } 26 | 27 | compileOptions { 28 | sourceCompatibility JavaVersion.VERSION_1_8 29 | targetCompatibility JavaVersion.VERSION_1_8 30 | } 31 | } 32 | 33 | repositories { 34 | mavenCentral() 35 | maven { url "https://jitpack.io" } 36 | } 37 | 38 | dependencies { 39 | kapt Deps.Dagger.compiler 40 | kapt Deps.Dagger.hiltCompiler 41 | 42 | api Deps.Utils.store 43 | 44 | api Deps.Android.Room.ktx 45 | api Deps.Android.Room.paging 46 | kapt Deps.Android.Room.compiler 47 | 48 | api Deps.Android.Paging.runtime 49 | 50 | api Deps.Android.dataStore 51 | 52 | api project(":modules:base") 53 | api project(":modules:common-domain") 54 | } 55 | -------------------------------------------------------------------------------- /modules/common-data/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /modules/common-data/src/main/java/tm/alashow/data/LastRequests.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2020, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.data 6 | 7 | import androidx.datastore.preferences.core.longPreferencesKey 8 | import kotlinx.coroutines.flow.first 9 | import org.threeten.bp.Duration 10 | import org.threeten.bp.Instant 11 | import timber.log.Timber 12 | 13 | class LastRequests( 14 | private val name: String, 15 | private val config: PreferencesStore, 16 | private val expiration: Duration = Duration.ofHours(24), 17 | ) { 18 | companion object { 19 | private const val defaultParams = "default" 20 | private const val keyPrefix = "last_requests_" 21 | 22 | suspend fun clearAll(config: PreferencesStore) { 23 | val preferences = config.getStore().data.first() 24 | preferences.asMap().forEach { (key, _) -> 25 | if (key.name.startsWith(keyPrefix)) { 26 | config.remove(key) 27 | } 28 | } 29 | } 30 | } 31 | 32 | private fun getPreferenceKey(params: String) = longPreferencesKey("last_requests_${name}_$params") 33 | 34 | suspend fun isExpired(params: String = defaultParams): Boolean { 35 | val key = getPreferenceKey(params) 36 | 37 | val lastRequestTime = Instant.ofEpochMilli(config.get(key, 0).first()) 38 | val now = Instant.now() 39 | 40 | val expired = lastRequestTime.plus(expiration).isBefore(now) 41 | Timber.i("Checking last requests expired for ${key.name}: expired=$expired, last=$lastRequestTime, now=$now") 42 | return expired 43 | } 44 | 45 | suspend fun save(params: String = defaultParams, instant: Instant = Instant.now()) { 46 | val key = getPreferenceKey(params) 47 | Timber.i("Saving last requests for ${key.name}: $instant") 48 | config.save(key, instant.toEpochMilli()) 49 | } 50 | 51 | suspend fun clear(params: String = defaultParams) { 52 | val key = getPreferenceKey(params) 53 | Timber.i("Clearing last requests for ${key.name}") 54 | config.remove(key) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /modules/common-data/src/main/java/tm/alashow/data/PaginatedEntryRemoteMediator.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.data 6 | 7 | import androidx.paging.ExperimentalPagingApi 8 | import androidx.paging.LoadType 9 | import androidx.paging.PagingState 10 | import androidx.paging.RemoteMediator 11 | import tm.alashow.domain.models.PaginatedEntity 12 | 13 | /** 14 | * A [RemoteMediator] which works on [PaginatedEntity] entities. [fetch] will be called with the 15 | * next page to load. 16 | */ 17 | @OptIn(ExperimentalPagingApi::class) 18 | class PaginatedEntryRemoteMediator( 19 | private val fetch: suspend (page: Int, refreshing: Boolean) -> Unit 20 | ) : RemoteMediator() where E : PaginatedEntity { 21 | override suspend fun load( 22 | loadType: LoadType, 23 | state: PagingState 24 | ): MediatorResult { 25 | val nextPage = when (loadType) { 26 | LoadType.REFRESH -> 0 27 | LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = true) 28 | LoadType.APPEND -> { 29 | val lastItem = state.lastItemOrNull() ?: return MediatorResult.Success(endOfPaginationReached = true) 30 | lastItem.page + 1 31 | } 32 | } 33 | return try { 34 | // we're assuming if the are already pages and loadType is refreshing, then user must be refreshing it manually 35 | // which can be used to force refresh the data source 36 | val isRefreshing = loadType == LoadType.REFRESH && state.pages.isNotEmpty() 37 | fetch(nextPage, isRefreshing) 38 | MediatorResult.Success(endOfPaginationReached = false) 39 | } catch (t: Throwable) { 40 | MediatorResult.Error(t) 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /modules/common-data/src/main/java/tm/alashow/data/RetrofitExtensions.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.data 6 | 7 | import kotlinx.coroutines.CoroutineDispatcher 8 | import kotlinx.coroutines.withContext 9 | 10 | suspend fun resultApiCall(dispatcher: CoroutineDispatcher, apiCall: suspend () -> T): Result { 11 | return withContext(dispatcher) { 12 | try { 13 | Result.success(apiCall.invoke()) 14 | } catch (throwable: Throwable) { 15 | Result.failure(throwable) 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /modules/common-data/src/main/java/tm/alashow/data/StoreExtensions.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.data 6 | 7 | import com.dropbox.android.external.store4.Store 8 | import com.dropbox.android.external.store4.StoreResponse 9 | import com.dropbox.android.external.store4.fresh 10 | import com.dropbox.android.external.store4.get 11 | import kotlinx.coroutines.flow.Flow 12 | import kotlinx.coroutines.flow.filterNot 13 | 14 | suspend inline fun Store.fetch( 15 | key: Key, 16 | forceFresh: Boolean = false 17 | ): Output = when { 18 | // If we're forcing a fresh fetch, do it now 19 | forceFresh -> fresh(key) 20 | else -> get(key) 21 | } 22 | 23 | fun Flow>.filterForResult(): Flow> = filterNot { 24 | it is StoreResponse.Loading || it is StoreResponse.NoNewData 25 | } 26 | -------------------------------------------------------------------------------- /modules/common-data/src/main/java/tm/alashow/data/db/NukeDatabase.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2018, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.data.db 6 | 7 | import androidx.room.RoomDatabase 8 | import javax.inject.Inject 9 | import kotlinx.coroutines.withContext 10 | import tm.alashow.base.util.CoroutineDispatchers 11 | 12 | /** 13 | * Tiny class for clearing all tables of database. 14 | */ 15 | class NukeDatabase @Inject constructor(private val dispatchers: CoroutineDispatchers) { 16 | suspend fun nuke(database: RoomDatabase) = withContext(dispatchers.io) { 17 | database.clearAllTables() 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /modules/common-data/src/main/java/tm/alashow/data/db/RoomRepo.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.data.db 6 | 7 | import kotlinx.coroutines.flow.Flow 8 | import kotlinx.coroutines.flow.first 9 | import kotlinx.coroutines.flow.flowOn 10 | import kotlinx.coroutines.flow.map 11 | import kotlinx.coroutines.withContext 12 | import tm.alashow.base.util.CoroutineDispatchers 13 | import tm.alashow.domain.models.BaseEntity 14 | 15 | abstract class RoomRepo( 16 | private val dao: BaseDao, 17 | private val dispatchers: CoroutineDispatchers 18 | ) { 19 | fun entry(id: ID) = dao.entry(id.toString()).flowOn(dispatchers.io) 20 | fun entries() = dao.entries().flowOn(dispatchers.io) 21 | fun entries(ids: List) = dao.entriesById(ids.map { it.toString() }).flowOn(dispatchers.io) 22 | 23 | open suspend fun insert(item: E): Long = withContext(dispatchers.io) { dao.insert(item) } 24 | open suspend fun insertAll(items: List): List = withContext(dispatchers.io) { dao.insertAll(items) } 25 | suspend fun update(item: E): E = withContext(dispatchers.io) { 26 | dao.update(item) 27 | dao.entry(item.getIdentifier()).first() 28 | } 29 | 30 | fun isEmpty(): Flow = dao.observeCount().flowOn(dispatchers.io).map { it == 0 } 31 | fun count(): Flow = dao.observeCount().flowOn(dispatchers.io) 32 | 33 | fun has(id: ID): Flow = dao.has(id.toString()).map { it > 0 } 34 | suspend fun exists(id: ID): Boolean = dao.exists(id.toString()) > 0 35 | 36 | open suspend fun delete(id: ID) = withContext(dispatchers.io) { dao.delete(id.toString()) } 37 | open suspend fun deleteAll() = withContext(dispatchers.io) { dao.deleteAll() } 38 | } 39 | -------------------------------------------------------------------------------- /modules/common-data/src/main/java/tm/alashow/data/db/RoomTransactionRunner.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2018, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.data.db 6 | 7 | import androidx.room.RoomDatabase 8 | import androidx.room.withTransaction 9 | 10 | interface DatabaseTransactionRunner { 11 | suspend operator fun invoke(block: suspend () -> T): T 12 | } 13 | 14 | class RoomTransactionRunner(private val db: RoomDatabase) : DatabaseTransactionRunner { 15 | override suspend operator fun invoke(block: suspend () -> T): T { 16 | return db.withTransaction { 17 | block() 18 | } 19 | } 20 | } 21 | 22 | class TestTransactionRunner : DatabaseTransactionRunner { 23 | override suspend fun invoke(block: suspend () -> T): T = block() 24 | } 25 | -------------------------------------------------------------------------------- /modules/common-domain/build.gradle: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2019, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | 6 | import tm.alashow.buildSrc.App 7 | import tm.alashow.buildSrc.Deps 8 | 9 | plugins { 10 | id "com.android.library" 11 | id "kotlin-android" 12 | id "kotlin-parcelize" 13 | id "org.jetbrains.kotlin.plugin.serialization" 14 | } 15 | 16 | android { 17 | compileSdkVersion App.compileSdkVersion 18 | 19 | defaultConfig { 20 | minSdkVersion App.minSdkVersion 21 | } 22 | 23 | lintOptions { 24 | disable "GradleCompatible" 25 | } 26 | 27 | compileOptions { 28 | sourceCompatibility JavaVersion.VERSION_1_8 29 | targetCompatibility JavaVersion.VERSION_1_8 30 | } 31 | } 32 | 33 | repositories { 34 | mavenCentral() 35 | maven { url "https://jitpack.io" } 36 | jcenter() 37 | } 38 | 39 | dependencies { 40 | implementation project(":modules:base") 41 | api Deps.Android.Room.runtime 42 | } -------------------------------------------------------------------------------- /modules/common-domain/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /modules/common-domain/src/main/java/tm/alashow/domain/extensions/Extensions.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2018, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.domain.extensions 6 | 7 | import java.util.* 8 | import kotlin.math.max 9 | 10 | inline val T.simpleName: String get() = this.javaClass.kotlin.simpleName ?: "Unknown" 11 | 12 | fun randomUUID(): String = UUID.randomUUID().toString() 13 | fun Boolean.toFloat() = if (this) 1f else 0f 14 | 15 | infix fun Float.muteUntil(that: Float) = max(this - that, 0.0f) * (1 / (1 - that)) 16 | -------------------------------------------------------------------------------- /modules/common-domain/src/main/java/tm/alashow/domain/extensions/StringExtensions.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2018, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.domain.extensions 6 | 7 | fun String?.orNA(ifBlank: Boolean = false, na: String = "N/A") = when (this.isNullOrEmpty() || (ifBlank && this.isBlank())) { 8 | false -> this 9 | else -> na 10 | } 11 | 12 | fun String?.orBlank() = when (this == null) { 13 | false -> this 14 | else -> "" 15 | } 16 | 17 | fun Int.blankIfZero() = toString().takeIf { it != "0" }.orBlank() 18 | 19 | fun List.interpunctize(interpunct: String = " ꞏ ") = filter { !it.isNullOrBlank() } 20 | .joinToString(interpunct) 21 | 22 | fun String?.capitalize() = orBlank().replaceFirstChar { it.uppercase() } 23 | -------------------------------------------------------------------------------- /modules/common-domain/src/main/java/tm/alashow/domain/models/Async.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.domain.models 6 | 7 | import kotlinx.coroutines.flow.Flow 8 | import kotlinx.coroutines.flow.catch 9 | import kotlinx.coroutines.flow.map 10 | import kotlinx.coroutines.flow.onStart 11 | import tm.alashow.base.util.extensions.delayItem 12 | 13 | fun Flow.asAsyncFlow() = 14 | map { Success(it) as Async } 15 | .onStart { emit(Loading()) } 16 | .catch { emit(Fail(it)) } 17 | 18 | fun Flow>.delayLoading(timeMillis: Long = 100L) = delayItem(timeMillis, Loading()) 19 | 20 | /** 21 | * The T generic is unused for some classes but since it is sealed and useful for Success and Fail, 22 | * it should be on all of them. 23 | * 24 | * Complete: Success, Fail 25 | * ShouldLoad: Uninitialized, Fail 26 | */ 27 | sealed class Async(val complete: Boolean, val shouldLoad: Boolean) { 28 | open operator fun invoke(): T? = null 29 | 30 | val isLoading get() = this is Loading 31 | 32 | fun whenSuccess(onOtherwise: () -> Unit = {}, onSuccess: (T) -> Unit) = when (this) { 33 | is Success -> onSuccess(invoke()) 34 | else -> onOtherwise() 35 | } 36 | } 37 | 38 | object Uninitialized : Async(complete = false, shouldLoad = true), Incomplete 39 | 40 | class Loading : Async(complete = false, shouldLoad = false), Incomplete { 41 | override fun equals(other: Any?) = other is Loading<*> 42 | 43 | override fun hashCode() = "Loading".hashCode() 44 | } 45 | 46 | data class Success(private val value: T) : Async(complete = true, shouldLoad = false) { 47 | 48 | override operator fun invoke(): T = value 49 | } 50 | 51 | data class Fail(val error: Throwable) : Async(complete = true, shouldLoad = true) 52 | 53 | interface Incomplete 54 | -------------------------------------------------------------------------------- /modules/common-domain/src/main/java/tm/alashow/domain/models/BaseEntity.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.domain.models 6 | 7 | interface BaseEntity { 8 | var params: String 9 | 10 | fun getIdentifier(): String 11 | } 12 | 13 | interface PaginatedEntity : BaseEntity { 14 | var page: Int 15 | } 16 | 17 | abstract class BasePaginatedEntity : PaginatedEntity { 18 | 19 | companion object { 20 | const val defaultParams = "" 21 | const val defaultPage = 0 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /modules/common-domain/src/main/java/tm/alashow/domain/models/BaseTypeConverters.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2019, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.domain.models 6 | 7 | import androidx.room.TypeConverter 8 | import org.threeten.bp.LocalDateTime 9 | import org.threeten.bp.format.DateTimeFormatter 10 | 11 | object BaseTypeConverters { 12 | 13 | private val localDateFormat = DateTimeFormatter.ISO_LOCAL_DATE_TIME 14 | 15 | @TypeConverter 16 | @JvmStatic 17 | fun fromParams(params: Params) = params.toString() 18 | 19 | @TypeConverter 20 | @JvmStatic 21 | fun toLocalDateTime(value: String): LocalDateTime = when (value.isBlank()) { 22 | true -> LocalDateTime.now() 23 | else -> LocalDateTime.parse(value, localDateFormat) 24 | } 25 | 26 | @TypeConverter 27 | @JvmStatic 28 | fun fromLocalDateTime(value: LocalDateTime): String = localDateFormat.format(value) 29 | } 30 | -------------------------------------------------------------------------------- /modules/common-domain/src/main/java/tm/alashow/domain/models/InvokeStatus.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.domain.models 6 | 7 | sealed class InvokeStatus 8 | object InvokeStarted : InvokeStatus() 9 | object InvokeSuccess : InvokeStatus() 10 | data class InvokeError(val throwable: Throwable) : InvokeStatus() 11 | -------------------------------------------------------------------------------- /modules/common-domain/src/main/java/tm/alashow/domain/models/Json.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.domain.models 6 | 7 | import kotlinx.serialization.json.Json 8 | 9 | val DEFAULT_JSON_FORMAT = Json { 10 | ignoreUnknownKeys = true 11 | coerceInputValues = true 12 | } 13 | 14 | val JSON = DEFAULT_JSON_FORMAT 15 | -------------------------------------------------------------------------------- /modules/common-domain/src/main/java/tm/alashow/domain/models/Optional.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2018, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.domain.models 6 | 7 | sealed class Optional { 8 | open operator fun invoke(): T? = null 9 | 10 | fun isNone() = this is None 11 | fun isSome() = this is Some 12 | 13 | /** 14 | * Will run [block] if this optional has [Some]. 15 | */ 16 | fun optional(onNone: () -> Unit = {}, onSome: (T) -> Unit) { 17 | when (this) { 18 | is Some -> onSome(value) 19 | else -> onNone() 20 | } 21 | } 22 | 23 | /** 24 | * @Note: Call only if you're sure it's [Some] 25 | */ 26 | fun value() = (this as Some).value 27 | 28 | data class Some(val value: T) : Optional() { 29 | override operator fun invoke(): T = value 30 | } 31 | 32 | object None : Optional() 33 | } 34 | 35 | typealias None = Optional.None 36 | 37 | /** 38 | * Returns [Optional.Some] with [T] if not null, 39 | * or [Optional.None] when null 40 | */ 41 | fun T?.orNone(): Optional = when (this != null) { 42 | true -> Optional.Some(this) 43 | else -> None 44 | } 45 | 46 | fun some(value: T?): Optional = value.orNone() 47 | 48 | fun Optional?.orNull(): T? = when (this) { 49 | is Optional.Some -> value 50 | else -> null 51 | } 52 | 53 | /** 54 | * Returns [Optional.Some] if not null, or [Optional.None] when null. 55 | */ 56 | fun Optional?.orNone(): Optional = when (this != null) { 57 | true -> this 58 | else -> None 59 | } 60 | 61 | /** 62 | * Returns [Optional.Some] if not null, or [Optional.None] when null. 63 | */ 64 | infix fun Optional?.or(that: T): T = when (this != null && isSome()) { 65 | true -> this.value() 66 | else -> that 67 | } 68 | -------------------------------------------------------------------------------- /modules/common-domain/src/main/java/tm/alashow/domain/models/SortOption.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.domain.models 6 | 7 | import java.io.Serializable 8 | import tm.alashow.i18n.UiMessageConvertable 9 | 10 | abstract class SortOption( 11 | open val isDescending: Boolean, 12 | open val comparator: Comparator? = null 13 | ) : UiMessageConvertable, Serializable { 14 | abstract fun toggleDescending(): SortOption 15 | } 16 | 17 | inline fun compareBySerializable(crossinline selector: (T) -> Comparable<*>?): Comparator = 18 | object : Serializable, Comparator { 19 | override fun compare(a: T, b: T) = compareValuesBy(a, b, selector) 20 | } 21 | 22 | inline fun compareByDescendingSerializable(crossinline selector: (T) -> Comparable<*>?): Comparator = 23 | object : Serializable, Comparator { 24 | override fun compare(a: T, b: T) = compareValuesBy(b, a, selector) 25 | } 26 | -------------------------------------------------------------------------------- /modules/common-testing/build.gradle: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2019, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | 6 | import tm.alashow.buildSrc.App 7 | import tm.alashow.buildSrc.Deps 8 | 9 | 10 | plugins { 11 | id "com.android.library" 12 | id "kotlin-android" 13 | id "kotlin-kapt" 14 | } 15 | 16 | android { 17 | compileSdkVersion App.compileSdkVersion 18 | 19 | defaultConfig { 20 | minSdkVersion App.minSdkVersion 21 | } 22 | 23 | lintOptions { 24 | disable "GradleCompatible" 25 | } 26 | 27 | compileOptions { 28 | sourceCompatibility JavaVersion.VERSION_1_8 29 | targetCompatibility JavaVersion.VERSION_1_8 30 | } 31 | } 32 | 33 | repositories { 34 | mavenCentral() 35 | jcenter() 36 | maven { url "https://jitpack.io" } 37 | } 38 | 39 | dependencies { 40 | implementation Deps.Utils.coil 41 | 42 | kapt Deps.Dagger.hiltCompiler 43 | 44 | api Deps.Utils.threeTen 45 | api Deps.Android.archCoreTesting 46 | api Deps.Android.Test.core 47 | api Deps.Android.Test.rules 48 | api Deps.Android.Test.runner 49 | api Deps.Android.Test.junit 50 | api Deps.Android.Room.testing 51 | api Deps.Kotlin.coroutineTesting 52 | api Deps.Dagger.hiltTesting 53 | 54 | api Deps.Testing.junit 55 | api Deps.Testing.mockito 56 | api Deps.Testing.mockitoKotlin 57 | api Deps.Testing.mockk 58 | api Deps.Testing.truth 59 | api Deps.Testing.turbine 60 | api Deps.Testing.robolectric 61 | } 62 | -------------------------------------------------------------------------------- /modules/common-testing/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /modules/common-testing/src/main/java/tm/alashow/base/testing/BaseTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.base.testing 6 | 7 | import androidx.arch.core.executor.testing.InstantTaskExecutorRule 8 | import androidx.test.ext.junit.runners.AndroidJUnit4 9 | import dagger.hilt.android.testing.HiltAndroidRule 10 | import dagger.hilt.android.testing.HiltTestApplication 11 | import org.junit.Before 12 | import org.junit.Rule 13 | import org.junit.runner.RunWith 14 | import org.mockito.junit.MockitoJUnit 15 | import org.mockito.junit.MockitoRule 16 | import org.robolectric.annotation.Config 17 | 18 | @Config(application = HiltTestApplication::class, manifest = Config.NONE) 19 | @RunWith(AndroidJUnit4::class) 20 | abstract class BaseTest { 21 | @get:Rule(order = 0) 22 | val hiltRule: HiltAndroidRule by lazy { HiltAndroidRule(this) } 23 | 24 | @get:Rule(order = 1) 25 | val mockitoRule: MockitoRule by lazy { MockitoJUnit.rule() } 26 | 27 | @get:Rule(order = 2) 28 | val instantTaskExecutorRule = InstantTaskExecutorRule() 29 | 30 | @Before 31 | open fun setUp() { 32 | hiltRule.inject() 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /modules/common-testing/src/main/java/tm/alashow/base/testing/TestImageModule.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.base.testing 6 | 7 | import android.content.Context 8 | import android.graphics.Bitmap 9 | import android.graphics.Canvas 10 | import android.graphics.Color 11 | import android.graphics.drawable.BitmapDrawable 12 | import coil.Coil 13 | import coil.ImageLoader 14 | import coil.decode.DataSource 15 | import coil.request.ImageRequest 16 | import coil.request.SuccessResult 17 | import dagger.Module 18 | import dagger.Provides 19 | import dagger.hilt.android.qualifiers.ApplicationContext 20 | import dagger.hilt.migration.DisableInstallInCheck 21 | import org.mockito.kotlin.any 22 | import org.mockito.kotlin.doReturn 23 | import org.mockito.kotlin.mock 24 | 25 | @Module 26 | @DisableInstallInCheck 27 | class TestImageModule { 28 | 29 | @Provides 30 | fun mockImageLoader( 31 | @ApplicationContext context: Context, 32 | ): ImageLoader = mock { 33 | onBlocking { execute(any()) } doReturn SuccessResult( 34 | drawable = Color.RED.let { 35 | val bitmap = Bitmap.createBitmap(500, 500, Bitmap.Config.ARGB_8888) 36 | val canvas = Canvas(bitmap) 37 | canvas.drawColor(it) 38 | BitmapDrawable(context.resources, bitmap) 39 | }, 40 | request = ImageRequest.Builder(context).build(), 41 | dataSource = DataSource.NETWORK, 42 | memoryCacheKey = null, 43 | diskCacheKey = null, 44 | isSampled = false, 45 | isPlaceholderCached = false, 46 | ) 47 | }.apply { 48 | Coil.setImageLoader(this) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /modules/common-testing/src/main/java/tm/alashow/base/testing/TurbineExtensions.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.base.testing 6 | 7 | import app.cash.turbine.FlowTurbine 8 | 9 | /** 10 | * Waits for first item and then completion. 11 | * Probably needs a better name 12 | */ 13 | suspend fun FlowTurbine.awaitSingle(): T { 14 | val item = awaitItem() 15 | awaitComplete() 16 | return item 17 | } 18 | -------------------------------------------------------------------------------- /modules/common-ui-components/build.gradle: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | 6 | 7 | import tm.alashow.buildSrc.App 8 | import tm.alashow.buildSrc.Deps 9 | 10 | plugins { 11 | id "com.android.library" 12 | id "kotlin-android" 13 | id "kotlin-kapt" 14 | } 15 | 16 | android { 17 | compileSdkVersion App.compileSdkVersion 18 | 19 | defaultConfig { 20 | minSdkVersion App.minSdkVersion 21 | } 22 | 23 | compileOptions { 24 | sourceCompatibility JavaVersion.VERSION_1_8 25 | targetCompatibility JavaVersion.VERSION_1_8 26 | } 27 | 28 | buildFeatures { 29 | compose = true 30 | } 31 | 32 | composeOptions { 33 | kotlinCompilerExtensionVersion Deps.Android.Compose.compilerVersion 34 | } 35 | } 36 | 37 | repositories { 38 | mavenCentral() 39 | } 40 | 41 | dependencies { 42 | implementation project(":modules:common-domain") 43 | api project(":modules:common-compose") 44 | api project(":modules:common-ui-theme") 45 | } 46 | -------------------------------------------------------------------------------- /modules/common-ui-components/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /modules/common-ui-components/src/main/java/tm/alashow/ui/Clickable.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.ui 6 | 7 | import androidx.compose.foundation.Indication 8 | import androidx.compose.foundation.clickable 9 | import androidx.compose.foundation.interaction.MutableInteractionSource 10 | import androidx.compose.material.MaterialTheme 11 | import androidx.compose.material.ripple.rememberRipple 12 | import androidx.compose.runtime.remember 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.composed 15 | import androidx.compose.ui.graphics.Color 16 | import androidx.compose.ui.semantics.Role 17 | import androidx.compose.ui.unit.Dp 18 | import androidx.compose.ui.unit.dp 19 | 20 | fun Modifier.simpleClickable( 21 | interactionSource: MutableInteractionSource? = null, 22 | indication: Indication? = null, 23 | onClick: () -> Unit, 24 | ) = composed { 25 | clickable( 26 | onClick = onClick, 27 | role = Role.Button, 28 | indication = indication, 29 | interactionSource = interactionSource ?: remember { MutableInteractionSource() } 30 | ) 31 | } 32 | 33 | fun Modifier.coloredRippleClickable( 34 | onClick: () -> Unit, 35 | color: Color? = null, 36 | bounded: Boolean = false, 37 | interactionSource: MutableInteractionSource? = null, 38 | rippleRadius: Dp = 24.dp, 39 | ) = composed { 40 | clickable( 41 | onClick = onClick, 42 | role = Role.Button, 43 | indication = rememberRipple(color = color ?: MaterialTheme.colors.secondary, bounded = bounded, radius = rippleRadius), 44 | interactionSource = interactionSource ?: remember { MutableInteractionSource() } 45 | ) 46 | } 47 | -------------------------------------------------------------------------------- /modules/common-ui-components/src/main/java/tm/alashow/ui/Dismissable.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.ui 6 | 7 | import androidx.compose.material.DismissDirection 8 | import androidx.compose.material.DismissValue 9 | import androidx.compose.material.ExperimentalMaterialApi 10 | import androidx.compose.material.SwipeToDismiss 11 | import androidx.compose.material.rememberDismissState 12 | import androidx.compose.runtime.Composable 13 | 14 | @OptIn(ExperimentalMaterialApi::class) 15 | @Composable 16 | fun Dismissable( 17 | onDismiss: () -> Unit, 18 | directions: Set = setOf(DismissDirection.StartToEnd, DismissDirection.EndToStart), 19 | content: @Composable () -> Unit 20 | ) { 21 | val dismissState = rememberDismissState { 22 | if (it != DismissValue.Default) { 23 | onDismiss.invoke() 24 | } 25 | true 26 | } 27 | SwipeToDismiss( 28 | state = dismissState, 29 | directions = directions, 30 | background = {}, 31 | dismissContent = { content() } 32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /modules/common-ui-components/src/main/java/tm/alashow/ui/DismissableSnackbar.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.ui 6 | 7 | import androidx.compose.material.ExperimentalMaterialApi 8 | import androidx.compose.material.Snackbar 9 | import androidx.compose.material.SnackbarData 10 | import androidx.compose.material.SnackbarHost 11 | import androidx.compose.material.SnackbarHostState 12 | import androidx.compose.material.SwipeToDismiss 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.ui.Modifier 15 | 16 | @Composable 17 | fun DismissableSnackbarHost(hostState: SnackbarHostState, modifier: Modifier = Modifier, onDismiss: () -> Unit = {}) { 18 | SnackbarHost( 19 | hostState = hostState, 20 | snackbar = { 21 | SwipeDismissSnackbar( 22 | data = it, 23 | onDismiss = onDismiss 24 | ) 25 | }, 26 | modifier = modifier 27 | ) 28 | } 29 | 30 | /** 31 | * Wrapper around [Snackbar] to make it swipe-dismissable, using [SwipeToDismiss]. 32 | */ 33 | @OptIn(ExperimentalMaterialApi::class) 34 | @Composable 35 | fun SwipeDismissSnackbar( 36 | data: SnackbarData, 37 | onDismiss: () -> Unit = {}, 38 | snackbar: @Composable (SnackbarData) -> Unit = { Snackbar(it) } 39 | ) { 40 | Dismissable(onDismiss = onDismiss) { 41 | snackbar(data) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /modules/common-ui-components/src/main/java/tm/alashow/ui/TimedVisibility.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.ui 6 | 7 | import androidx.compose.animation.AnimatedVisibility 8 | import androidx.compose.animation.ExperimentalAnimationApi 9 | import androidx.compose.animation.fadeIn 10 | import androidx.compose.animation.fadeOut 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.runtime.DisposableEffect 13 | import androidx.compose.runtime.getValue 14 | import androidx.compose.runtime.mutableStateOf 15 | import androidx.compose.runtime.remember 16 | import androidx.compose.runtime.rememberCoroutineScope 17 | import androidx.compose.runtime.setValue 18 | import androidx.compose.ui.Modifier 19 | import kotlinx.coroutines.delay 20 | import kotlinx.coroutines.launch 21 | 22 | /** 23 | * Delays visibility of given [content] for [delayMillis]. 24 | */ 25 | @Composable 26 | fun Delayed(delayMillis: Long = 200, modifier: Modifier = Modifier, content: @Composable () -> Unit) { 27 | TimedVisibility(delayMillis = delayMillis, visibility = false, modifier = modifier, content = content) 28 | } 29 | 30 | /** 31 | * Changes visibility of given [content] after [delayMillis] to opposite of initial [visibility]. 32 | */ 33 | @OptIn(ExperimentalAnimationApi::class) 34 | @Composable 35 | fun TimedVisibility(delayMillis: Long = 4000, visibility: Boolean = true, modifier: Modifier = Modifier, content: @Composable () -> Unit) { 36 | var visible by remember { mutableStateOf(visibility) } 37 | val coroutine = rememberCoroutineScope() 38 | 39 | DisposableEffect(Unit) { 40 | val job = coroutine.launch { 41 | delay(delayMillis) 42 | visible = !visible 43 | } 44 | 45 | onDispose { 46 | job.cancel() 47 | } 48 | } 49 | AnimatedVisibility(visible = visible, modifier = modifier, enter = fadeIn(), exit = fadeOut()) { 50 | content() 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /modules/common-ui-components/src/main/java/tm/alashow/ui/components/Error.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.ui.components 6 | 7 | import androidx.compose.foundation.Image 8 | import androidx.compose.foundation.layout.* 9 | import androidx.compose.material.MaterialTheme 10 | import androidx.compose.material.Text 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.ui.Alignment 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.res.painterResource 15 | import androidx.compose.ui.res.stringResource 16 | import androidx.compose.ui.text.font.FontWeight 17 | import androidx.compose.ui.tooling.preview.Preview 18 | import androidx.compose.ui.unit.dp 19 | import tm.alashow.ui.theme.AppTheme 20 | 21 | @Composable 22 | fun EmptyErrorBox( 23 | modifier: Modifier = Modifier, 24 | title: String = stringResource(R.string.error_empty_title), 25 | message: String = stringResource(R.string.error_empty), 26 | retryLabel: String = stringResource(R.string.error_retry), 27 | retryVisible: Boolean = true, 28 | onRetryClick: () -> Unit = {}, 29 | ) { 30 | ErrorBox( 31 | title = title, 32 | message = message, 33 | retryLabel = retryLabel, 34 | retryVisible = retryVisible, 35 | onRetryClick = onRetryClick, 36 | modifier = modifier 37 | ) 38 | } 39 | 40 | @Preview 41 | @Composable 42 | fun ErrorBox( 43 | modifier: Modifier = Modifier, 44 | title: String = stringResource(R.string.error_title), 45 | message: String = stringResource(R.string.error_unknown), 46 | retryLabel: String = stringResource(R.string.error_retry), 47 | retryVisible: Boolean = true, 48 | onRetryClick: () -> Unit = {} 49 | ) { 50 | Box( 51 | modifier = modifier 52 | .fillMaxWidth() 53 | ) { 54 | Column( 55 | verticalArrangement = Arrangement.spacedBy(AppTheme.specs.paddingTiny), 56 | horizontalAlignment = Alignment.CenterHorizontally, 57 | modifier = Modifier 58 | .align(Alignment.Center) 59 | .padding(top = AppTheme.specs.paddingLarge) 60 | ) { 61 | Image( 62 | painter = painterResource(id = R.drawable.morty_face), 63 | contentDescription = null, 64 | modifier = Modifier 65 | .size(150.dp) 66 | .padding(bottom = AppTheme.specs.paddingLarge) 67 | ) 68 | Text(title, style = MaterialTheme.typography.h6.copy(fontWeight = FontWeight.Bold)) 69 | Text(message) 70 | if (retryVisible) 71 | TextRoundedButton( 72 | onClick = onRetryClick, 73 | text = retryLabel, 74 | modifier = Modifier.padding(top = AppTheme.specs.padding) 75 | ) 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /modules/common-ui-components/src/main/java/tm/alashow/ui/components/IconButton.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.ui.components 6 | 7 | import androidx.compose.foundation.ExperimentalFoundationApi 8 | import androidx.compose.foundation.combinedClickable 9 | import androidx.compose.foundation.interaction.MutableInteractionSource 10 | import androidx.compose.foundation.layout.Box 11 | import androidx.compose.foundation.layout.padding 12 | import androidx.compose.foundation.layout.size 13 | import androidx.compose.material.ContentAlpha 14 | import androidx.compose.material.LocalContentAlpha 15 | import androidx.compose.material.ripple.rememberRipple 16 | import androidx.compose.runtime.Composable 17 | import androidx.compose.runtime.CompositionLocalProvider 18 | import androidx.compose.runtime.remember 19 | import androidx.compose.ui.Alignment 20 | import androidx.compose.ui.Modifier 21 | import androidx.compose.ui.composed 22 | import androidx.compose.ui.graphics.Color 23 | import androidx.compose.ui.semantics.Role 24 | import androidx.compose.ui.unit.Dp 25 | import androidx.compose.ui.unit.dp 26 | import tm.alashow.ui.theme.AppTheme 27 | 28 | private val RippleRadius = 24.dp 29 | private val IconButtonSizeModifier = Modifier.size(48.dp) 30 | 31 | @OptIn(ExperimentalFoundationApi::class) 32 | @Composable 33 | fun IconButton( 34 | onClick: () -> Unit, 35 | modifier: Modifier = Modifier, 36 | enabled: Boolean = true, 37 | rippleColor: Color = Color.Unspecified, 38 | rippleRadius: Dp = RippleRadius, 39 | onLongClickLabel: String? = null, 40 | onLongClick: (() -> Unit)? = null, 41 | onDoubleClick: (() -> Unit)? = null, 42 | interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, 43 | content: @Composable () -> Unit 44 | ) { 45 | Box( 46 | modifier = modifier 47 | .combinedClickable( 48 | onClick = onClick, 49 | onLongClickLabel = onLongClickLabel, 50 | onLongClick = onLongClick, 51 | onDoubleClick = onDoubleClick, 52 | enabled = enabled, 53 | role = Role.Button, 54 | interactionSource = interactionSource, 55 | indication = rememberRipple(bounded = false, color = rippleColor, radius = rippleRadius) 56 | ) 57 | .then(IconButtonSizeModifier), 58 | contentAlignment = Alignment.Center 59 | ) { 60 | val contentAlpha = if (enabled) LocalContentAlpha.current else ContentAlpha.disabled 61 | CompositionLocalProvider(LocalContentAlpha provides contentAlpha, content = content) 62 | } 63 | } 64 | 65 | fun Modifier.textIconModifier() = composed { 66 | Modifier 67 | .size(24.dp) 68 | .padding(end = AppTheme.specs.paddingTiny) 69 | } 70 | -------------------------------------------------------------------------------- /modules/common-ui-components/src/main/java/tm/alashow/ui/components/Placeholder.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.ui.components 6 | 7 | import androidx.annotation.FloatRange 8 | import androidx.compose.animation.core.InfiniteRepeatableSpec 9 | import androidx.compose.material.MaterialTheme 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.ui.graphics.Color 12 | import com.google.accompanist.placeholder.PlaceholderDefaults 13 | import com.google.accompanist.placeholder.PlaceholderHighlight 14 | import com.google.accompanist.placeholder.shimmer 15 | 16 | @Composable 17 | fun shimmer( 18 | highlightColor: Color = MaterialTheme.colors.secondary.copy(alpha = .15f), 19 | animationSpec: InfiniteRepeatableSpec = PlaceholderDefaults.shimmerAnimationSpec, 20 | @FloatRange(from = 0.0, to = 1.0) progressForMaxAlpha: Float = 0.6f, 21 | ): PlaceholderHighlight = PlaceholderHighlight.shimmer( 22 | highlightColor = highlightColor, 23 | animationSpec = animationSpec, 24 | progressForMaxAlpha = progressForMaxAlpha, 25 | ) 26 | -------------------------------------------------------------------------------- /modules/common-ui-components/src/main/java/tm/alashow/ui/components/ProgressIndicator.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.ui.components 6 | 7 | import androidx.compose.foundation.layout.Box 8 | import androidx.compose.foundation.layout.fillMaxSize 9 | import androidx.compose.foundation.layout.fillMaxWidth 10 | import androidx.compose.foundation.layout.size 11 | import androidx.compose.foundation.lazy.LazyListScope 12 | import androidx.compose.material.CircularProgressIndicator 13 | import androidx.compose.material.MaterialTheme 14 | import androidx.compose.runtime.Composable 15 | import androidx.compose.ui.Alignment 16 | import androidx.compose.ui.Modifier 17 | import androidx.compose.ui.graphics.Color 18 | import androidx.compose.ui.unit.Dp 19 | import androidx.compose.ui.unit.dp 20 | import tm.alashow.ui.Delayed 21 | 22 | object ProgressIndicatorDefaults { 23 | val sizeLarge = 32.dp to 2.dp 24 | val sizeMedium = 24.dp to 1.5.dp 25 | val sizeSmall = 16.dp to 1.dp 26 | val size = 48.dp to 4.dp 27 | } 28 | 29 | @Composable 30 | fun ProgressIndicatorSmall(modifier: Modifier = Modifier) = 31 | ProgressIndicator(modifier, ProgressIndicatorDefaults.sizeSmall.first, ProgressIndicatorDefaults.sizeSmall.second) 32 | 33 | @Composable 34 | fun ProgressIndicatorMedium(modifier: Modifier = Modifier) = 35 | ProgressIndicator(modifier, ProgressIndicatorDefaults.sizeMedium.first, ProgressIndicatorDefaults.sizeMedium.second) 36 | 37 | @Composable 38 | fun ProgressIndicator(modifier: Modifier = Modifier) = 39 | ProgressIndicator(modifier, ProgressIndicatorDefaults.sizeLarge.first, ProgressIndicatorDefaults.sizeLarge.second) 40 | 41 | @Composable 42 | fun ProgressIndicator( 43 | modifier: Modifier = Modifier, 44 | size: Dp = ProgressIndicatorDefaults.size.first, 45 | strokeWidth: Dp = ProgressIndicatorDefaults.size.second, 46 | color: Color = MaterialTheme.colors.secondary, 47 | ) { 48 | CircularProgressIndicator(modifier.size(size), color, strokeWidth) 49 | } 50 | 51 | private const val FULL_SCREEN_LOADING_DELAY = 100L 52 | 53 | @Composable 54 | fun FullScreenLoading(modifier: Modifier = Modifier, delayMillis: Long = FULL_SCREEN_LOADING_DELAY) { 55 | Delayed(delayMillis = delayMillis) { 56 | Box( 57 | contentAlignment = Alignment.Center, 58 | modifier = when (modifier == Modifier) { 59 | true -> Modifier.fillMaxSize() 60 | false -> modifier 61 | } 62 | ) { 63 | ProgressIndicator() 64 | } 65 | } 66 | } 67 | 68 | fun LazyListScope.fullScreenLoading(delayMillis: Long = 100, modifier: Modifier = Modifier) { 69 | item { 70 | FullScreenLoading( 71 | delayMillis = delayMillis, 72 | modifier = modifier 73 | .fillParentMaxHeight() 74 | .fillMaxWidth() 75 | ) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /modules/common-ui-components/src/main/res/drawable/morty_face.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alashow/rick-and-morty/976d1ccd21a81a1219852ac6ba6928aa272dab9a/modules/common-ui-components/src/main/res/drawable/morty_face.png -------------------------------------------------------------------------------- /modules/common-ui-theme/build.gradle: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | 6 | 7 | import tm.alashow.buildSrc.App 8 | import tm.alashow.buildSrc.Deps 9 | 10 | plugins { 11 | id "com.android.library" 12 | id "dagger.hilt.android.plugin" 13 | id "kotlin-android" 14 | id "kotlin-kapt" 15 | } 16 | 17 | android { 18 | compileSdkVersion App.compileSdkVersion 19 | 20 | defaultConfig { 21 | minSdkVersion App.minSdkVersion 22 | } 23 | 24 | compileOptions { 25 | sourceCompatibility JavaVersion.VERSION_1_8 26 | targetCompatibility JavaVersion.VERSION_1_8 27 | } 28 | 29 | buildFeatures { 30 | compose = true 31 | } 32 | 33 | composeOptions { 34 | kotlinCompilerExtensionVersion Deps.Android.Compose.compilerVersion 35 | } 36 | } 37 | 38 | repositories { 39 | mavenCentral() 40 | } 41 | 42 | dependencies { 43 | api project(":modules:common-compose") 44 | implementation project(":modules:common-data") 45 | 46 | implementation Deps.Dagger.hilt 47 | kapt Deps.Dagger.compiler 48 | kapt Deps.Dagger.hiltCompiler 49 | } 50 | -------------------------------------------------------------------------------- /modules/common-ui-theme/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /modules/common-ui-theme/src/main/java/tm/alashow/ui/ThemeViewModel.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.ui 6 | 7 | import androidx.lifecycle.SavedStateHandle 8 | import androidx.lifecycle.ViewModel 9 | import androidx.lifecycle.viewModelScope 10 | import dagger.hilt.android.lifecycle.HiltViewModel 11 | import javax.inject.Inject 12 | import kotlinx.coroutines.launch 13 | import tm.alashow.base.ui.ThemeState 14 | import tm.alashow.data.PreferencesStore 15 | import tm.alashow.ui.theme.DefaultTheme 16 | 17 | object PreferenceKeys { 18 | const val THEME_STATE_KEY = "theme_state" 19 | } 20 | 21 | @HiltViewModel 22 | class ThemeViewModel @Inject constructor( 23 | private val handle: SavedStateHandle, 24 | private val preferences: PreferencesStore, 25 | ) : ViewModel() { 26 | 27 | val themeState = preferences.get(PreferenceKeys.THEME_STATE_KEY, ThemeState.serializer(), DefaultTheme) 28 | 29 | fun applyThemeState(themeState: ThemeState) { 30 | viewModelScope.launch { 31 | preferences.save(PreferenceKeys.THEME_STATE_KEY, themeState, ThemeState.serializer()) 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /modules/common-ui-theme/src/main/java/tm/alashow/ui/theme/AppBarAlphas.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.ui.theme 6 | 7 | import androidx.compose.material.MaterialTheme 8 | import androidx.compose.runtime.Composable 9 | 10 | object AppBarAlphas { 11 | @Composable 12 | fun translucentBarAlpha(): Float = when { 13 | MaterialTheme.colors.isLight -> 0.97f 14 | else -> 0.95f 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /modules/common-ui-theme/src/main/java/tm/alashow/ui/theme/AppTheme.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.ui.theme 6 | 7 | import androidx.compose.foundation.text.selection.LocalTextSelectionColors 8 | import androidx.compose.foundation.text.selection.TextSelectionColors 9 | import androidx.compose.material.Colors 10 | import androidx.compose.material.MaterialTheme 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.runtime.CompositionLocalProvider 13 | import androidx.compose.runtime.Stable 14 | import androidx.compose.runtime.getValue 15 | import androidx.compose.runtime.mutableStateOf 16 | import androidx.compose.runtime.remember 17 | import androidx.compose.runtime.setValue 18 | import androidx.compose.runtime.staticCompositionLocalOf 19 | import tm.alashow.base.ui.ThemeState 20 | 21 | val LocalThemeState = staticCompositionLocalOf { 22 | error("No LocalThemeState provided") 23 | } 24 | private val LocalAppColors = staticCompositionLocalOf { 25 | error("No LocalAppColors provided") 26 | } 27 | private val LocalSpecs = staticCompositionLocalOf { 28 | error("No LocalSpecs provided") 29 | } 30 | 31 | object AppTheme { 32 | val state: ThemeState 33 | @Composable 34 | get() = LocalThemeState.current 35 | 36 | val colors: AppColors 37 | @Composable 38 | get() = LocalAppColors.current 39 | 40 | val specs: Specs 41 | @Composable 42 | get() = LocalSpecs.current 43 | } 44 | 45 | @Composable 46 | fun ProvideAppTheme( 47 | theme: ThemeState, 48 | colors: AppColors, 49 | specs: Specs = DefaultSpecs, 50 | content: @Composable () -> Unit 51 | ) { 52 | val appColors = remember { colors.copy() }.apply { update(colors) } 53 | 54 | CompositionLocalProvider( 55 | LocalThemeState provides theme, 56 | LocalAppColors provides appColors, 57 | LocalSpecs provides specs, 58 | content = content 59 | ) 60 | } 61 | 62 | @Stable 63 | data class AppColors( 64 | private val _materialColors: Colors, 65 | ) { 66 | 67 | var materialColors by mutableStateOf(_materialColors) 68 | private set 69 | 70 | fun update(other: AppColors) { 71 | materialColors = other.materialColors 72 | } 73 | } 74 | 75 | @Composable 76 | fun MaterialThemePatches(content: @Composable () -> Unit) { 77 | // change selection color from primary to secondary 78 | val textSelectionColors = TextSelectionColors( 79 | handleColor = MaterialTheme.colors.secondary, 80 | backgroundColor = MaterialTheme.colors.secondary.copy(alpha = 0.4f) 81 | ) 82 | CompositionLocalProvider(LocalTextSelectionColors provides textSelectionColors, content = content) 83 | } 84 | -------------------------------------------------------------------------------- /modules/common-ui-theme/src/main/java/tm/alashow/ui/theme/Shape.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.ui.theme 6 | 7 | import androidx.compose.foundation.shape.RoundedCornerShape 8 | import androidx.compose.material.Shapes 9 | import androidx.compose.ui.unit.dp 10 | 11 | val Shapes = Shapes( 12 | small = RoundedCornerShape(8.dp), 13 | medium = RoundedCornerShape(4.dp), 14 | large = RoundedCornerShape(4.dp) 15 | ) 16 | -------------------------------------------------------------------------------- /modules/common-ui-theme/src/main/java/tm/alashow/ui/theme/Specs.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.ui.theme 6 | 7 | import androidx.compose.foundation.layout.PaddingValues 8 | import androidx.compose.runtime.Immutable 9 | import androidx.compose.ui.unit.Dp 10 | import androidx.compose.ui.unit.dp 11 | 12 | val ContentPaddingLarge = 28.dp 13 | val ContentPadding = 16.dp 14 | val ContentPaddingSmall = 8.dp 15 | val ContentPaddingTiny = 4.dp 16 | 17 | @Immutable 18 | data class Specs( 19 | val padding: Dp = ContentPadding, 20 | val paddingSmall: Dp = ContentPaddingSmall, 21 | val paddingTiny: Dp = ContentPaddingTiny, 22 | val paddingLarge: Dp = ContentPaddingLarge, 23 | 24 | val paddings: PaddingValues = PaddingValues(padding), 25 | val inputPaddings: PaddingValues = PaddingValues(horizontal = padding, vertical = paddingSmall), 26 | 27 | val iconSize: Dp = 36.dp, 28 | val iconSizeSmall: Dp = 28.dp, 29 | val iconSizeTiny: Dp = 18.dp, 30 | val iconSizeLarge: Dp = 48.dp, 31 | ) 32 | -------------------------------------------------------------------------------- /modules/common-ui-theme/src/main/java/tm/alashow/ui/theme/Styles.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.ui.theme 6 | 7 | import androidx.compose.material.ButtonDefaults 8 | import androidx.compose.material.ContentAlpha 9 | import androidx.compose.material.MaterialTheme 10 | import androidx.compose.material.TextFieldDefaults 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.ui.geometry.Offset 13 | import androidx.compose.ui.graphics.Color 14 | import androidx.compose.ui.graphics.Shadow 15 | import androidx.compose.ui.text.font.FontWeight 16 | 17 | // TODO: not sure if this is the best way to define styles 18 | @Composable 19 | fun topAppBarTitleStyle() = MaterialTheme.typography.h4.copy(fontWeight = FontWeight.Bold) 20 | 21 | @Composable 22 | fun topAppBarTitleStyleSmall() = MaterialTheme.typography.h5.copy(fontWeight = FontWeight.Bold) 23 | 24 | @Composable 25 | fun textShadow(color: Color = Color.Black, offset: Offset = Offset(0f, 1f), radius: Float = 0.4f) = Shadow(color, offset, radius) 26 | 27 | @Composable 28 | fun borderlessTextFieldColors( 29 | cursorColor: Color = MaterialTheme.colors.secondary, 30 | ) = outlinedTextFieldColors(cursorColor, Color.Transparent, Color.Transparent) 31 | 32 | @Composable 33 | fun outlinedTextFieldColors( 34 | cursorColor: Color = MaterialTheme.colors.secondary, 35 | focusedBorderColor: Color = MaterialTheme.colors.secondary.copy(alpha = ContentAlpha.medium), 36 | unfocusedBorderColor: Color = MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.disabled), 37 | ) = TextFieldDefaults.outlinedTextFieldColors( 38 | focusedBorderColor = focusedBorderColor, 39 | unfocusedBorderColor = unfocusedBorderColor, 40 | cursorColor = cursorColor, 41 | ) 42 | 43 | @Composable 44 | fun outlinedButtonColors(contentColor: Color = MaterialTheme.colors.onSurface) = 45 | ButtonDefaults.outlinedButtonColors(contentColor = contentColor) 46 | -------------------------------------------------------------------------------- /modules/common-ui-theme/src/main/java/tm/alashow/ui/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.ui.theme 6 | 7 | import androidx.compose.foundation.isSystemInDarkTheme 8 | import androidx.compose.material.MaterialTheme 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.runtime.SideEffect 11 | import androidx.compose.ui.graphics.Color 12 | import com.google.accompanist.systemuicontroller.rememberSystemUiController 13 | import tm.alashow.base.ui.ColorPalettePreference 14 | import tm.alashow.base.ui.DarkModePreference 15 | import tm.alashow.base.ui.ThemeState 16 | 17 | val DefaultTheme = ThemeState() 18 | val DefaultThemeDark = ThemeState(DarkModePreference.ON) 19 | val DefaultSpecs = Specs() 20 | 21 | @Composable 22 | fun AppTheme( 23 | theme: ThemeState = DefaultTheme, 24 | changeSystemBar: Boolean = true, 25 | content: @Composable () -> Unit 26 | ) { 27 | val isDarkTheme = when (theme.darkModePreference) { 28 | DarkModePreference.AUTO -> isSystemInDarkTheme() 29 | DarkModePreference.ON -> true 30 | DarkModePreference.OFF -> false 31 | } 32 | val colors = when (theme.colorPalettePreference) { 33 | ColorPalettePreference.Black -> if (isDarkTheme) appDarkColors(Color.Black, Secondary) else appLightColors(Primary, Secondary) 34 | else -> if (isDarkTheme) DarkAppColors else LightAppColors 35 | } 36 | 37 | if (changeSystemBar) { 38 | val systemUiController = rememberSystemUiController() 39 | SideEffect { 40 | systemUiController.setSystemBarsColor( 41 | color = Color.Transparent, 42 | darkIcons = colors.materialColors.isLight 43 | ) 44 | } 45 | } 46 | 47 | ProvideAppTheme(theme, colors) { 48 | MaterialTheme( 49 | colors = animate(colors.materialColors), 50 | typography = Typography, 51 | shapes = Shapes, 52 | content = { MaterialThemePatches(content) } 53 | ) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /modules/common-ui-theme/src/main/java/tm/alashow/ui/theme/Type.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.ui.theme 6 | 7 | import androidx.compose.material.Typography 8 | import androidx.compose.ui.text.font.Font 9 | import androidx.compose.ui.text.font.FontFamily 10 | import androidx.compose.ui.text.font.FontStyle 11 | import androidx.compose.ui.text.font.FontWeight 12 | 13 | private val APP_FONT = FontFamily( 14 | fonts = listOf( 15 | Font( 16 | resId = R.font.circular_black, 17 | weight = FontWeight.Black, 18 | style = FontStyle.Normal 19 | ), 20 | Font( 21 | resId = R.font.circular_bold, 22 | weight = FontWeight.Bold, 23 | style = FontStyle.Normal 24 | ), 25 | Font( 26 | resId = R.font.circular_regular, 27 | weight = FontWeight.Normal, 28 | style = FontStyle.Normal 29 | ), 30 | Font( 31 | resId = R.font.montserrat_light, 32 | weight = FontWeight.Light, 33 | style = FontStyle.Normal 34 | ), 35 | ) 36 | ) 37 | 38 | val Typography = Typography(defaultFontFamily = APP_FONT) 39 | -------------------------------------------------------------------------------- /modules/common-ui-theme/src/main/res/font/circular_black.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alashow/rick-and-morty/976d1ccd21a81a1219852ac6ba6928aa272dab9a/modules/common-ui-theme/src/main/res/font/circular_black.otf -------------------------------------------------------------------------------- /modules/common-ui-theme/src/main/res/font/circular_bold.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alashow/rick-and-morty/976d1ccd21a81a1219852ac6ba6928aa272dab9a/modules/common-ui-theme/src/main/res/font/circular_bold.otf -------------------------------------------------------------------------------- /modules/common-ui-theme/src/main/res/font/circular_regular.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alashow/rick-and-morty/976d1ccd21a81a1219852ac6ba6928aa272dab9a/modules/common-ui-theme/src/main/res/font/circular_regular.otf -------------------------------------------------------------------------------- /modules/common-ui-theme/src/main/res/font/montserrat_light.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alashow/rick-and-morty/976d1ccd21a81a1219852ac6ba6928aa272dab9a/modules/common-ui-theme/src/main/res/font/montserrat_light.ttf -------------------------------------------------------------------------------- /modules/core-characters/build.gradle: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2019, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | 6 | import tm.alashow.buildSrc.App 7 | import tm.alashow.buildSrc.Deps 8 | 9 | plugins { 10 | id "com.android.library" 11 | id "kotlin-android" 12 | id "kotlin-kapt" 13 | id "kotlin-parcelize" 14 | } 15 | 16 | android { 17 | compileSdkVersion App.compileSdkVersion 18 | 19 | defaultConfig { 20 | minSdkVersion App.minSdkVersion 21 | } 22 | 23 | lintOptions { 24 | disable "GradleCompatible" 25 | } 26 | 27 | compileOptions { 28 | sourceCompatibility JavaVersion.VERSION_1_8 29 | targetCompatibility JavaVersion.VERSION_1_8 30 | } 31 | } 32 | 33 | repositories { 34 | mavenCentral() 35 | maven { url "https://jitpack.io" } 36 | } 37 | 38 | dependencies { 39 | api project(":modules:core-data") 40 | 41 | kapt Deps.Dagger.compiler 42 | kapt Deps.Dagger.hiltCompiler 43 | 44 | kapt Deps.Android.Room.compiler 45 | 46 | testImplementation project(":modules:common-testing") 47 | kaptTest Deps.Dagger.hiltCompiler 48 | } 49 | -------------------------------------------------------------------------------- /modules/core-characters/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /modules/core-characters/src/main/java/tm/alashow/rickmorty/data/interactors/character/GetCharacterDetails.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.rickmorty.data.interactors.character 6 | 7 | import javax.inject.Inject 8 | import kotlinx.coroutines.withContext 9 | import tm.alashow.base.util.CoroutineDispatchers 10 | import tm.alashow.data.ResultInteractor 11 | import tm.alashow.data.fetch 12 | import tm.alashow.rickmorty.data.repos.character.CharacterDetailsStore 13 | import tm.alashow.rickmorty.domain.entities.Character 14 | import tm.alashow.rickmorty.domain.entities.CharacterId 15 | 16 | class GetCharacterDetails @Inject constructor( 17 | private val dispatchers: CoroutineDispatchers, 18 | private val characterDetailsStore: CharacterDetailsStore, 19 | ) : ResultInteractor() { 20 | 21 | data class Params(val characterId: CharacterId, val forceRefresh: Boolean = false) 22 | 23 | override suspend fun doWork(params: Params) = withContext(dispatchers.network) { 24 | characterDetailsStore.fetch(params.characterId, params.forceRefresh) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /modules/core-characters/src/main/java/tm/alashow/rickmorty/data/interactors/character/GetCharacterWithLocationResidents.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.rickmorty.data.interactors.character 6 | 7 | import javax.inject.Inject 8 | import kotlinx.coroutines.flow.Flow 9 | import kotlinx.coroutines.flow.flowOf 10 | import kotlinx.coroutines.flow.map 11 | import tm.alashow.data.SubjectInteractor 12 | import tm.alashow.rickmorty.domain.entities.Character 13 | import tm.alashow.rickmorty.domain.entities.Location 14 | 15 | class GetCharacterWithLocationResidents @Inject constructor( 16 | private val getCharactersByUrls: GetCharactersByUrls, 17 | ) : SubjectInteractor() { 18 | 19 | private fun Location.applyResidents(charactersByUrl: Map) = copy( 20 | residentsCharacters = residents.map { r -> 21 | val id = r.split("/").last().toLong() 22 | charactersByUrl[r] ?: Character.createUnknown(id) 23 | } 24 | ) 25 | 26 | override fun createObservable(params: Character): Flow { 27 | val origin = params.origin 28 | val location = params.location 29 | return if (!origin.isUnknown || !location.isUnknown) { 30 | getCharactersByUrls(GetCharactersByUrls.Params(origin.residents + location.residents)) 31 | getCharactersByUrls.flow.map { 32 | val charactersByUrl = it.associateBy { c -> c.url } 33 | params.copy( 34 | origin = origin.applyResidents(charactersByUrl), 35 | location = location.applyResidents(charactersByUrl) 36 | ) 37 | } 38 | } else flowOf(params) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /modules/core-characters/src/main/java/tm/alashow/rickmorty/data/interactors/character/GetCharacters.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.rickmorty.data.interactors.character 6 | 7 | import javax.inject.Inject 8 | import kotlinx.coroutines.withContext 9 | import tm.alashow.base.util.CoroutineDispatchers 10 | import tm.alashow.data.ResultInteractor 11 | import tm.alashow.data.fetch 12 | import tm.alashow.rickmorty.data.CharactersParams 13 | import tm.alashow.rickmorty.data.repos.character.CharactersStore 14 | import tm.alashow.rickmorty.domain.entities.Character 15 | 16 | class GetCharacters @Inject constructor( 17 | private val dispatchers: CoroutineDispatchers, 18 | private val charactersStore: CharactersStore, 19 | ) : ResultInteractor>() { 20 | 21 | data class Params(val charactersParams: CharactersParams, val forceRefresh: Boolean = false) 22 | 23 | override suspend fun doWork(params: Params) = withContext(dispatchers.network) { 24 | charactersStore.fetch(params.charactersParams, params.forceRefresh) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /modules/core-characters/src/main/java/tm/alashow/rickmorty/data/interactors/character/GetCharactersByUrls.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.rickmorty.data.interactors.character 6 | 7 | import javax.inject.Inject 8 | import tm.alashow.base.util.CoroutineDispatchers 9 | import tm.alashow.data.SubjectInteractor 10 | import tm.alashow.rickmorty.data.db.daos.CharactersDao 11 | import tm.alashow.rickmorty.domain.entities.Character 12 | 13 | class GetCharactersByUrls @Inject constructor( 14 | private val dispatchers: CoroutineDispatchers, 15 | private val dao: CharactersDao, 16 | ) : SubjectInteractor>() { 17 | 18 | data class Params(val urls: List) 19 | 20 | override fun createObservable(params: Params) = dao.getCharactersByUrls(params.urls) 21 | } 22 | -------------------------------------------------------------------------------- /modules/core-characters/src/main/java/tm/alashow/rickmorty/data/interactors/character/GetCharactersPagingSourceWithFilters.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.rickmorty.data.interactors.character 6 | 7 | import androidx.paging.PagingSource 8 | import javax.inject.Inject 9 | import tm.alashow.rickmorty.data.CharactersParams 10 | import tm.alashow.rickmorty.data.db.daos.CharactersDao 11 | import tm.alashow.rickmorty.domain.entities.Character 12 | 13 | class GetCharactersPagingSourceWithFilters @Inject constructor( 14 | private val dao: CharactersDao, 15 | ) { 16 | operator fun invoke(params: CharactersParams): PagingSource = when (params.filters.hasFilters) { 17 | true -> dao.entriesPagingSourceWithFilters( 18 | // null == %% == any item 19 | status = params.filters.status ?: "%%", 20 | species = params.filters.species ?: "%%", 21 | type = params.filters.type ?: "%%", 22 | gender = params.filters.gender ?: "%%", 23 | origin = "%${params.filters.origin ?: ""}%", 24 | location = "%${params.filters.location ?: ""}%", 25 | params = params 26 | ) 27 | else -> dao.entriesPagingSource(params) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /modules/core-characters/src/main/java/tm/alashow/rickmorty/data/observers/character/ObserveCharacter.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2022, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.rickmorty.data.observers.character 6 | 7 | import javax.inject.Inject 8 | import kotlinx.coroutines.flow.Flow 9 | import tm.alashow.data.SubjectInteractor 10 | import tm.alashow.rickmorty.data.db.daos.CharactersDao 11 | import tm.alashow.rickmorty.domain.entities.Character 12 | import tm.alashow.rickmorty.domain.entities.CharacterId 13 | 14 | class ObserveCharacter @Inject constructor( 15 | private val charactersDao: CharactersDao, 16 | ) : SubjectInteractor() { 17 | override fun createObservable(params: CharacterId): Flow = charactersDao.entry(params.toString()) 18 | } 19 | -------------------------------------------------------------------------------- /modules/core-characters/src/main/java/tm/alashow/rickmorty/data/observers/character/ObserveCharacterDetails.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.rickmorty.data.observers.character 6 | 7 | import javax.inject.Inject 8 | import kotlinx.coroutines.ExperimentalCoroutinesApi 9 | import kotlinx.coroutines.flow.flatMapLatest 10 | import tm.alashow.data.SubjectInteractor 11 | import tm.alashow.rickmorty.data.interactors.character.GetCharacterDetails 12 | import tm.alashow.rickmorty.data.interactors.character.GetCharacterWithLocationResidents 13 | import tm.alashow.rickmorty.domain.entities.Character 14 | 15 | class ObserveCharacterDetails @Inject constructor( 16 | private val getCharacterDetails: GetCharacterDetails, 17 | private val getCharacterWithLocationResidents: GetCharacterWithLocationResidents 18 | ) : SubjectInteractor() { 19 | 20 | @OptIn(ExperimentalCoroutinesApi::class) 21 | override fun createObservable(params: GetCharacterDetails.Params) = getCharacterDetails(params) 22 | .flatMapLatest { 23 | getCharacterWithLocationResidents(it) 24 | getCharacterWithLocationResidents.flow 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /modules/core-characters/src/main/java/tm/alashow/rickmorty/data/observers/character/ObserveCharactersFilterOptions.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.rickmorty.data.observers.character 6 | 7 | import javax.inject.Inject 8 | import kotlinx.coroutines.flow.Flow 9 | import kotlinx.coroutines.flow.map 10 | import tm.alashow.data.SubjectInteractor 11 | import tm.alashow.rickmorty.data.CharactersParams 12 | import tm.alashow.rickmorty.data.db.daos.CharactersDao 13 | import tm.alashow.rickmorty.domain.entities.Character 14 | 15 | class ObserveCharactersFilterOptions @Inject constructor( 16 | private val dao: CharactersDao, 17 | ) : SubjectInteractor() { 18 | 19 | private fun List.toOptions(selector: (Character) -> String) = map(selector).toSortedSet() 20 | 21 | override fun createObservable(params: Unit): Flow = dao.entries().map { characters -> 22 | val statusOptions = characters.toOptions { it.status } 23 | val speciesOptions = characters.toOptions { it.species } 24 | val genderOptions = characters.toOptions { it.gender } 25 | val typeOptions = characters.toOptions { it.type } 26 | val originOptions = characters.toOptions { it.origin.name } 27 | val locationOptions = characters.toOptions { it.location.name } 28 | 29 | CharactersParams.FilterOptions( 30 | statusOptions = statusOptions, 31 | speciesOptions = speciesOptions, 32 | genderOptions = genderOptions, 33 | typeOptions = typeOptions, 34 | originOptions = originOptions, 35 | locationOptions = locationOptions 36 | ) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /modules/core-characters/src/main/java/tm/alashow/rickmorty/data/observers/character/ObservePagedCharacters.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.rickmorty.data.observers.character 6 | 7 | import androidx.paging.ExperimentalPagingApi 8 | import androidx.paging.Pager 9 | import androidx.paging.PagingConfig 10 | import androidx.paging.PagingData 11 | import javax.inject.Inject 12 | import kotlinx.coroutines.flow.Flow 13 | import tm.alashow.data.PaginatedEntryRemoteMediator 14 | import tm.alashow.data.PagingInteractor 15 | import tm.alashow.rickmorty.data.CharactersParams 16 | import tm.alashow.rickmorty.data.db.daos.CharactersDao 17 | import tm.alashow.rickmorty.data.interactors.character.GetCharacters 18 | import tm.alashow.rickmorty.data.interactors.character.GetCharactersPagingSourceWithFilters 19 | import tm.alashow.rickmorty.domain.entities.Character 20 | 21 | @OptIn(ExperimentalPagingApi::class) 22 | class ObservePagedCharacters @Inject constructor( 23 | private val getCharacters: GetCharacters, 24 | private val dao: CharactersDao, 25 | private val getCharactersPagingSourceWithFilters: GetCharactersPagingSourceWithFilters, 26 | ) : PagingInteractor() { 27 | 28 | override fun createObservable( 29 | params: Params 30 | ): Flow> { 31 | return Pager( 32 | config = params.pagingConfig, 33 | remoteMediator = PaginatedEntryRemoteMediator { page, refreshing -> 34 | try { 35 | getCharacters.execute( 36 | GetCharacters.Params( 37 | charactersParams = params.charactersParams.copy(page = page), 38 | forceRefresh = refreshing 39 | ) 40 | ) 41 | } catch (error: Exception) { 42 | onError(error) 43 | throw error 44 | } 45 | }, 46 | pagingSourceFactory = { getCharactersPagingSourceWithFilters(params.charactersParams) } 47 | ).flow 48 | } 49 | 50 | data class Params( 51 | val charactersParams: CharactersParams, 52 | override val pagingConfig: PagingConfig = DEFAULT_PAGING_CONFIG, 53 | ) : Parameters 54 | } 55 | -------------------------------------------------------------------------------- /modules/core-characters/src/main/java/tm/alashow/rickmorty/data/repos/character/CharacterDetailDataSource.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.rickmorty.data.repos.character 6 | 7 | import javax.inject.Inject 8 | import kotlinx.coroutines.flow.first 9 | import tm.alashow.base.util.CoroutineDispatchers 10 | import tm.alashow.data.resultApiCall 11 | import tm.alashow.rickmorty.data.api.RickAndMortyEndpoints 12 | import tm.alashow.rickmorty.data.db.daos.CharactersDao 13 | import tm.alashow.rickmorty.domain.entities.Character 14 | import tm.alashow.rickmorty.domain.entities.CharacterId 15 | 16 | class CharacterDetailDataSource @Inject constructor( 17 | private val dao: CharactersDao, 18 | private val endpoints: RickAndMortyEndpoints, 19 | private val dispatchers: CoroutineDispatchers 20 | ) { 21 | suspend operator fun invoke(params: CharacterId): Result { 22 | return resultApiCall(dispatchers.network) { 23 | // get it from database or fallback to network 24 | val result = dao.entryNullable(params.toString()).first() ?: endpoints.character(params) 25 | val locationDetails = when (result.location.isUnknown) { 26 | true -> result.location 27 | else -> endpoints.locationByUrl(result.location.url) 28 | } 29 | val originDetails = when (result.origin.isUnknown) { 30 | true -> result.origin 31 | else -> endpoints.locationByUrl(result.origin.url) 32 | } 33 | 34 | result.copy( 35 | origin = originDetails, 36 | location = locationDetails 37 | ) 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /modules/core-characters/src/main/java/tm/alashow/rickmorty/data/repos/character/CharactersDataSource.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.rickmorty.data.repos.character 6 | 7 | import javax.inject.Inject 8 | import retrofit2.HttpException 9 | import tm.alashow.base.util.CoroutineDispatchers 10 | import tm.alashow.data.resultApiCall 11 | import tm.alashow.rickmorty.data.CharactersParams 12 | import tm.alashow.rickmorty.data.CharactersParams.Companion.toQueryMap 13 | import tm.alashow.rickmorty.data.api.RickAndMortyEndpoints 14 | import tm.alashow.rickmorty.domain.models.CharactersApiResponse 15 | import tm.alashow.rickmorty.domain.models.checkForErrors 16 | import tm.alashow.rickmorty.domain.models.errors.EmptyResultException 17 | 18 | class CharactersDataSource @Inject constructor( 19 | private val endpoints: RickAndMortyEndpoints, 20 | private val dispatchers: CoroutineDispatchers 21 | ) { 22 | suspend operator fun invoke(params: CharactersParams): Result { 23 | return resultApiCall(dispatchers.network) { 24 | try { 25 | endpoints.characters(params.toQueryMap()) 26 | .checkForErrors() 27 | } catch (e: HttpException) { 28 | // api returns 404 if page is out of range 29 | if (e.code() == 404) throw EmptyResultException() 30 | else throw e 31 | } 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /modules/core-characters/src/test/kotlin/tm/alashow/rickmorty/data/TestModule.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.rickmorty.data 6 | 7 | import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory 8 | import dagger.Module 9 | import dagger.Provides 10 | import dagger.hilt.InstallIn 11 | import dagger.hilt.components.SingletonComponent 12 | import kotlinx.serialization.ExperimentalSerializationApi 13 | import okhttp3.MediaType.Companion.toMediaType 14 | import okhttp3.OkHttpClient 15 | import retrofit2.Retrofit 16 | import tm.alashow.Config 17 | import tm.alashow.base.di.TestAppModule 18 | import tm.alashow.domain.models.DEFAULT_JSON_FORMAT 19 | import tm.alashow.rickmorty.data.db.TestDatabaseModule 20 | 21 | @Module( 22 | includes = [ 23 | TestAppModule::class, 24 | TestDatabaseModule::class 25 | ] 26 | ) 27 | @InstallIn(SingletonComponent::class) 28 | class TestModule { 29 | 30 | @Provides 31 | fun provideOkhttpClient(): OkHttpClient = OkHttpClient.Builder().build() 32 | 33 | @OptIn(ExperimentalSerializationApi::class) 34 | @Provides 35 | fun provideRetrofit(client: OkHttpClient): Retrofit = Retrofit.Builder() 36 | .baseUrl(Config.API_BASE_URL) 37 | .addConverterFactory(DEFAULT_JSON_FORMAT.asConverterFactory("application/json".toMediaType())) 38 | .client(client) 39 | .build() 40 | } 41 | -------------------------------------------------------------------------------- /modules/core-characters/src/test/kotlin/tm/alashow/rickmorty/data/interactors/GetCharacterDetailsTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.rickmorty.data.interactors 6 | 7 | import com.google.common.truth.Truth.assertThat 8 | import dagger.hilt.android.testing.HiltAndroidTest 9 | import dagger.hilt.android.testing.UninstallModules 10 | import javax.inject.Inject 11 | import kotlinx.coroutines.test.runTest 12 | import org.junit.After 13 | import org.junit.Test 14 | import retrofit2.HttpException 15 | import tm.alashow.base.testing.BaseTest 16 | import tm.alashow.rickmorty.data.db.AppDatabase 17 | import tm.alashow.rickmorty.data.db.DatabaseModule 18 | import tm.alashow.rickmorty.data.interactors.character.GetCharacterDetails 19 | 20 | @UninstallModules(DatabaseModule::class) 21 | @HiltAndroidTest 22 | class GetCharacterDetailsTest : BaseTest() { 23 | 24 | @Inject lateinit var database: AppDatabase 25 | @Inject lateinit var getCharacterDetails: GetCharacterDetails 26 | 27 | private val testParams = GetCharacterDetails.Params(-1) 28 | 29 | @After 30 | fun tearDown() { 31 | database.close() 32 | } 33 | 34 | @Test(expected = HttpException::class) 35 | fun `get character details given wrong id throws NotFound HttpException`() = runTest { 36 | // assuming default test params has wrong id 37 | getCharacterDetails.execute(testParams) 38 | } 39 | 40 | @Test 41 | fun `get character details by id = 1 returns Rick Sanchez character with origin & location details`() = runTest { 42 | val result = getCharacterDetails.execute(testParams.copy(characterId = 1)) 43 | 44 | assertThat(result.name) 45 | .isEqualTo("Rick Sanchez") 46 | 47 | assertThat(result.origin.dimension) 48 | .contains("Dimension C-137") 49 | assertThat(result.location.residents) 50 | .isNotEmpty() 51 | } 52 | 53 | @Test 54 | fun `get character details by id = 2 return Morty Smith character with origin & location details`() = runTest { 55 | val result = getCharacterDetails.execute(testParams.copy(characterId = 2)) 56 | 57 | assertThat(result.name) 58 | .isEqualTo("Morty Smith") 59 | assertThat(result.origin.id) 60 | .isEqualTo(0) 61 | assertThat(result.location.residents) 62 | .isNotEmpty() 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /modules/core-characters/src/test/kotlin/tm/alashow/rickmorty/data/observers/ObserveCharacterDetailsTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.rickmorty.data.observers 6 | 7 | import app.cash.turbine.test 8 | import com.google.common.truth.Truth.assertThat 9 | import dagger.hilt.android.testing.HiltAndroidTest 10 | import dagger.hilt.android.testing.UninstallModules 11 | import javax.inject.Inject 12 | import kotlinx.coroutines.test.runTest 13 | import org.junit.After 14 | import org.junit.Test 15 | import tm.alashow.base.testing.BaseTest 16 | import tm.alashow.rickmorty.data.db.AppDatabase 17 | import tm.alashow.rickmorty.data.db.DatabaseModule 18 | import tm.alashow.rickmorty.data.interactors.character.GetCharacterDetails 19 | import tm.alashow.rickmorty.data.observers.character.ObserveCharacterDetails 20 | 21 | @UninstallModules(DatabaseModule::class) 22 | @HiltAndroidTest 23 | class ObserveCharacterDetailsTest : BaseTest() { 24 | 25 | @Inject lateinit var database: AppDatabase 26 | @Inject lateinit var observeCharacterDetails: ObserveCharacterDetails 27 | 28 | private val testParams = GetCharacterDetails.Params(-1) 29 | 30 | @After 31 | fun tearDown() { 32 | database.close() 33 | } 34 | 35 | @Test 36 | fun `observing character details by id = 1 returns Rick Sanchez`() = runTest { 37 | val testId = 1L 38 | observeCharacterDetails(testParams.copy(characterId = testId)) 39 | 40 | assertThat(observeCharacterDetails.get().name) 41 | .isEqualTo("Rick Sanchez") 42 | 43 | observeCharacterDetails.flow.test { 44 | val result = awaitItem() 45 | assertThat(result.name) 46 | .isEqualTo("Rick Sanchez") 47 | assertThat(result.origin.dimension) 48 | .contains("Dimension C-137") 49 | assertThat(result.location.residents) 50 | .isNotEmpty() 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /modules/core-characters/src/test/kotlin/tm/alashow/rickmorty/data/observers/ObserveCharacterTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.rickmorty.data.observers 6 | 7 | import app.cash.turbine.test 8 | import com.google.common.truth.Truth.assertThat 9 | import dagger.hilt.android.testing.HiltAndroidTest 10 | import dagger.hilt.android.testing.UninstallModules 11 | import javax.inject.Inject 12 | import kotlinx.coroutines.test.runTest 13 | import org.junit.After 14 | import org.junit.Test 15 | import tm.alashow.base.testing.BaseTest 16 | import tm.alashow.rickmorty.data.db.AppDatabase 17 | import tm.alashow.rickmorty.data.db.DatabaseModule 18 | import tm.alashow.rickmorty.data.interactors.character.GetCharacterDetails 19 | import tm.alashow.rickmorty.data.observers.character.ObserveCharacter 20 | 21 | @UninstallModules(DatabaseModule::class) 22 | @HiltAndroidTest 23 | class ObserveCharacterTest : BaseTest() { 24 | 25 | @Inject lateinit var database: AppDatabase 26 | @Inject lateinit var getCharactersDetails: GetCharacterDetails 27 | @Inject lateinit var observeCharacter: ObserveCharacter 28 | 29 | @After 30 | fun tearDown() { 31 | database.close() 32 | } 33 | 34 | @Test 35 | fun `observing character by id = 1 returns null if it's not in database yet`() = runTest { 36 | observeCharacter(1) 37 | 38 | observeCharacter.flow.test { 39 | assertThat(awaitItem()) 40 | .isEqualTo(null) 41 | } 42 | } 43 | 44 | @Test 45 | fun `observing character by id = 1 returns Rick Sanchez if it's in database`() = runTest { 46 | val testId = 1L 47 | getCharactersDetails.execute(GetCharacterDetails.Params(characterId = testId)) 48 | 49 | observeCharacter(testId) 50 | observeCharacter.flow.test { 51 | assertThat(awaitItem()?.name) 52 | .isEqualTo("Rick Sanchez") 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /modules/core-data/build.gradle: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2019, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | 6 | import tm.alashow.buildSrc.App 7 | import tm.alashow.buildSrc.Deps 8 | 9 | plugins { 10 | id "com.android.library" 11 | id "kotlin-android" 12 | id "kotlin-kapt" 13 | id "kotlin-parcelize" 14 | id "org.jetbrains.kotlin.plugin.serialization" 15 | } 16 | 17 | android { 18 | compileSdkVersion App.compileSdkVersion 19 | 20 | defaultConfig { 21 | minSdkVersion App.minSdkVersion 22 | 23 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 24 | 25 | javaCompileOptions { 26 | annotationProcessorOptions { 27 | arguments += ["room.schemaLocation": "$projectDir/schemas".toString()] 28 | } 29 | } 30 | } 31 | 32 | compileOptions { 33 | sourceCompatibility JavaVersion.VERSION_1_8 34 | targetCompatibility JavaVersion.VERSION_1_8 35 | } 36 | } 37 | 38 | repositories { 39 | mavenCentral() 40 | maven { url "https://jitpack.io" } 41 | } 42 | 43 | dependencies { 44 | api project(":modules:common-data") 45 | api project(":modules:core-domain") 46 | 47 | kapt Deps.Dagger.compiler 48 | kapt Deps.Dagger.hiltCompiler 49 | 50 | kapt Deps.Android.Room.compiler 51 | 52 | testImplementation project(":modules:common-testing") 53 | kaptTest Deps.Dagger.hiltCompiler 54 | kaptTest Deps.Android.Room.compiler 55 | } 56 | -------------------------------------------------------------------------------- /modules/core-data/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /modules/core-data/src/main/java/tm/alashow/rickmorty/data/CharacterParams.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.rickmorty.data 6 | 7 | import tm.alashow.rickmorty.domain.entities.CharacterId 8 | 9 | data class CharacterParams( 10 | val id: CharacterId, 11 | ) { 12 | 13 | // used as key in Room/Store 14 | override fun toString() = "id=$id" 15 | } 16 | -------------------------------------------------------------------------------- /modules/core-data/src/main/java/tm/alashow/rickmorty/data/CharactersParams.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.rickmorty.data 6 | 7 | import android.os.Parcelable 8 | import kotlinx.parcelize.Parcelize 9 | import kotlinx.serialization.Serializable 10 | 11 | @Serializable 12 | data class CharactersParams( 13 | // example: Rick 14 | val name: String? = null, 15 | // options: alive, dead or unknown 16 | val status: String? = null, 17 | // examples: Human, Alien 18 | val species: String? = null, 19 | // examples: Bird-Person 20 | val type: String? = null, 21 | // options: female, male, genderless or unknown 22 | val gender: String? = null, 23 | val page: Int = 0, 24 | 25 | val filters: Filters = Filters(), 26 | ) { 27 | 28 | @Serializable 29 | data class FilterOptions( 30 | val statusOptions: Set = emptySet(), 31 | val speciesOptions: Set = emptySet(), 32 | val typeOptions: Set = emptySet(), 33 | val genderOptions: Set = emptySet(), 34 | val originOptions: Set = emptySet(), 35 | val locationOptions: Set = emptySet(), 36 | ) 37 | 38 | @Serializable 39 | @Parcelize 40 | data class Filters( 41 | val status: String? = null, 42 | val species: String? = null, 43 | val type: String? = null, 44 | val gender: String? = null, 45 | val origin: String? = null, 46 | val location: String? = null, 47 | ) : Parcelable { 48 | val hasFilters = status != null || species != null || type != null || gender != null || origin != null || location != null 49 | } 50 | 51 | // used as a key in Room/Store 52 | override fun toString() = "nm=$name,st=$status,sp=$species,tp=$type,gn=$gender" 53 | 54 | companion object { 55 | fun CharactersParams.toQueryMap(): Map = mutableMapOf( 56 | "name" to (name ?: ""), 57 | "status" to (status ?: ""), 58 | "species" to (species ?: ""), 59 | "type" to (type ?: ""), 60 | "gender" to (gender ?: ""), 61 | "page" to page + 1, 62 | ) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /modules/core-data/src/main/java/tm/alashow/rickmorty/data/SampleData.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.rickmorty.data 6 | 7 | import java.util.* 8 | import kotlin.math.abs 9 | import kotlin.random.Random 10 | import tm.alashow.rickmorty.domain.entities.Character 11 | 12 | object SampleData { 13 | private val random = Random(1000) 14 | 15 | fun Random.id(): Long = abs(nextLong()) 16 | fun Random.sid(): String = nextInt().toString() 17 | 18 | fun randomString() = UUID.randomUUID().toString().replace("-", "") 19 | 20 | val Character: Character = character() 21 | 22 | fun character() = Character( 23 | id = random.id(), 24 | primaryKey = "sample-character-${random.id()}", 25 | searchIndex = random.nextInt(), 26 | page = random.nextInt(), 27 | name = "Character ${random.id()}", 28 | url = "https://rickandmortyapi.com/api/character/${random.id()}", 29 | ) 30 | } 31 | -------------------------------------------------------------------------------- /modules/core-data/src/main/java/tm/alashow/rickmorty/data/SearchParams.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.rickmorty.data 6 | 7 | data class SearchParams( 8 | val query: String, 9 | val page: Int = 0, 10 | ) { 11 | 12 | // used as a key in Room/Store 13 | override fun toString() = "query=$query" 14 | 15 | companion object { 16 | fun SearchParams.toQueryMap(): Map = mutableMapOf( 17 | "query" to query, 18 | "page" to page, 19 | ) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /modules/core-data/src/main/java/tm/alashow/rickmorty/data/api/ApiModule.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.rickmorty.data.api 6 | 7 | import dagger.Module 8 | import dagger.Provides 9 | import dagger.hilt.InstallIn 10 | import dagger.hilt.components.SingletonComponent 11 | import javax.inject.Singleton 12 | import retrofit2.Retrofit 13 | 14 | @InstallIn(SingletonComponent::class) 15 | @Module 16 | class ApiModule { 17 | @Provides 18 | @Singleton 19 | fun provideEndpoints(retrofit: Retrofit): RickAndMortyEndpoints = retrofit.create(RickAndMortyEndpoints::class.java) 20 | } 21 | -------------------------------------------------------------------------------- /modules/core-data/src/main/java/tm/alashow/rickmorty/data/api/RickAndMortyEndpoints.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.rickmorty.data.api 6 | 7 | import retrofit2.http.GET 8 | import retrofit2.http.Path 9 | import retrofit2.http.QueryMap 10 | import retrofit2.http.Url 11 | import tm.alashow.rickmorty.domain.entities.Character 12 | import tm.alashow.rickmorty.domain.entities.CharacterId 13 | import tm.alashow.rickmorty.domain.entities.Location 14 | import tm.alashow.rickmorty.domain.models.CharactersApiResponse 15 | 16 | interface RickAndMortyEndpoints { 17 | 18 | @JvmSuppressWildcards 19 | @GET("/api/character/") 20 | suspend fun characters(@QueryMap params: Map = emptyMap()): CharactersApiResponse 21 | 22 | @JvmSuppressWildcards 23 | @GET("/api/character/{id}") 24 | suspend fun character(@Path("id") id: CharacterId): Character 25 | 26 | @JvmSuppressWildcards 27 | @GET 28 | suspend fun locationByUrl(@Url url: String): Location 29 | } 30 | -------------------------------------------------------------------------------- /modules/core-data/src/main/java/tm/alashow/rickmorty/data/db/AppDatabase.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.rickmorty.data.db 6 | 7 | import androidx.room.Database 8 | import androidx.room.RoomDatabase 9 | import androidx.room.TypeConverters 10 | import tm.alashow.domain.models.BaseTypeConverters 11 | import tm.alashow.rickmorty.data.db.daos.CharactersDao 12 | import tm.alashow.rickmorty.domain.entities.Character 13 | 14 | @Database( 15 | version = 1, 16 | entities = [ 17 | Character::class, 18 | ], 19 | ) 20 | @TypeConverters(BaseTypeConverters::class, AppTypeConverters::class) 21 | abstract class AppDatabase : RoomDatabase() { 22 | abstract fun charactersDao(): CharactersDao 23 | } 24 | -------------------------------------------------------------------------------- /modules/core-data/src/main/java/tm/alashow/rickmorty/data/db/AppTypeConverters.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.rickmorty.data.db 6 | 7 | import androidx.room.TypeConverter 8 | import kotlinx.serialization.builtins.ListSerializer 9 | import tm.alashow.domain.models.DEFAULT_JSON_FORMAT 10 | import tm.alashow.rickmorty.data.CharactersParams 11 | import tm.alashow.rickmorty.data.SearchParams 12 | import tm.alashow.rickmorty.domain.entities.Character 13 | import tm.alashow.rickmorty.domain.entities.Location 14 | 15 | object AppTypeConverters { 16 | 17 | private val json = DEFAULT_JSON_FORMAT 18 | 19 | @TypeConverter 20 | @JvmStatic 21 | fun fromCharactersParams(params: CharactersParams) = params.toString() 22 | 23 | @TypeConverter 24 | @JvmStatic 25 | fun fromSearchParams(params: SearchParams) = params.toString() 26 | 27 | @TypeConverter 28 | @JvmStatic 29 | fun toCharacterList(value: String): List = json.decodeFromString(ListSerializer(Character.serializer()), value) 30 | 31 | @TypeConverter 32 | @JvmStatic 33 | fun fromCharacterList(value: List): String = json.encodeToString(ListSerializer(Character.serializer()), value) 34 | 35 | @TypeConverter 36 | @JvmStatic 37 | fun toLocation(value: String): Location = json.decodeFromString(Location.serializer(), value) 38 | 39 | @TypeConverter 40 | @JvmStatic 41 | fun fromLocation(value: Location): String = json.encodeToString(Location.serializer(), value) 42 | } 43 | -------------------------------------------------------------------------------- /modules/core-data/src/main/java/tm/alashow/rickmorty/data/db/DatabaseModule.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.rickmorty.data.db 6 | 7 | import android.content.Context 8 | import androidx.room.Room 9 | import dagger.Module 10 | import dagger.Provides 11 | import dagger.hilt.InstallIn 12 | import dagger.hilt.android.qualifiers.ApplicationContext 13 | import dagger.hilt.components.SingletonComponent 14 | import dagger.hilt.migration.DisableInstallInCheck 15 | import javax.inject.Singleton 16 | import tm.alashow.data.db.DatabaseTransactionRunner 17 | import tm.alashow.data.db.RoomTransactionRunner 18 | import tm.alashow.data.db.TestTransactionRunner 19 | 20 | @InstallIn(SingletonComponent::class) 21 | @Module 22 | class DatabaseModule { 23 | @Singleton 24 | @Provides 25 | fun appDatabase(context: Context): AppDatabase { 26 | val builder = Room.databaseBuilder(context, AppDatabase::class.java, "app.db") 27 | .fallbackToDestructiveMigration() 28 | return builder.build() 29 | } 30 | 31 | @Singleton 32 | @Provides 33 | fun databaseTransactionRunner(db: AppDatabase): DatabaseTransactionRunner = RoomTransactionRunner(db) 34 | } 35 | 36 | @Module 37 | @DisableInstallInCheck 38 | object TestDatabaseModule { 39 | @Singleton 40 | @Provides 41 | fun provideTestDatabase(@ApplicationContext context: Context): AppDatabase { 42 | return Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java) 43 | .allowMainThreadQueries() 44 | .build() 45 | } 46 | 47 | @Singleton 48 | @Provides 49 | fun databaseTransactionRunner(): DatabaseTransactionRunner = TestTransactionRunner() 50 | } 51 | -------------------------------------------------------------------------------- /modules/core-data/src/main/java/tm/alashow/rickmorty/data/db/daos/DaosModule.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.rickmorty.data.db.daos 6 | 7 | import dagger.Module 8 | import dagger.Provides 9 | import dagger.hilt.InstallIn 10 | import dagger.hilt.components.SingletonComponent 11 | import tm.alashow.data.db.PaginatedEntryDao 12 | import tm.alashow.rickmorty.data.CharactersParams 13 | import tm.alashow.rickmorty.data.db.AppDatabase 14 | import tm.alashow.rickmorty.domain.entities.Character 15 | 16 | @InstallIn(SingletonComponent::class) 17 | @Module 18 | class DaosModule { 19 | 20 | @Provides 21 | fun charactersDao(db: AppDatabase) = db.charactersDao() 22 | 23 | @Provides 24 | fun charactersDaoBase(db: AppDatabase): PaginatedEntryDao = db.charactersDao() 25 | } 26 | -------------------------------------------------------------------------------- /modules/core-data/src/test/kotlin/tm/alashow/rickmorty/data/TestModule.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.rickmorty.data 6 | 7 | import dagger.Module 8 | import dagger.hilt.InstallIn 9 | import dagger.hilt.components.SingletonComponent 10 | import tm.alashow.rickmorty.data.db.TestDatabaseModule 11 | 12 | @Module(includes = [TestDatabaseModule::class]) 13 | @InstallIn(SingletonComponent::class) 14 | object TestModule 15 | -------------------------------------------------------------------------------- /modules/core-domain/build.gradle: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2019, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | 6 | 7 | import tm.alashow.buildSrc.App 8 | import tm.alashow.buildSrc.Deps 9 | 10 | plugins { 11 | id "com.android.library" 12 | id "kotlin-android" 13 | id "kotlin-parcelize" 14 | id "org.jetbrains.kotlin.plugin.serialization" 15 | } 16 | 17 | android { 18 | compileSdkVersion App.compileSdkVersion 19 | 20 | defaultConfig { 21 | minSdkVersion App.minSdkVersion 22 | } 23 | 24 | lintOptions { 25 | disable "GradleCompatible" 26 | } 27 | 28 | compileOptions { 29 | sourceCompatibility JavaVersion.VERSION_1_8 30 | targetCompatibility JavaVersion.VERSION_1_8 31 | } 32 | } 33 | 34 | repositories { 35 | mavenCentral() 36 | maven { url "https://jitpack.io" } 37 | jcenter() 38 | } 39 | 40 | dependencies { 41 | api project(":modules:common-domain") 42 | implementation Deps.Kotlin.serializationJson 43 | implementation Deps.Utils.threeTenAbp 44 | } -------------------------------------------------------------------------------- /modules/core-domain/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /modules/core-domain/src/main/java/tm/alashow/Config.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow 6 | 7 | import org.threeten.bp.Duration 8 | 9 | object Config { 10 | const val BASE_HOST = "rickandmortyapi.com" 11 | const val BASE_URL = "https://$BASE_HOST/" 12 | const val API_BASE_URL = "https://$BASE_HOST/" 13 | 14 | const val PLAYSTORE_ID = "tm.alashow.rickmorty" 15 | const val PLAYSTORE_URL = "https://play.google.com/store/apps/details?id=$PLAYSTORE_ID" 16 | 17 | val API_TIMEOUT = Duration.ofSeconds(40).toMillis() 18 | } 19 | -------------------------------------------------------------------------------- /modules/core-domain/src/main/java/tm/alashow/rickmorty/domain/Constants.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2022, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.rickmorty.domain 6 | 7 | const val UNKNOWN_ITEM = "unknown" 8 | 9 | fun String?.isUnknown(): Boolean = this?.lowercase() == UNKNOWN_ITEM 10 | -------------------------------------------------------------------------------- /modules/core-domain/src/main/java/tm/alashow/rickmorty/domain/entities/Character.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.rickmorty.domain.entities 6 | 7 | import android.os.Parcelable 8 | import androidx.room.ColumnInfo 9 | import androidx.room.Entity 10 | import androidx.room.PrimaryKey 11 | import kotlinx.parcelize.Parcelize 12 | import kotlinx.serialization.SerialName 13 | import kotlinx.serialization.Serializable 14 | import kotlinx.serialization.Transient 15 | import tm.alashow.domain.extensions.interpunctize 16 | import tm.alashow.domain.models.BasePaginatedEntity 17 | import tm.alashow.rickmorty.domain.isUnknown 18 | 19 | typealias CharacterId = Long 20 | 21 | const val UNKNOWN_ITEM = "Unknown" 22 | 23 | @Parcelize 24 | @Serializable 25 | @Entity(tableName = "characters") 26 | data class Character( 27 | @SerialName("id") 28 | @ColumnInfo(name = "id") 29 | val id: CharacterId = 0L, 30 | 31 | @SerialName("name") 32 | @ColumnInfo(name = "name") 33 | val name: String = UNKNOWN_ITEM, 34 | 35 | @SerialName("status") 36 | @ColumnInfo(name = "status") 37 | val status: String = UNKNOWN_ITEM, 38 | 39 | @SerialName("species") 40 | @ColumnInfo(name = "species") 41 | val species: String = UNKNOWN_ITEM, 42 | 43 | @SerialName("type") 44 | @ColumnInfo(name = "type") 45 | val type: String = "", 46 | 47 | @SerialName("gender") 48 | @ColumnInfo(name = "gender") 49 | val gender: String = UNKNOWN_ITEM, 50 | 51 | @SerialName("image") 52 | @ColumnInfo(name = "image") 53 | val imageUrl: String = "", 54 | 55 | @SerialName("url") 56 | @ColumnInfo(name = "url") 57 | val url: String = "", 58 | 59 | @SerialName("origin") 60 | @ColumnInfo(name = "origin") 61 | val origin: Location = Location(), 62 | 63 | @SerialName("location") 64 | @ColumnInfo(name = "location") 65 | val location: Location = Location(), 66 | 67 | @Transient 68 | @ColumnInfo(name = "details_fetched") 69 | val detailsFetched: Boolean = false, 70 | 71 | @SerialName("params") 72 | @ColumnInfo(name = "params") 73 | override var params: String = defaultParams, 74 | 75 | @SerialName("page") 76 | @ColumnInfo(name = "page") 77 | override var page: Int = defaultPage, 78 | 79 | @PrimaryKey 80 | val primaryKey: String = "", 81 | 82 | @SerialName("search_index") 83 | @ColumnInfo(name = "search_index") 84 | val searchIndex: Int = 0, 85 | ) : BasePaginatedEntity(), Parcelable { 86 | 87 | val description 88 | get() = listOf(status, species, type) 89 | .filterNot { it.isUnknown() } 90 | .interpunctize(" - ") 91 | .ifBlank { "No description" } 92 | 93 | val isAlive get() = status.lowercase() == "alive" 94 | val isDead get() = status.lowercase() == "dead" 95 | 96 | override fun getIdentifier() = id.toString() 97 | 98 | fun isUnknown() = name.startsWith("$UNKNOWN_ITEM #") 99 | 100 | companion object { 101 | fun createUnknown(id: CharacterId) = Character( 102 | id = id, 103 | name = "$UNKNOWN_ITEM #$id", 104 | ) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /modules/core-domain/src/main/java/tm/alashow/rickmorty/domain/entities/Location.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.rickmorty.domain.entities 6 | 7 | import android.os.Parcelable 8 | import androidx.room.ColumnInfo 9 | import androidx.room.Ignore 10 | import androidx.room.PrimaryKey 11 | import kotlinx.parcelize.Parcelize 12 | import kotlinx.serialization.SerialName 13 | import kotlinx.serialization.Serializable 14 | import tm.alashow.domain.models.BasePaginatedEntity 15 | import tm.alashow.rickmorty.domain.isUnknown 16 | 17 | typealias LocationId = Long 18 | 19 | @Parcelize 20 | @Serializable 21 | data class Location( 22 | @SerialName("id") 23 | @ColumnInfo(name = "id") 24 | val id: LocationId = 0L, 25 | 26 | @SerialName("name") 27 | @ColumnInfo(name = "name") 28 | val name: String = UNKNOWN_ITEM, 29 | 30 | @SerialName("type") 31 | @ColumnInfo(name = "type") 32 | val type: String = "", 33 | 34 | @SerialName("dimension") 35 | @ColumnInfo(name = "dimension") 36 | val dimension: String = "", 37 | 38 | @SerialName("residents") 39 | @ColumnInfo(name = "residents") 40 | val residents: List = emptyList(), 41 | 42 | @Ignore 43 | @Transient 44 | val residentsCharacters: List = emptyList(), 45 | 46 | @SerialName("url") 47 | @ColumnInfo(name = "url") 48 | val url: String = "", 49 | 50 | @SerialName("params") 51 | @ColumnInfo(name = "params") 52 | override var params: String = defaultParams, 53 | 54 | @SerialName("page") 55 | @ColumnInfo(name = "page") 56 | override var page: Int = defaultPage, 57 | 58 | @PrimaryKey 59 | val primaryKey: String = "", 60 | 61 | @SerialName("search_index") 62 | @ColumnInfo(name = "search_index") 63 | val searchIndex: Int = 0, 64 | ) : BasePaginatedEntity(), Parcelable { 65 | 66 | override fun getIdentifier() = id.toString() 67 | 68 | val isUnknown get() = name.isBlank() || name.isUnknown() 69 | } 70 | -------------------------------------------------------------------------------- /modules/core-domain/src/main/java/tm/alashow/rickmorty/domain/models/CharactersApiResponse.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2022, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.rickmorty.domain.models 6 | 7 | import kotlinx.serialization.SerialName 8 | import kotlinx.serialization.Serializable 9 | import tm.alashow.rickmorty.domain.entities.Character 10 | 11 | @Serializable 12 | data class CharactersApiResponse( 13 | @SerialName("info") 14 | val paginationInfo: PaginationInfo = PaginationInfo(), 15 | 16 | @SerialName("results") 17 | val results: List = emptyList(), 18 | ) : PaginatedApiResponse() { 19 | override val isSuccessful = true 20 | } 21 | -------------------------------------------------------------------------------- /modules/core-domain/src/main/java/tm/alashow/rickmorty/domain/models/PaginatedApiResponse.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.rickmorty.domain.models 6 | 7 | import kotlinx.serialization.SerialName 8 | import kotlinx.serialization.Serializable 9 | import tm.alashow.rickmorty.domain.models.errors.ApiErrorException 10 | 11 | interface ApiResponse { 12 | val isSuccessful: Boolean 13 | } 14 | 15 | @Serializable 16 | abstract class PaginatedApiResponse() : ApiResponse { 17 | 18 | @Serializable 19 | data class PaginationInfo( 20 | @SerialName("count") 21 | val totalItems: Int = 0, 22 | 23 | @SerialName("pages") 24 | val pages: Int = 0, 25 | 26 | @SerialName("next") 27 | val nextPageUrl: String? = null, 28 | 29 | @SerialName("prev") 30 | val previousPageUrl: String? = null, 31 | ) 32 | } 33 | 34 | fun T.checkForErrors(): T = if (isSuccessful) this 35 | else throw ApiErrorException() 36 | -------------------------------------------------------------------------------- /modules/core-domain/src/main/java/tm/alashow/rickmorty/domain/models/errors/ApiErrorException.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.rickmorty.domain.models.errors 6 | 7 | import androidx.annotation.StringRes 8 | 9 | open class ApiErrorException( 10 | override val message: String? = null, 11 | 12 | @StringRes 13 | open val errorRes: Int? = null 14 | ) : RuntimeException("API returned an error: $message") { 15 | override fun toString() = message ?: super.toString() 16 | } 17 | -------------------------------------------------------------------------------- /modules/core-domain/src/main/java/tm/alashow/rickmorty/domain/models/errors/CommonApiExceptions.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2018, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.rickmorty.domain.models.errors 6 | 7 | class NotFoundException(override val message: String = "Not found") : ApiErrorException(message) 8 | class EmptyResultException(override val message: String = "Result was empty") : ApiErrorException(message) 9 | 10 | fun List?.throwOnEmpty() = if (isNullOrEmpty()) throw EmptyResultException() else this 11 | 12 | fun Result>.requireNonEmpty(condition: () -> Boolean = { true }): List { 13 | return getOrThrow().apply { if (condition()) throwOnEmpty() } 14 | } 15 | -------------------------------------------------------------------------------- /modules/i18n/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /modules/i18n/build.gradle: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2019, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | 6 | import tm.alashow.buildSrc.App 7 | 8 | plugins { 9 | id "com.android.library" 10 | id "kotlin-android" 11 | } 12 | 13 | android { 14 | compileSdkVersion App.compileSdkVersion 15 | 16 | defaultConfig { 17 | minSdkVersion App.minSdkVersion 18 | } 19 | } -------------------------------------------------------------------------------- /modules/i18n/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 5 | 6 | -------------------------------------------------------------------------------- /modules/i18n/src/main/java/tm/alashow/i18n/CommonValidationErrors.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2019, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.i18n 6 | 7 | // TODO: simplify error typing 8 | 9 | object DatabaseNotFoundError : ValidationErrorException(ValidationError(UiMessage.Resource(R.string.error_database_validation_notFound))) 10 | object DatabaseError : ValidationErrorException(ValidationError(UiMessage.Resource(R.string.error_database))) 11 | object DatabaseInsertError : ValidationErrorException(ValidationError(UiMessage.Resource(R.string.error_database))) 12 | object DatabaseUpdateError : ValidationErrorException(ValidationError(UiMessage.Resource(R.string.error_database))) 13 | 14 | object LoadingError : ValidationErrorException(ValidationError(UiMessage.Resource(R.string.error_loading))) 15 | 16 | object ValidationErrorUnknown : ValidationErrorException(ValidationError(UiMessage.Resource(R.string.error_unknown))) 17 | open class ValidationErrorBlank : ValidationErrorException(ValidationError(UiMessage.Resource(R.string.error_blank))) 18 | open class ValidationErrorTooShort : ValidationErrorException(ValidationError(UiMessage.Resource(R.string.error_validation_textShort))) 19 | open class ValidationErrorTooLong : ValidationErrorException(ValidationError(UiMessage.Resource(R.string.error_validation_textLong))) 20 | -------------------------------------------------------------------------------- /modules/i18n/src/main/java/tm/alashow/i18n/TextCreator.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.i18n 6 | 7 | import android.content.res.Resources 8 | 9 | interface TextCreator { 10 | fun Params.localize(resources: Resources): String 11 | } 12 | -------------------------------------------------------------------------------- /modules/i18n/src/main/java/tm/alashow/i18n/UiMessage.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.i18n 6 | 7 | import java.util.Collections.emptyList 8 | 9 | sealed class UiMessage(open val value: T) { 10 | data class Plain(override val value: String) : UiMessage(value) 11 | data class Resource(override val value: Int, val formatArgs: List = emptyList()) : UiMessage(value) 12 | data class Error(override val value: Throwable) : UiMessage(value) 13 | } 14 | 15 | interface UiMessageConvertable { 16 | fun toUiMessage(): UiMessage<*> 17 | } 18 | -------------------------------------------------------------------------------- /modules/i18n/src/main/java/tm/alashow/i18n/Validation.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2019, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.i18n 6 | 7 | open class ValidationErrorException(val error: ValidationError, override val message: String? = error.toUiMessage().toString()) : Exception(message), UiMessageConvertable { 8 | override fun toUiMessage() = error.toUiMessage() 9 | 10 | companion object { 11 | fun of(error: ValidationError, message: String? = null) = ValidationErrorException(error, message) 12 | } 13 | } 14 | 15 | open class ValidationError(val message: UiMessage<*>) : UiMessageConvertable { 16 | override fun toUiMessage() = message 17 | fun error(message: String? = null) = ValidationErrorException(this, message) 18 | 19 | open fun isValid() = true 20 | open fun validate() { 21 | if (!isValid()) throw error() 22 | } 23 | } 24 | 25 | fun Throwable.asValidationError() = when (this) { 26 | is ValidationErrorException -> error 27 | else -> ValidationErrorUnknown.error 28 | } 29 | 30 | typealias ValidationErrors = ArrayList 31 | 32 | fun ValidationErrors.isValid() = isEmpty() 33 | fun ValidationError.toErrors(): ValidationErrors = arrayListOf(this) 34 | -------------------------------------------------------------------------------- /modules/i18n/src/main/res/values/app_strings.xml: -------------------------------------------------------------------------------- 1 | 5 | 6 | Rick and Morty 7 | Rick and Morty 8 | Rick and Morty 9 | 10 | Characters 11 | 12 | Filters 13 | Status 14 | Species 15 | Type 16 | Gender 17 | Origin 18 | Last seen location 19 | None 20 | 21 | Character 22 | Name 23 | Status 24 | Species 25 | Type 26 | Origin location 27 | Origin location residents 28 | Last seen location 29 | Name 30 | Type 31 | Dimension 32 | Number of Residents 33 | Last seen location residents 34 | -------------------------------------------------------------------------------- /modules/i18n/src/main/res/values/donottranslate.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | %1$dx 9 | -------------------------------------------------------------------------------- /modules/ui-characters/build.gradle: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | 6 | 7 | import tm.alashow.buildSrc.App 8 | import tm.alashow.buildSrc.Deps 9 | 10 | plugins { 11 | id "com.android.library" 12 | id "dagger.hilt.android.plugin" 13 | id "kotlin-android" 14 | id "kotlin-kapt" 15 | id "kotlin-parcelize" 16 | } 17 | 18 | android { 19 | compileSdkVersion App.compileSdkVersion 20 | 21 | defaultConfig { 22 | minSdkVersion App.minSdkVersion 23 | } 24 | 25 | compileOptions { 26 | sourceCompatibility JavaVersion.VERSION_1_8 27 | targetCompatibility JavaVersion.VERSION_1_8 28 | } 29 | 30 | buildFeatures { 31 | compose = true 32 | } 33 | 34 | composeOptions { 35 | kotlinCompilerExtensionVersion Deps.Android.Compose.compilerVersion 36 | } 37 | } 38 | 39 | repositories { 40 | mavenCentral() 41 | } 42 | 43 | dependencies { 44 | 45 | implementation project(":modules:common-compose") 46 | implementation project(":modules:common-ui-theme") 47 | implementation project(":modules:common-ui-components") 48 | implementation project(':modules:ui-navigation') 49 | 50 | implementation project(":modules:core-characters") 51 | 52 | implementation Deps.Dagger.hilt 53 | kapt Deps.Dagger.compiler 54 | kapt Deps.Dagger.hiltCompiler 55 | } 56 | -------------------------------------------------------------------------------- /modules/ui-characters/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /modules/ui-characters/src/main/java/tm/alashow/rickmorty/ui/character/components/CharacterStatusDot.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2022, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.rickmorty.ui.character.components 6 | 7 | import androidx.compose.foundation.background 8 | import androidx.compose.foundation.layout.* 9 | import androidx.compose.foundation.shape.CircleShape 10 | import androidx.compose.material.* 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.draw.clip 14 | import androidx.compose.ui.graphics.Color 15 | import androidx.compose.ui.tooling.preview.Preview 16 | import tm.alashow.rickmorty.domain.entities.Character 17 | import tm.alashow.ui.theme.AppTheme 18 | 19 | @Composable 20 | @Preview 21 | fun CharacterStatusDot( 22 | modifier: Modifier = Modifier, 23 | character: Character = Character(), 24 | ) { 25 | CharacterStatusDot( 26 | isAlive = character.isAlive, 27 | isDead = character.isDead, 28 | modifier = modifier, 29 | ) 30 | } 31 | 32 | @Composable 33 | fun CharacterStatusDot( 34 | isAlive: Boolean, 35 | isDead: Boolean, 36 | modifier: Modifier = Modifier, 37 | ) { 38 | Spacer( 39 | modifier 40 | .size(AppTheme.specs.paddingSmall) 41 | .clip(CircleShape) 42 | .background( 43 | when { 44 | isAlive -> Color.Green 45 | isDead -> Color.Red 46 | else -> Color.Gray 47 | } 48 | ) 49 | ) 50 | } 51 | -------------------------------------------------------------------------------- /modules/ui-characters/src/main/java/tm/alashow/rickmorty/ui/character/detail/CharacterDetailRow.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2022, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.rickmorty.ui.character.detail 6 | 7 | import androidx.compose.foundation.layout.Arrangement 8 | import androidx.compose.foundation.layout.Row 9 | import androidx.compose.foundation.layout.RowScope 10 | import androidx.compose.foundation.layout.padding 11 | import androidx.compose.material.MaterialTheme 12 | import androidx.compose.material.Text 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.ui.Alignment 15 | import androidx.compose.ui.Modifier 16 | import androidx.compose.ui.text.TextStyle 17 | import androidx.compose.ui.text.font.FontWeight 18 | import com.google.accompanist.placeholder.material.placeholder 19 | import tm.alashow.ui.components.shimmer 20 | import tm.alashow.ui.theme.AppTheme 21 | 22 | @Composable 23 | internal fun CharacterDetailRow( 24 | label: String, 25 | value: String, 26 | modifier: Modifier = Modifier, 27 | isDetailsLoading: Boolean = false, 28 | separator: @Composable RowScope.() -> Unit = {}, 29 | labelStyle: TextStyle = MaterialTheme.typography.body1, 30 | valueStyle: TextStyle = MaterialTheme.typography.body1, 31 | ) { 32 | val hasValue = value.isNotBlank() 33 | val loadingModifier = Modifier.placeholder( 34 | visible = !hasValue && isDetailsLoading, 35 | highlight = shimmer(), 36 | ) 37 | if (hasValue || isDetailsLoading) 38 | Row( 39 | horizontalArrangement = Arrangement.spacedBy(AppTheme.specs.paddingSmall), 40 | verticalAlignment = Alignment.CenterVertically, 41 | modifier = modifier 42 | .padding(vertical = AppTheme.specs.paddingTiny) 43 | .then(loadingModifier) 44 | ) { 45 | Text( 46 | text = "$label:", 47 | style = labelStyle, 48 | fontWeight = FontWeight.Bold 49 | ) 50 | separator() 51 | Text( 52 | text = value.replaceFirstChar { it.uppercase() }, 53 | style = valueStyle 54 | ) 55 | } 56 | } 57 | 58 | @Composable 59 | internal fun CharacterDetailLabel(label: String, modifier: Modifier = Modifier) { 60 | Text( 61 | text = label, 62 | style = MaterialTheme.typography.h6, 63 | fontWeight = FontWeight.Bold, 64 | modifier = modifier.padding( 65 | top = AppTheme.specs.padding, 66 | bottom = AppTheme.specs.paddingSmall 67 | ) 68 | ) 69 | } 70 | -------------------------------------------------------------------------------- /modules/ui-characters/src/main/java/tm/alashow/rickmorty/ui/character/detail/CharacterDetailViewModel.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.rickmorty.ui.character.detail 6 | 7 | import androidx.lifecycle.SavedStateHandle 8 | import androidx.lifecycle.ViewModel 9 | import androidx.lifecycle.viewModelScope 10 | import dagger.hilt.android.lifecycle.HiltViewModel 11 | import javax.inject.Inject 12 | import kotlinx.coroutines.flow.collect 13 | import kotlinx.coroutines.flow.combine 14 | import kotlinx.coroutines.launch 15 | import tm.alashow.base.ui.SnackbarAction 16 | import tm.alashow.base.ui.SnackbarManager 17 | import tm.alashow.base.ui.SnackbarMessage 18 | import tm.alashow.base.util.extensions.stateInDefault 19 | import tm.alashow.base.util.toUiMessage 20 | import tm.alashow.domain.models.Fail 21 | import tm.alashow.i18n.UiMessage 22 | import tm.alashow.navigation.screens.CHARACTER_ID_KEY 23 | import tm.alashow.rickmorty.data.interactors.character.GetCharacterDetails 24 | import tm.alashow.rickmorty.data.observers.character.ObserveCharacter 25 | import tm.alashow.rickmorty.data.observers.character.ObserveCharacterDetails 26 | import tm.alashow.rickmorty.ui.character.R 27 | 28 | data class DetailsFailedToLoadMessage(val error: Throwable) : SnackbarMessage( 29 | message = error.toUiMessage(), 30 | action = SnackbarAction( 31 | label = UiMessage.Resource(R.string.error_retry), 32 | argument = error 33 | ) 34 | ) 35 | 36 | @HiltViewModel 37 | class CharacterDetailViewModel @Inject constructor( 38 | private val handle: SavedStateHandle, 39 | private val character: ObserveCharacter, 40 | private val characterDetails: ObserveCharacterDetails, 41 | private val snackbarManager: SnackbarManager, 42 | ) : ViewModel() { 43 | 44 | private val characterParams = handle.get(CHARACTER_ID_KEY) ?: -1 45 | 46 | val state = combine(character.flow, characterDetails.asyncFlow, ::CharacterDetailViewState) 47 | .stateInDefault(viewModelScope, CharacterDetailViewState.EMPTY) 48 | 49 | init { 50 | load() 51 | observeDetailErrors() 52 | } 53 | 54 | private fun load(forceRefresh: Boolean = false) { 55 | character(characterParams) 56 | if (forceRefresh) 57 | characterDetails(GetCharacterDetails.Params(-1)) 58 | characterDetails(GetCharacterDetails.Params(characterParams, forceRefresh)) 59 | } 60 | 61 | private fun observeDetailErrors() = viewModelScope.launch { 62 | characterDetails.asyncFlow.collect { 63 | if (it is Fail) { 64 | val failedToLoadMessage = DetailsFailedToLoadMessage(it.error) 65 | snackbarManager.addMessage(failedToLoadMessage) 66 | if (snackbarManager.observeMessageAction(failedToLoadMessage) != null) { 67 | refresh() 68 | } 69 | } 70 | } 71 | } 72 | 73 | fun refresh() = load(true) 74 | } 75 | -------------------------------------------------------------------------------- /modules/ui-characters/src/main/java/tm/alashow/rickmorty/ui/character/detail/CharacterDetailViewState.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2022, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.rickmorty.ui.character.detail 6 | 7 | import tm.alashow.domain.models.* 8 | import tm.alashow.rickmorty.domain.entities.Character 9 | 10 | data class CharacterDetailViewState( 11 | val character: Character? = null, 12 | val characterDetails: Async = Uninitialized, 13 | ) { 14 | val isLoaded = (characterDetails() ?: character) != null 15 | val isDetailsLoading = characterDetails is Loading 16 | 17 | companion object { 18 | val EMPTY = CharacterDetailViewState() 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /modules/ui-characters/src/main/java/tm/alashow/rickmorty/ui/character/list/CharactersViewState.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2022, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.rickmorty.ui.character.list 6 | 7 | import tm.alashow.rickmorty.data.CharactersParams 8 | 9 | data class CharactersViewState( 10 | val filterOptions: CharactersParams.FilterOptions = CharactersParams.FilterOptions(), 11 | val filters: CharactersParams.Filters = CharactersParams.Filters(), 12 | val error: Throwable? = null, 13 | ) { 14 | 15 | companion object { 16 | val EMPTY = CharactersViewState() 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /modules/ui-navigation/build.gradle: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | 6 | 7 | import tm.alashow.buildSrc.App 8 | import tm.alashow.buildSrc.Deps 9 | 10 | plugins { 11 | id "com.android.library" 12 | id "dagger.hilt.android.plugin" 13 | id "kotlin-android" 14 | id "kotlin-kapt" 15 | } 16 | 17 | android { 18 | compileSdkVersion App.compileSdkVersion 19 | 20 | defaultConfig { 21 | minSdkVersion App.minSdkVersion 22 | } 23 | 24 | compileOptions { 25 | sourceCompatibility JavaVersion.VERSION_1_8 26 | targetCompatibility JavaVersion.VERSION_1_8 27 | } 28 | 29 | buildFeatures { 30 | compose = true 31 | } 32 | 33 | composeOptions { 34 | kotlinCompilerExtensionVersion Deps.Android.Compose.compilerVersion 35 | } 36 | } 37 | 38 | repositories { 39 | mavenCentral() 40 | } 41 | 42 | dependencies { 43 | implementation project(":modules:common-compose") 44 | implementation project(":modules:core-domain") 45 | implementation Deps.Testing.turbine 46 | 47 | implementation Deps.Dagger.hilt 48 | kapt Deps.Dagger.compiler 49 | kapt Deps.Dagger.hiltCompiler 50 | } 51 | -------------------------------------------------------------------------------- /modules/ui-navigation/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /modules/ui-navigation/src/main/java/tm/alashow/navigation/BottomSheetNavigator.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.navigation 6 | 7 | import androidx.compose.animation.core.AnimationSpec 8 | import androidx.compose.material.ExperimentalMaterialApi 9 | import androidx.compose.material.ModalBottomSheetValue 10 | import androidx.compose.material.SwipeableDefaults 11 | import androidx.compose.material.rememberModalBottomSheetState 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.runtime.LaunchedEffect 14 | import androidx.compose.runtime.remember 15 | import androidx.compose.runtime.snapshotFlow 16 | import com.google.accompanist.navigation.material.BottomSheetNavigator 17 | import com.google.accompanist.navigation.material.ExperimentalMaterialNavigationApi 18 | import kotlinx.coroutines.InternalCoroutinesApi 19 | import kotlinx.coroutines.flow.collectLatest 20 | 21 | @OptIn(ExperimentalMaterialApi::class, ExperimentalMaterialNavigationApi::class, InternalCoroutinesApi::class) 22 | @Composable 23 | fun rememberBottomSheetNavigator( 24 | animationSpec: AnimationSpec = SwipeableDefaults.AnimationSpec, 25 | skipHalfExpanded: Boolean = true, 26 | ): BottomSheetNavigator { 27 | val sheetState = rememberModalBottomSheetState( 28 | ModalBottomSheetValue.Hidden, 29 | animationSpec 30 | ) 31 | 32 | if (skipHalfExpanded) { 33 | LaunchedEffect(sheetState) { 34 | snapshotFlow { sheetState.isAnimationRunning } 35 | .collectLatest { 36 | with(sheetState) { 37 | val isOpening = currentValue == ModalBottomSheetValue.Hidden && targetValue == ModalBottomSheetValue.HalfExpanded 38 | val isClosing = currentValue == ModalBottomSheetValue.Expanded && targetValue == ModalBottomSheetValue.HalfExpanded 39 | when { 40 | isOpening -> animateTo(ModalBottomSheetValue.Expanded) 41 | isClosing -> animateTo(ModalBottomSheetValue.Hidden) 42 | } 43 | } 44 | } 45 | } 46 | } 47 | 48 | return remember(sheetState) { 49 | BottomSheetNavigator(sheetState = sheetState) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /modules/ui-navigation/src/main/java/tm/alashow/navigation/NavigationModule.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.navigation 6 | 7 | import dagger.Module 8 | import dagger.Provides 9 | import dagger.hilt.InstallIn 10 | import dagger.hilt.components.SingletonComponent 11 | import javax.inject.Singleton 12 | 13 | @Module 14 | @InstallIn(SingletonComponent::class) 15 | class NavigationModule { 16 | 17 | @Singleton 18 | @Provides 19 | fun navigator(): Navigator = Navigator() 20 | } 21 | -------------------------------------------------------------------------------- /modules/ui-navigation/src/main/java/tm/alashow/navigation/Navigator.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.navigation 6 | 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.runtime.CompositionLocalProvider 9 | import androidx.compose.runtime.staticCompositionLocalOf 10 | import androidx.hilt.navigation.compose.hiltViewModel 11 | import kotlinx.coroutines.channels.Channel 12 | import kotlinx.coroutines.flow.receiveAsFlow 13 | import tm.alashow.navigation.screens.ROOT_SCREENS 14 | 15 | val LocalNavigator = staticCompositionLocalOf { 16 | error("No LocalNavigator given") 17 | } 18 | 19 | @Composable 20 | fun NavigatorHost( 21 | viewModel: NavigatorViewModel = hiltViewModel(), 22 | content: @Composable () -> Unit 23 | ) { 24 | CompositionLocalProvider(LocalNavigator provides viewModel.navigator, content = content) 25 | } 26 | 27 | sealed class NavigationEvent(open val route: String) { 28 | object Back : NavigationEvent("Back") 29 | data class Destination(override val route: String, val root: String? = null) : NavigationEvent(route) 30 | 31 | override fun toString() = route 32 | } 33 | 34 | class Navigator { 35 | private val navigationQueue = Channel(Channel.CONFLATED) 36 | 37 | fun navigate(route: String) { 38 | val basePath = route.split("/").firstOrNull() 39 | val root = if (ROOT_SCREENS.any { it.route == basePath }) basePath else null 40 | navigationQueue.trySend(NavigationEvent.Destination(route, root)) 41 | } 42 | 43 | fun goBack() { 44 | navigationQueue.trySend(NavigationEvent.Back) 45 | } 46 | 47 | val queue = navigationQueue.receiveAsFlow() 48 | } 49 | -------------------------------------------------------------------------------- /modules/ui-navigation/src/main/java/tm/alashow/navigation/NavigatorExtensions.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.navigation 6 | 7 | import app.cash.turbine.test 8 | 9 | suspend fun Navigator.assertNextRouteContains(vararg expectedValues: String?) = queue.test { 10 | val newRoute = awaitItem().route 11 | expectedValues.filterNotNull().forEach { 12 | assert(newRoute.contains(it)) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /modules/ui-navigation/src/main/java/tm/alashow/navigation/NavigatorViewModel.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | package tm.alashow.navigation 6 | 7 | import androidx.lifecycle.SavedStateHandle 8 | import androidx.lifecycle.ViewModel 9 | import dagger.hilt.android.lifecycle.HiltViewModel 10 | import javax.inject.Inject 11 | 12 | @HiltViewModel 13 | class NavigatorViewModel @Inject constructor( 14 | val navigator: Navigator, 15 | private val handle: SavedStateHandle, 16 | ) : ViewModel() 17 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | 6 | include ":modules:i18n" 7 | include ":modules:base" 8 | include ":modules:base-android" 9 | include ":modules:common-testing" 10 | include ":modules:common-domain" 11 | include ":modules:common-data" 12 | include ":modules:core-domain" 13 | include ":modules:core-data" 14 | include ":modules:core-characters" 15 | include ":modules:common-compose" 16 | include ":modules:common-ui-theme" 17 | include ":modules:common-ui-components" 18 | include ":modules:ui-navigation" 19 | include ":modules:ui-characters" 20 | include ":app" 21 | -------------------------------------------------------------------------------- /signing/alashov-debug.jks: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alashow/rick-and-morty/976d1ccd21a81a1219852ac6ba6928aa272dab9a/signing/alashov-debug.jks -------------------------------------------------------------------------------- /spotless/copyright.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) $YEAR, Alashov Berkeli 3 | * All rights reserved. 4 | */ 5 | --------------------------------------------------------------------------------