diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 303005d..1f1e2ab 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -13,19 +13,20 @@ jobs: timeout-minutes: 25 steps: - - name: Checkout repo - uses: actions/checkout@v1 + - name: Checkout + uses: actions/checkout@v3 - - name: set up JDK 11 - uses: actions/setup-java@v1 + - name: Setup JDK + uses: actions/setup-java@v3 with: - java-version: 11 + distribution: 'adopt' + java-version: 17 - name: Test and Build run: ./gradlew build - name: Generate Code-Coverage report - run: ./gradlew jacoco + run: ./gradlew createDebugUnitTestCoverageReport - name: Push Code-Coverage Report to codecov.io env: diff --git a/.gitignore b/.gitignore index c91a391..4202a53 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,8 @@ captures/ .idea/deploymentTargetDropDown.xml # Comment next line if keeping position of elements in Navigation Editor is relevant for you .idea/navEditor.xml +# GitHub Copilot +.idea/copilot # Keystore files # Uncomment the following lines if you do not want to check your keystore files in. @@ -85,3 +87,5 @@ lint/generated/ lint/outputs/ lint/tmp/ # lint/reports/ + +.DS_Store \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..8f00030 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,5 @@ +# Default ignored files +/shelf/ +/workspace.xml +# GitHub Copilot persisted chat sessions +/copilot/chatSessions diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml index 7829f3c..ab75be2 100644 --- a/.idea/codeStyles/Project.xml +++ b/.idea/codeStyles/Project.xml @@ -1,16 +1,11 @@ - - + \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml index 6e6eec1..79ee123 100644 --- a/.idea/codeStyles/codeStyleConfig.xml +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -1,6 +1,5 @@ \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml index fb7f4a8..b589d56 100644 --- a/.idea/compiler.xml +++ b/.idea/compiler.xml @@ -1,6 +1,6 @@ - + \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml new file mode 100644 index 0000000..b268ef3 --- /dev/null +++ b/.idea/deploymentTargetSelector.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/detekt.xml b/.idea/detekt.xml deleted file mode 100644 index d7ca9af..0000000 --- a/.idea/detekt.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - true - true - true - true - - \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml index 9029a53..44ca2d9 100644 --- a/.idea/inspectionProfiles/Project_Default.xml +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -1,7 +1,41 @@ \ No newline at end of file diff --git a/.idea/inspectionProfiles/ktlint.xml b/.idea/inspectionProfiles/ktlint.xml deleted file mode 100644 index 7d04a74..0000000 --- a/.idea/inspectionProfiles/ktlint.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml deleted file mode 100644 index 64580d1..0000000 --- a/.idea/inspectionProfiles/profiles_settings.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - diff --git a/.idea/jarRepositories.xml b/.idea/jarRepositories.xml deleted file mode 100644 index e34606c..0000000 --- a/.idea/jarRepositories.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml new file mode 100644 index 0000000..fdf8d99 --- /dev/null +++ b/.idea/kotlinc.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/migrations.xml b/.idea/migrations.xml new file mode 100644 index 0000000..f8051a6 --- /dev/null +++ b/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/other.xml b/.idea/other.xml new file mode 100644 index 0000000..0d3a1fb --- /dev/null +++ b/.idea/other.xml @@ -0,0 +1,263 @@ + + + + + + \ No newline at end of file diff --git a/README.md b/README.md index 75ad4d2..4f8eaa6 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ This repository contains Rick and Morty Android application which I am using as training material. -![Screenshot](SCREENSHOT1.png) ![Screenshot](SCREENSHOT2.png) +Screenshot Screenshot Screenshot ## User Stories @@ -76,7 +76,7 @@ Code is covered by unit tests implemented using **Mockito** and **Kluent**. Also ## License -Copyright 2020 Mohsen Mirhoseini Argi +Copyright 2020 Mohsen Mirhoseini Licensed to the Apache Software Foundation (ASF) under one or more contributor license agreements. See the NOTICE file distributed with this work for additional information regarding copyright ownership. The ASF licenses this file to you under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at diff --git a/SCREENSHOT1.png b/SCREENSHOT1.png index 3abf7d4..c1508c7 100644 Binary files a/SCREENSHOT1.png and b/SCREENSHOT1.png differ diff --git a/SCREENSHOT2.png b/SCREENSHOT2.png index ee5e11d..d7d05d8 100644 Binary files a/SCREENSHOT2.png and b/SCREENSHOT2.png differ diff --git a/SCREENSHOT3.png b/SCREENSHOT3.png index 078e0e1..aa0dcac 100644 Binary files a/SCREENSHOT3.png and b/SCREENSHOT3.png differ diff --git a/app/.gitignore b/app/.gitignore index 796b96d..42afabf 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -1 +1 @@ -/build +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e868a0b..b2d76c5 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,198 +1,109 @@ -import org.jlleitschuh.gradle.ktlint.reporter.ReporterType - plugins { - id("com.android.application") - kotlin("android") - kotlin("kapt") - kotlin("plugin.serialization") version "1.5.31" - id("org.jetbrains.dokka") version "1.5.30" - id("androidx.navigation.safeargs.kotlin") - - id("io.gitlab.arturbosch.detekt") version "1.18.1" - id("org.jlleitschuh.gradle.ktlint") version "10.2.0" - - jacoco + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.ksp) } android { - compileSdk = 31 + namespace = "com.mohsenoid.rickandmorty" + compileSdk = 34 defaultConfig { applicationId = "com.mohsenoid.rickandmorty" - minSdk = 24 - targetSdk = 31 - multiDexEnabled = true - - versionCode = 10 - versionName = "2.7.0" + targetSdk = 34 + versionCode = 1 + versionName = "1.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - } - buildTypes { - defaultConfig { - buildConfigField("String", "BASE_URL", "\"https://rickandmortyapi.com/api/\"") + vectorDrawables { + useSupportLibrary = true } + buildConfigField("String", "API_BASE_URL", "\"https://rickandmortyapi.com/api/\"") + signingConfig = signingConfigs.getByName("debug") + } + + buildTypes { debug { isMinifyEnabled = false - isTestCoverageEnabled = true + enableUnitTestCoverage = true } + release { isMinifyEnabled = false proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" + "proguard-rules.pro", ) } } - - sourceSets { - getByName("main") { - java { - srcDirs("src/main/kotlin") - srcDirs("${buildDir.absolutePath}/generated/source/kaptKotlin/") - } - } - getByName("test") { - java { - srcDirs("src/test/kotlin") - } - } - getByName("androidTest") { - java { - srcDirs("src/androidTest/kotlin") - } - } - } - - testOptions { - execution = "ANDROIDX_TEST_ORCHESTRATOR" - animationsDisabled = true - - unitTests { - isIncludeAndroidResources = true - } - } - - buildFeatures { - dataBinding = true - } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 } - kotlinOptions { - allWarningsAsErrors = true - jvmTarget = JavaVersion.VERSION_11.toString() - freeCompilerArgs = listOf("-Xopt-in=kotlin.RequiresOptIn") + jvmTarget = "1.8" } - - lint { - isIgnoreWarnings = false - isWarningsAsErrors = true + buildFeatures { + buildConfig = true + compose = true } -} - -// https://arturbosch.github.io/detekt/#quick-start-with-gradle -detekt { - allRules = true - source = files("src/main/kotlin/") - baseline = file("detekt-baseline.xml") - buildUponDefaultConfig = true - reports { - html { enabled = true } - xml { enabled = true } - txt { enabled = false } + composeOptions { + kotlinCompilerExtensionVersion = "1.5.1" } -} - -// https://github.com/JLLeitschuh/ktlint-gradle#configuration -ktlint { - reporters { - reporter(ReporterType.HTML) - reporter(ReporterType.CHECKSTYLE) + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } } } -// https://docs.gradle.org/current/userguide/jacoco_plugin.html -jacoco { - toolVersion = "0.8.7" -} - dependencies { - // Android Jetpack - implementation("androidx.appcompat:appcompat:1.4.0") - implementation("androidx.core:core-ktx:1.7.0") - implementation("androidx.recyclerview:recyclerview:1.2.1") - implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0") - implementation("androidx.cardview:cardview:1.0.0") - implementation("androidx.multidex:multidex:2.0.1") - testImplementation("androidx.test:core:1.4.0") - testImplementation("androidx.arch.core:core-testing:2.1.0") - androidTestImplementation("androidx.test.ext:junit:1.1.3") - androidTestImplementation("androidx.test.espresso:espresso-core:3.4.0") - - // Kotlin - implementation(platform("org.jetbrains.kotlin:kotlin-bom:1.5.31")) - implementation("org.jetbrains.kotlin:kotlin-reflect") - implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8") - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.1") - - implementation(platform("org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.5.2")) - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android") - testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test") - - dokkaHtmlPlugin("org.jetbrains.dokka:kotlin-as-java-plugin:1.5.30") - - // Test - testImplementation("junit:junit:4.13.2") - testImplementation("org.robolectric:robolectric:4.7.2") - testImplementation("io.mockk:mockk:1.12.1") + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + + // Compose + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.material.icons.extended) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.navigation.compose) + debugImplementation(libs.androidx.compose.ui.tooling) + debugImplementation(libs.androidx.compose.ui.test.manifest) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) // Koin - val koinVersion = "3.1.4" - implementation("io.insert-koin:koin-android:$koinVersion") - implementation("io.insert-koin:koin-android-compat:$koinVersion") - testImplementation("io.insert-koin:koin-test:$koinVersion") + implementation(libs.koin.core) + implementation(libs.koin.android) + implementation(libs.koin.androidx.compose) + testImplementation(platform(libs.koin.bom)) + testImplementation(libs.koin.test) // Retrofit - implementation("com.squareup.retrofit2:retrofit:2.9.0") - implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:0.8.0") - val okhttpVersion = "4.9.3" - implementation("com.squareup.okhttp3:okhttp:$okhttpVersion") - implementation("com.squareup.okhttp3:logging-interceptor:$okhttpVersion") + implementation(libs.bundles.retrofit) + implementation(libs.okhttp3.logging.interceptor) // Room - val roomVersion = "2.3.0" - implementation("androidx.room:room-runtime:$roomVersion") - kapt("androidx.room:room-compiler:$roomVersion") - implementation("androidx.room:room-ktx:$roomVersion") - - // Navigation component - val navigationComponentVersion = "2.3.5" - implementation("androidx.navigation:navigation-fragment-ktx:$navigationComponentVersion") - implementation("androidx.navigation:navigation-ui-ktx:$navigationComponentVersion") - implementation("androidx.navigation:navigation-dynamic-features-fragment:$navigationComponentVersion") - androidTestImplementation("androidx.navigation:navigation-testing:$navigationComponentVersion") - - // timber - implementation("com.jakewharton.timber:timber:5.0.1") - - // picasso - implementation("com.squareup.picasso:picasso:2.71828") - - // chucker - val chuckerVersion = "3.5.2" - debugImplementation("com.github.chuckerteam.chucker:library:$chuckerVersion") - releaseImplementation("com.github.chuckerteam.chucker:library-no-op:$chuckerVersion") - - // leakcanary - debugImplementation("com.squareup.leakcanary:leakcanary-android:2.7") - - // Startup - implementation("androidx.startup:startup-runtime:1.1.0") + implementation(libs.androidx.room.runtime) + implementation(libs.androidx.room.ktx) + annotationProcessor(libs.androidx.room.compiler) + ksp(libs.androidx.room.compiler) + implementation(platform(libs.koin.bom)) + + // Coil + implementation(libs.coil) + + testImplementation(libs.junit) + + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + } diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index f1b4245..481bb43 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -18,4 +18,4 @@ # If you keep the line number information, uncomment this to # hide the original source file name. -#-renamesourcefileattribute SourceFile +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/androidTest/java/com/mohsenoid/rickandmorty/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/mohsenoid/rickandmorty/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..b449f99 --- /dev/null +++ b/app/src/androidTest/java/com/mohsenoid/rickandmorty/ExampleInstrumentedTest.kt @@ -0,0 +1,22 @@ +package com.mohsenoid.rickandmorty + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.mohsenoid.rickandmorty", appContext.packageName) + } +} diff --git a/app/src/debug/res/xml/network_security_config.xml b/app/src/debug/res/xml/network_security_config.xml deleted file mode 100644 index f17a19f..0000000 --- a/app/src/debug/res/xml/network_security_config.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 576c956..23d891c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,44 +1,30 @@ + xmlns:tools="http://schemas.android.com/tools"> - - - + + android:theme="@style/Theme.RickAndMorty"> - - - - - - - - + diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..2bf9244 Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ diff --git a/app/src/main/ic_launcher-web.png b/app/src/main/ic_launcher-web.png deleted file mode 100644 index 46f3584..0000000 Binary files a/app/src/main/ic_launcher-web.png and /dev/null differ diff --git a/app/src/main/java/com/mohsenoid/rickandmorty/AppModule.kt b/app/src/main/java/com/mohsenoid/rickandmorty/AppModule.kt new file mode 100644 index 0000000..1a3f66d --- /dev/null +++ b/app/src/main/java/com/mohsenoid/rickandmorty/AppModule.kt @@ -0,0 +1,43 @@ +package com.mohsenoid.rickandmorty + +import com.google.gson.GsonBuilder +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import org.koin.dsl.module +import retrofit2.Converter +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory + + +val appModule = module { + + single { + GsonBuilder().create() + } + + single { + GsonConverterFactory.create(get()) + } + + single { + val logging: HttpLoggingInterceptor = HttpLoggingInterceptor().apply { + level = if (BuildConfig.DEBUG) { + HttpLoggingInterceptor.Level.BODY + } else { + HttpLoggingInterceptor.Level.NONE + } + } + + val httpClient = OkHttpClient.Builder() + httpClient.addInterceptor(logging) + httpClient.build() + } + + single { + Retrofit.Builder() + .addConverterFactory(get()) + .baseUrl(BuildConfig.API_BASE_URL) + .client(get()) + .build() + } +} diff --git a/app/src/main/java/com/mohsenoid/rickandmorty/MainActivity.kt b/app/src/main/java/com/mohsenoid/rickandmorty/MainActivity.kt new file mode 100644 index 0000000..eb0d75d --- /dev/null +++ b/app/src/main/java/com/mohsenoid/rickandmorty/MainActivity.kt @@ -0,0 +1,181 @@ +@file:OptIn(ExperimentalMaterial3Api::class) + +package com.mohsenoid.rickandmorty + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import androidx.navigation.navDeepLink +import com.mohsenoid.rickandmorty.ui.NavRoute +import com.mohsenoid.rickandmorty.ui.characters.details.CharacterDetailsScreen +import com.mohsenoid.rickandmorty.ui.characters.list.CharactersScreen +import com.mohsenoid.rickandmorty.ui.episodes.EpisodesScreen +import com.mohsenoid.rickandmorty.ui.theme.RickAndMortyTheme + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + RickAndMortyTheme { + val navController = rememberNavController() + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + AppTopBar(navController = navController) + }, + ) { innerPadding -> + NavHost( + navController = navController, + startDestination = NavRoute.EpisodesScreen.route, + ) { + composable( + route = NavRoute.EpisodesScreen.route, + ) { + EpisodesScreen( + navController = navController, + modifier = Modifier.padding(innerPadding), + ) + } + + composable( + route = NavRoute.CharactersScreen.route, + deepLinks = listOf( + navDeepLink { + uriPattern = NavRoute.CharactersScreen.deeplink + }, + ), + arguments = listOf( + navArgument(NavRoute.CHARACTERS_ARG) { + type = NavType.StringType + }, + ), + ) { backStackEntry -> + val charactersArg = + backStackEntry.arguments?.getString("characters") + val characters = + charactersArg?.split(",")?.mapNotNull { it.toIntOrNull() } + ?.toSet() + ?: emptySet() + + CharactersScreen( + navController = navController, + charactersIds = characters, + modifier = Modifier.padding(innerPadding), + ) + } + + composable( + route = NavRoute.CharacterDetailsScreen.route, + deepLinks = listOf( + navDeepLink { + uriPattern = NavRoute.CharacterDetailsScreen.deeplink + }, + ), + arguments = listOf( + navArgument(NavRoute.CHARACTER_ARG) { + type = NavType.StringType + }, + ), + ) { backStackEntry -> + val characterArg = backStackEntry.arguments?.getString("character") + + CharacterDetailsScreen( + characterId = characterArg?.toIntOrNull() ?: -1, + modifier = Modifier.padding(innerPadding), + ) + } + } + } + } + } + } +} + +@Composable +fun AppTopBar( + navController: NavController, + modifier: Modifier = Modifier, +) { + val currentBackStackEntry by navController.currentBackStackEntryFlow.collectAsStateWithLifecycle( + initialValue = navController.currentBackStackEntry, + ) + TopAppBar( + title = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResource(id = R.string.app_name), + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.titleLarge, + ) + + } + }, + modifier = modifier, + navigationIcon = { + if (currentBackStackEntry?.destination?.route != NavRoute.EpisodesScreen.route) { + IconButton(onClick = { navController.popBackStack() }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + tint = MaterialTheme.colorScheme.onSurface, + ) + } + } else { + Icon( + painter = painterResource(id = R.drawable.rick_morty), + contentDescription = "Home", + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .padding(4.dp) + .size(40.dp), + ) + } + }, + ) +} + +@Preview +@Composable +fun AppTopBarDarkPreview() { + RickAndMortyTheme(darkTheme = true) { + AppTopBar(navController = rememberNavController()) + } +} + +@Preview +@Composable +fun AppTopBarLightPreview() { + RickAndMortyTheme(darkTheme = false) { + AppTopBar(navController = rememberNavController()) + } +} diff --git a/app/src/main/java/com/mohsenoid/rickandmorty/RickAndMortyApplication.kt b/app/src/main/java/com/mohsenoid/rickandmorty/RickAndMortyApplication.kt new file mode 100644 index 0000000..205003a --- /dev/null +++ b/app/src/main/java/com/mohsenoid/rickandmorty/RickAndMortyApplication.kt @@ -0,0 +1,20 @@ +package com.mohsenoid.rickandmorty + +import android.app.Application +import com.mohsenoid.rickandmorty.data.dataModule +import com.mohsenoid.rickandmorty.domain.domainModule +import com.mohsenoid.rickandmorty.ui.uiModule +import org.koin.android.ext.koin.androidContext +import org.koin.android.ext.koin.androidLogger +import org.koin.core.context.startKoin + +class RickAndMortyApplication : Application() { + override fun onCreate() { + super.onCreate() + startKoin { + androidLogger() + androidContext(this@RickAndMortyApplication) + modules(appModule, dataModule, domainModule, uiModule) + } + } +} diff --git a/app/src/main/java/com/mohsenoid/rickandmorty/data/DataModule.kt b/app/src/main/java/com/mohsenoid/rickandmorty/data/DataModule.kt new file mode 100644 index 0000000..172b5a6 --- /dev/null +++ b/app/src/main/java/com/mohsenoid/rickandmorty/data/DataModule.kt @@ -0,0 +1,48 @@ +package com.mohsenoid.rickandmorty.data + +import com.mohsenoid.rickandmorty.data.characters.CharactersRepositoryImpl +import com.mohsenoid.rickandmorty.data.db.Database +import com.mohsenoid.rickandmorty.data.db.DatabaseProvider +import com.mohsenoid.rickandmorty.data.episodes.EpisodesRepositoryImpl +import com.mohsenoid.rickandmorty.data.remote.ApiService +import com.mohsenoid.rickandmorty.domain.characters.CharactersRepository +import com.mohsenoid.rickandmorty.domain.episodes.EpisodesRepository +import org.koin.dsl.module +import retrofit2.Retrofit + + +val dataModule = module { + + single { + val retrofit: Retrofit = get() + retrofit.create(ApiService::class.java) + } + + single { + DatabaseProvider.getDatabase(context = get()) + } + + factory { + val db: Database = get() + db.characterDao() + } + + factory { + val db: Database = get() + db.episodeDao() + } + + single { + EpisodesRepositoryImpl( + apiService = get(), + episodesDao = get(), + ) + } + + single { + CharactersRepositoryImpl( + apiService = get(), + characterDao = get(), + ) + } +} diff --git a/app/src/main/java/com/mohsenoid/rickandmorty/data/characters/CharactersRepositoryImpl.kt b/app/src/main/java/com/mohsenoid/rickandmorty/data/characters/CharactersRepositoryImpl.kt new file mode 100644 index 0000000..2e0873f --- /dev/null +++ b/app/src/main/java/com/mohsenoid/rickandmorty/data/characters/CharactersRepositoryImpl.kt @@ -0,0 +1,121 @@ +package com.mohsenoid.rickandmorty.data.characters + +import com.mohsenoid.rickandmorty.data.characters.dao.CharacterDao +import com.mohsenoid.rickandmorty.data.characters.mapper.CharacterMapper.toCharacter +import com.mohsenoid.rickandmorty.data.characters.mapper.CharacterMapper.toCharacterEntity +import com.mohsenoid.rickandmorty.data.remote.ApiService +import com.mohsenoid.rickandmorty.data.remote.model.CharacterRemoteModel +import com.mohsenoid.rickandmorty.domain.RepositoryGetResult +import com.mohsenoid.rickandmorty.domain.characters.CharactersRepository +import com.mohsenoid.rickandmorty.domain.characters.model.Character +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +internal class CharactersRepositoryImpl( + private val apiService: ApiService, + private val characterDao: CharacterDao, +) : CharactersRepository { + + private val charactersCache: MutableSet = mutableSetOf() + + override suspend fun getCharacters(characterIds: Set): RepositoryGetResult> = + withContext(Dispatchers.IO) { + val cachedCharacterIds = charactersCache.map(Character::id).toSet() + val uncachedCharacterIds = characterIds - cachedCharacterIds + if (uncachedCharacterIds.isEmpty()) { + return@withContext RepositoryGetResult.Success( + charactersCache.filter { it.id in characterIds } + .toSet(), + ) + } + + val dbCharacters = characterDao.getCharacters(characterIds).map { it.toCharacter() } + cacheCharacters(dbCharacters) + val dbCharacterIds = dbCharacters.map(Character::id).toSet() + val pendingCharacterIds = characterIds - dbCharacterIds + if (pendingCharacterIds.isEmpty()) { + return@withContext RepositoryGetResult.Success( + charactersCache.filter { it.id in characterIds } + .toSet(), + ) + } + + val pendingCharacterIdsString = pendingCharacterIds.joinToString(",") + val response = + runCatching { apiService.getCharacters(pendingCharacterIdsString) }.getOrNull() + ?: return@withContext RepositoryGetResult.Failure.NoConnection("Connection Error!") + val remoteCharacters: List? = response.body() + if (response.isSuccessful && remoteCharacters != null) { + val charactersEntity = remoteCharacters.map { it.toCharacterEntity() } + charactersEntity.forEach { characterDao.insertCharacter(it) } + val characters = charactersEntity.map { it.toCharacter() } + cacheCharacters(characters) + return@withContext RepositoryGetResult.Success( + charactersCache.filter { it.id in characterIds } + .toSet(), + ) + } else { + return@withContext RepositoryGetResult.Failure.Unknown( + response.message().ifEmpty { "Unknown Error" }, + ) + } + } + + private fun cacheCharacters(dbCharacters: List) { + charactersCache += dbCharacters + } + + + override suspend fun getCharacter(characterId: Int): RepositoryGetResult = + withContext(Dispatchers.IO) { + val getCachedCharacterResult = getCachedCharacter(characterId) + if (getCachedCharacterResult is RepositoryGetResult.Success) { + return@withContext getCachedCharacterResult + } + + val getDatabaseCharacterResult = getDatabaseCharacter(characterId) + if (getDatabaseCharacterResult is RepositoryGetResult.Success) { + return@withContext getDatabaseCharacterResult + } + + val getRemoteCharacterResult = getRemoteCharacter(characterId) + return@withContext getRemoteCharacterResult + } + + private fun getCachedCharacter(characterId: Int): RepositoryGetResult { + val cachedCharacter = charactersCache.firstOrNull { it.id == characterId } + if (cachedCharacter != null) { + return RepositoryGetResult.Success(cachedCharacter) + } + + return RepositoryGetResult.Failure.Unknown("No cached character") + } + + private fun getDatabaseCharacter(characterId: Int): RepositoryGetResult { + val dbCharacter = characterDao.getCharacter(characterId)?.toCharacter() + if (dbCharacter != null) { + charactersCache += dbCharacter + return RepositoryGetResult.Success(dbCharacter) + } + + return RepositoryGetResult.Failure.Unknown("No database character") + } + + private suspend fun getRemoteCharacter(characterId: Int): RepositoryGetResult { + val response = + runCatching { apiService.getCharacter(characterId) }.getOrNull() + ?: return RepositoryGetResult.Failure.NoConnection("No Connection Error") + val remoteCharacter: CharacterRemoteModel? = response.body() + if (response.isSuccessful && remoteCharacter != null) { + val characterEntity = remoteCharacter.toCharacterEntity() + characterDao.insertCharacter(characterEntity) + val character = characterEntity.toCharacter() + charactersCache += character + return RepositoryGetResult.Success(character) + } else { + return RepositoryGetResult.Failure.Unknown( + response.message().ifEmpty { "Unknown Error" }, + ) + } + } +} diff --git a/app/src/main/java/com/mohsenoid/rickandmorty/data/characters/dao/CharacterDao.kt b/app/src/main/java/com/mohsenoid/rickandmorty/data/characters/dao/CharacterDao.kt new file mode 100644 index 0000000..0b51d7d --- /dev/null +++ b/app/src/main/java/com/mohsenoid/rickandmorty/data/characters/dao/CharacterDao.kt @@ -0,0 +1,24 @@ +package com.mohsenoid.rickandmorty.data.characters.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Update +import com.mohsenoid.rickandmorty.data.characters.entity.CharacterEntity + +@Dao +internal interface CharacterDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insertCharacter(character: CharacterEntity) + + @Query("SELECT * FROM characters WHERE id IN (:characterIds)") + fun getCharacters(characterIds: Set): List + + @Query("SELECT * FROM characters WHERE id = :characterId") + fun getCharacter(characterId: Int): CharacterEntity? + + @Update + fun updateCharacter(character: CharacterEntity) +} diff --git a/app/src/main/java/com/mohsenoid/rickandmorty/data/characters/entity/CharacterEntity.kt b/app/src/main/java/com/mohsenoid/rickandmorty/data/characters/entity/CharacterEntity.kt new file mode 100644 index 0000000..20add0b --- /dev/null +++ b/app/src/main/java/com/mohsenoid/rickandmorty/data/characters/entity/CharacterEntity.kt @@ -0,0 +1,28 @@ +package com.mohsenoid.rickandmorty.data.characters.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "characters") +internal data class CharacterEntity( + @PrimaryKey + @ColumnInfo(name = "id") + val id: Int, + @ColumnInfo(name = "name") + val name: String, + @ColumnInfo(name = "status") + val status: String, + @ColumnInfo(name = "species") + val species: String, + @ColumnInfo(name = "type") + val type: String, + @ColumnInfo(name = "gender") + val gender: String, + @ColumnInfo(name = "origin") + val origin: String, + @ColumnInfo(name = "location") + val location: String, + @ColumnInfo(name = "image") + val image: String, +) diff --git a/app/src/main/java/com/mohsenoid/rickandmorty/data/characters/mapper/CharacterMapper.kt b/app/src/main/java/com/mohsenoid/rickandmorty/data/characters/mapper/CharacterMapper.kt new file mode 100644 index 0000000..718cf2c --- /dev/null +++ b/app/src/main/java/com/mohsenoid/rickandmorty/data/characters/mapper/CharacterMapper.kt @@ -0,0 +1,32 @@ +package com.mohsenoid.rickandmorty.data.characters.mapper + +import com.mohsenoid.rickandmorty.data.characters.entity.CharacterEntity +import com.mohsenoid.rickandmorty.data.remote.model.CharacterRemoteModel +import com.mohsenoid.rickandmorty.domain.characters.model.Character + +internal object CharacterMapper { + + fun CharacterRemoteModel.toCharacterEntity() = CharacterEntity( + id = id, + name = name, + status = status, + species = species, + type = type, + gender = gender, + origin = origin.name, + location = location.name, + image = image, + ) + + fun CharacterEntity.toCharacter() = Character( + id = id, + name = name, + status = status, + species = species, + type = type, + gender = gender, + origin = origin, + location = location, + image = image, + ) +} diff --git a/app/src/main/java/com/mohsenoid/rickandmorty/data/db/Database.kt b/app/src/main/java/com/mohsenoid/rickandmorty/data/db/Database.kt new file mode 100644 index 0000000..e730e11 --- /dev/null +++ b/app/src/main/java/com/mohsenoid/rickandmorty/data/db/Database.kt @@ -0,0 +1,16 @@ +package com.mohsenoid.rickandmorty.data.db + +import androidx.room.Database +import androidx.room.RoomDatabase +import com.mohsenoid.rickandmorty.data.characters.dao.CharacterDao +import com.mohsenoid.rickandmorty.data.characters.entity.CharacterEntity +import com.mohsenoid.rickandmorty.data.episodes.dao.EpisodesDao +import com.mohsenoid.rickandmorty.data.episodes.entity.EpisodeEntity + +@Database(entities = [EpisodeEntity::class, CharacterEntity::class], version = 1) +internal abstract class Database : RoomDatabase() { + + abstract fun episodeDao(): EpisodesDao + + abstract fun characterDao(): CharacterDao +} diff --git a/app/src/main/java/com/mohsenoid/rickandmorty/data/db/DatabaseProvider.kt b/app/src/main/java/com/mohsenoid/rickandmorty/data/db/DatabaseProvider.kt new file mode 100644 index 0000000..8841b61 --- /dev/null +++ b/app/src/main/java/com/mohsenoid/rickandmorty/data/db/DatabaseProvider.kt @@ -0,0 +1,25 @@ +package com.mohsenoid.rickandmorty.data.db + +import android.content.Context +import androidx.room.Room + +internal object DatabaseProvider { + + private const val DATABASE_NAME = "db" + private lateinit var INSTANCE: Database + + fun getDatabase(context: Context): Database { + if (!::INSTANCE.isInitialized) { + synchronized(Database::class.java) { + INSTANCE = Room.databaseBuilder( + context = context.applicationContext, + klass = Database::class.java, + name = DATABASE_NAME, + ) + .fallbackToDestructiveMigration() + .build() + } + } + return INSTANCE + } +} diff --git a/app/src/main/java/com/mohsenoid/rickandmorty/data/db/util/IntSetConverter.kt b/app/src/main/java/com/mohsenoid/rickandmorty/data/db/util/IntSetConverter.kt new file mode 100644 index 0000000..7ca593a --- /dev/null +++ b/app/src/main/java/com/mohsenoid/rickandmorty/data/db/util/IntSetConverter.kt @@ -0,0 +1,18 @@ +package com.mohsenoid.rickandmorty.data.db.util + +import androidx.room.TypeConverter +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken + +internal class IntSetConverter { + @TypeConverter + fun fromString(value: String): Set { + val setType = object : TypeToken>() {}.type + return Gson().fromJson(value, setType) + } + + @TypeConverter + fun toString(set: Set): String { + return Gson().toJson(set) + } +} diff --git a/app/src/main/java/com/mohsenoid/rickandmorty/data/episodes/EpisodesRepositoryImpl.kt b/app/src/main/java/com/mohsenoid/rickandmorty/data/episodes/EpisodesRepositoryImpl.kt new file mode 100644 index 0000000..1adfdbc --- /dev/null +++ b/app/src/main/java/com/mohsenoid/rickandmorty/data/episodes/EpisodesRepositoryImpl.kt @@ -0,0 +1,78 @@ +package com.mohsenoid.rickandmorty.data.episodes + +import com.mohsenoid.rickandmorty.data.episodes.dao.EpisodesDao +import com.mohsenoid.rickandmorty.data.episodes.mapper.EpisodeMapper.toEpisode +import com.mohsenoid.rickandmorty.data.episodes.mapper.EpisodeMapper.toEpisodeEntity +import com.mohsenoid.rickandmorty.data.remote.ApiService +import com.mohsenoid.rickandmorty.data.remote.model.EpisodeRemoteModel +import com.mohsenoid.rickandmorty.domain.RepositoryGetResult +import com.mohsenoid.rickandmorty.domain.episodes.EpisodesRepository +import com.mohsenoid.rickandmorty.domain.episodes.model.Episode +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +internal class EpisodesRepositoryImpl( + val apiService: ApiService, + val episodesDao: EpisodesDao, +) : EpisodesRepository { + + private val episodesCache: MutableMap> = mutableMapOf() + + override suspend fun getEpisodes(page: Int): RepositoryGetResult> = + withContext(Dispatchers.IO) { + val getCachedEpisodesResult = getCachedEpisodes(page) + if (getCachedEpisodesResult is RepositoryGetResult.Success) { + return@withContext getCachedEpisodesResult + } + + val getDatabaseEpisodesResult = getDatabaseEpisodes(page) + if (getDatabaseEpisodesResult is RepositoryGetResult.Success) { + return@withContext getDatabaseEpisodesResult + } + + return@withContext getRemoteEpisodes(page) + } + + private fun getCachedEpisodes(page: Int): RepositoryGetResult> { + val cachedEpisodes = episodesCache.getOrDefault(page, emptyList()) + if (cachedEpisodes.isNotEmpty()) { + return RepositoryGetResult.Success(cachedEpisodes) + } + + return RepositoryGetResult.Failure.Unknown("No cached episodes") + } + + private fun getDatabaseEpisodes(page: Int): RepositoryGetResult> { + val dbEpisodes = episodesDao.getEpisodes(page = page).map { it.toEpisode() } + if (dbEpisodes.isNotEmpty()) { + cacheEpisodes(page, dbEpisodes) + return RepositoryGetResult.Success(dbEpisodes) + } + + return RepositoryGetResult.Failure.Unknown("No database episodes") + } + + private suspend fun getRemoteEpisodes(page: Int): RepositoryGetResult> { + val response = runCatching { apiService.getEpisodes(page) }.getOrNull() + ?: return RepositoryGetResult.Failure.NoConnection("Connection Error!") + val remoteEpisodes: List? = response.body()?.results + if (response.isSuccessful && remoteEpisodes != null) { + val episodesEntity = remoteEpisodes.map { it.toEpisodeEntity(page) } + episodesEntity.forEach { episodesDao.insertEpisode(it) } + + val serviceEpisodes = episodesEntity.map { it.toEpisode() } + cacheEpisodes(page, serviceEpisodes) + return RepositoryGetResult.Success(serviceEpisodes) + } else if (response.code() == 404) { + return RepositoryGetResult.Failure.EndOfList("End of list") + } else { + return RepositoryGetResult.Failure.Unknown( + response.message().ifEmpty { "Unknown Error" }, + ) + } + } + + private fun cacheEpisodes(page: Int, episodes: List) { + episodesCache[page] = episodes + } +} diff --git a/app/src/main/java/com/mohsenoid/rickandmorty/data/episodes/dao/EpisodesDao.kt b/app/src/main/java/com/mohsenoid/rickandmorty/data/episodes/dao/EpisodesDao.kt new file mode 100644 index 0000000..d1b1051 --- /dev/null +++ b/app/src/main/java/com/mohsenoid/rickandmorty/data/episodes/dao/EpisodesDao.kt @@ -0,0 +1,23 @@ +package com.mohsenoid.rickandmorty.data.episodes.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.mohsenoid.rickandmorty.data.episodes.entity.EpisodeEntity + +@Dao +internal interface EpisodesDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insertEpisode(episode: EpisodeEntity) + + @Query("SELECT * FROM episodes WHERE page = :page ORDER BY id ASC") + fun getEpisodes(page: Int): List + + @Query("SELECT * FROM episodes WHERE id = :episodeId") + fun getEpisode(episodeId: Int): EpisodeEntity? + + @Query("DELETE FROM episodes") + fun deleteAll() +} diff --git a/app/src/main/java/com/mohsenoid/rickandmorty/data/episodes/entity/EpisodeEntity.kt b/app/src/main/java/com/mohsenoid/rickandmorty/data/episodes/entity/EpisodeEntity.kt new file mode 100644 index 0000000..f6aa668 --- /dev/null +++ b/app/src/main/java/com/mohsenoid/rickandmorty/data/episodes/entity/EpisodeEntity.kt @@ -0,0 +1,25 @@ +package com.mohsenoid.rickandmorty.data.episodes.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import androidx.room.TypeConverters +import com.mohsenoid.rickandmorty.data.db.util.IntSetConverter + +@Entity(tableName = "episodes") +@TypeConverters(IntSetConverter::class) +internal data class EpisodeEntity( + @PrimaryKey + @ColumnInfo(name = "id") + val id: Int, + @ColumnInfo(name = "page") + val page: Int, + @ColumnInfo(name = "name") + val name: String, + @ColumnInfo(name = "air_date") + val airDate: String, + @ColumnInfo(name = "episode") + val episode: String, + @ColumnInfo(name = "characters") + val characters: Set, +) diff --git a/app/src/main/java/com/mohsenoid/rickandmorty/data/episodes/mapper/EpisodeMapper.kt b/app/src/main/java/com/mohsenoid/rickandmorty/data/episodes/mapper/EpisodeMapper.kt new file mode 100644 index 0000000..58df866 --- /dev/null +++ b/app/src/main/java/com/mohsenoid/rickandmorty/data/episodes/mapper/EpisodeMapper.kt @@ -0,0 +1,28 @@ +package com.mohsenoid.rickandmorty.data.episodes.mapper + +import com.mohsenoid.rickandmorty.data.episodes.entity.EpisodeEntity +import com.mohsenoid.rickandmorty.data.remote.model.EpisodeRemoteModel +import com.mohsenoid.rickandmorty.domain.episodes.model.Episode + +internal object EpisodeMapper { + + fun EpisodeRemoteModel.toEpisodeEntity(page: Int) = EpisodeEntity( + id = id, + page = page, + name = name, + airDate = airDate, + episode = episode, + characters = characters.toCharacterIds(), + ) + + fun EpisodeEntity.toEpisode() = Episode( + id = id, + name = name, + airDate = airDate, + episode = episode, + characters = characters, + ) +} + +private fun Set.toCharacterIds(): Set = + mapNotNull { it.split("/").lastOrNull()?.toIntOrNull() }.toSet() diff --git a/app/src/main/java/com/mohsenoid/rickandmorty/data/remote/ApiService.kt b/app/src/main/java/com/mohsenoid/rickandmorty/data/remote/ApiService.kt new file mode 100644 index 0000000..0d1fb74 --- /dev/null +++ b/app/src/main/java/com/mohsenoid/rickandmorty/data/remote/ApiService.kt @@ -0,0 +1,21 @@ +package com.mohsenoid.rickandmorty.data.remote + +import com.mohsenoid.rickandmorty.data.remote.model.CharacterRemoteModel +import com.mohsenoid.rickandmorty.data.remote.model.CharactersResponse +import com.mohsenoid.rickandmorty.data.remote.model.EpisodesResponse +import retrofit2.Response +import retrofit2.http.GET +import retrofit2.http.Path +import retrofit2.http.Query + +internal interface ApiService { + + @GET("episode") + suspend fun getEpisodes(@Query("page") page: Int): Response + + @GET("character/{characterIds}") + suspend fun getCharacters(@Path("characterIds") characterIds: String): Response + + @GET("character/{characterId}") + suspend fun getCharacter(@Path("characterId") characterId: Int): Response +} diff --git a/app/src/main/java/com/mohsenoid/rickandmorty/data/remote/model/CharacterRemoteModel.kt b/app/src/main/java/com/mohsenoid/rickandmorty/data/remote/model/CharacterRemoteModel.kt new file mode 100644 index 0000000..513d2d6 --- /dev/null +++ b/app/src/main/java/com/mohsenoid/rickandmorty/data/remote/model/CharacterRemoteModel.kt @@ -0,0 +1,31 @@ +package com.mohsenoid.rickandmorty.data.remote.model + + +import com.google.gson.annotations.SerializedName + +internal data class CharacterRemoteModel( + @SerializedName("id") + val id: Int, + @SerializedName("name") + val name: String, + @SerializedName("status") + val status: String, + @SerializedName("species") + val species: String, + @SerializedName("type") + val type: String, + @SerializedName("gender") + val gender: String, + @SerializedName("origin") + val origin: OriginRemoteModel, + @SerializedName("location") + val location: LocationRemoteModel, + @SerializedName("image") + val image: String, + @SerializedName("episode") + val episode: List, + @SerializedName("url") + val url: String, + @SerializedName("created") + val created: String, +) diff --git a/app/src/main/java/com/mohsenoid/rickandmorty/data/remote/model/CharactersResponse.kt b/app/src/main/java/com/mohsenoid/rickandmorty/data/remote/model/CharactersResponse.kt new file mode 100644 index 0000000..95759fb --- /dev/null +++ b/app/src/main/java/com/mohsenoid/rickandmorty/data/remote/model/CharactersResponse.kt @@ -0,0 +1,4 @@ +package com.mohsenoid.rickandmorty.data.remote.model + + +internal typealias CharactersResponse = List diff --git a/app/src/main/java/com/mohsenoid/rickandmorty/data/remote/model/EpisodeRemoteModel.kt b/app/src/main/java/com/mohsenoid/rickandmorty/data/remote/model/EpisodeRemoteModel.kt new file mode 100644 index 0000000..f1fb569 --- /dev/null +++ b/app/src/main/java/com/mohsenoid/rickandmorty/data/remote/model/EpisodeRemoteModel.kt @@ -0,0 +1,21 @@ +package com.mohsenoid.rickandmorty.data.remote.model + + +import com.google.gson.annotations.SerializedName + +internal data class EpisodeRemoteModel( + @SerializedName("id") + val id: Int, + @SerializedName("name") + val name: String, + @SerializedName("air_date") + val airDate: String, + @SerializedName("episode") + val episode: String, + @SerializedName("characters") + val characters: Set, + @SerializedName("url") + val url: String, + @SerializedName("created") + val created: String, +) diff --git a/app/src/main/java/com/mohsenoid/rickandmorty/data/remote/model/EpisodesResponse.kt b/app/src/main/java/com/mohsenoid/rickandmorty/data/remote/model/EpisodesResponse.kt new file mode 100644 index 0000000..2907e29 --- /dev/null +++ b/app/src/main/java/com/mohsenoid/rickandmorty/data/remote/model/EpisodesResponse.kt @@ -0,0 +1,11 @@ +package com.mohsenoid.rickandmorty.data.remote.model + + +import com.google.gson.annotations.SerializedName + +internal data class EpisodesResponse( + @SerializedName("info") + val info: InfoRemoteModel, + @SerializedName("results") + val results: List, +) diff --git a/app/src/main/java/com/mohsenoid/rickandmorty/data/remote/model/InfoRemoteModel.kt b/app/src/main/java/com/mohsenoid/rickandmorty/data/remote/model/InfoRemoteModel.kt new file mode 100644 index 0000000..bd462bf --- /dev/null +++ b/app/src/main/java/com/mohsenoid/rickandmorty/data/remote/model/InfoRemoteModel.kt @@ -0,0 +1,15 @@ +package com.mohsenoid.rickandmorty.data.remote.model + + +import com.google.gson.annotations.SerializedName + +internal data class InfoRemoteModel( + @SerializedName("count") + val count: Int, + @SerializedName("pages") + val pages: Int, + @SerializedName("next") + val next: String?, + @SerializedName("prev") + val prev: String?, +) diff --git a/app/src/main/java/com/mohsenoid/rickandmorty/data/remote/model/LocationRemoteModel.kt b/app/src/main/java/com/mohsenoid/rickandmorty/data/remote/model/LocationRemoteModel.kt new file mode 100644 index 0000000..04499d4 --- /dev/null +++ b/app/src/main/java/com/mohsenoid/rickandmorty/data/remote/model/LocationRemoteModel.kt @@ -0,0 +1,11 @@ +package com.mohsenoid.rickandmorty.data.remote.model + + +import com.google.gson.annotations.SerializedName + +internal data class LocationRemoteModel( + @SerializedName("name") + val name: String, + @SerializedName("url") + val url: String, +) diff --git a/app/src/main/java/com/mohsenoid/rickandmorty/data/remote/model/OriginRemoteModel.kt b/app/src/main/java/com/mohsenoid/rickandmorty/data/remote/model/OriginRemoteModel.kt new file mode 100644 index 0000000..104db29 --- /dev/null +++ b/app/src/main/java/com/mohsenoid/rickandmorty/data/remote/model/OriginRemoteModel.kt @@ -0,0 +1,11 @@ +package com.mohsenoid.rickandmorty.data.remote.model + + +import com.google.gson.annotations.SerializedName + +internal data class OriginRemoteModel( + @SerializedName("name") + val name: String, + @SerializedName("url") + val url: String, +) diff --git a/app/src/main/java/com/mohsenoid/rickandmorty/domain/DomainModule.kt b/app/src/main/java/com/mohsenoid/rickandmorty/domain/DomainModule.kt new file mode 100644 index 0000000..9cc1191 --- /dev/null +++ b/app/src/main/java/com/mohsenoid/rickandmorty/domain/DomainModule.kt @@ -0,0 +1,8 @@ +package com.mohsenoid.rickandmorty.domain + +import org.koin.dsl.module + + +val domainModule = module { + +} diff --git a/app/src/main/java/com/mohsenoid/rickandmorty/domain/RepositoryGetResult.kt b/app/src/main/java/com/mohsenoid/rickandmorty/domain/RepositoryGetResult.kt new file mode 100644 index 0000000..8678f05 --- /dev/null +++ b/app/src/main/java/com/mohsenoid/rickandmorty/domain/RepositoryGetResult.kt @@ -0,0 +1,21 @@ +package com.mohsenoid.rickandmorty.domain + +sealed interface RepositoryGetResult { + + data class Success(val data: T) : RepositoryGetResult + + sealed interface Failure : RepositoryGetResult { + val message: String + + data class EndOfList(override val message: String) : Failure + data class NoConnection(override val message: String) : Failure + data class Unknown(override val message: String) : Failure + } + + fun getOrNull(): T? { + return when (this) { + is Success -> data + is Failure -> null + } + } +} diff --git a/app/src/main/java/com/mohsenoid/rickandmorty/domain/characters/CharactersRepository.kt b/app/src/main/java/com/mohsenoid/rickandmorty/domain/characters/CharactersRepository.kt new file mode 100644 index 0000000..bedf266 --- /dev/null +++ b/app/src/main/java/com/mohsenoid/rickandmorty/domain/characters/CharactersRepository.kt @@ -0,0 +1,9 @@ +package com.mohsenoid.rickandmorty.domain.characters + +import com.mohsenoid.rickandmorty.domain.RepositoryGetResult +import com.mohsenoid.rickandmorty.domain.characters.model.Character + +interface CharactersRepository { + suspend fun getCharacters(characterIds: Set): RepositoryGetResult> + suspend fun getCharacter(characterId: Int): RepositoryGetResult +} diff --git a/app/src/main/java/com/mohsenoid/rickandmorty/domain/characters/model/Character.kt b/app/src/main/java/com/mohsenoid/rickandmorty/domain/characters/model/Character.kt new file mode 100644 index 0000000..fc30e86 --- /dev/null +++ b/app/src/main/java/com/mohsenoid/rickandmorty/domain/characters/model/Character.kt @@ -0,0 +1,15 @@ +package com.mohsenoid.rickandmorty.domain.characters.model + +import androidx.compose.runtime.Immutable + +@Immutable +data class Character( + val id: Int, val name: String, + val status: String, + val species: String, + val type: String, + val gender: String, + val origin: String, + val location: String, + val image: String, +) diff --git a/app/src/main/java/com/mohsenoid/rickandmorty/domain/episodes/EpisodesRepository.kt b/app/src/main/java/com/mohsenoid/rickandmorty/domain/episodes/EpisodesRepository.kt new file mode 100644 index 0000000..d9f625a --- /dev/null +++ b/app/src/main/java/com/mohsenoid/rickandmorty/domain/episodes/EpisodesRepository.kt @@ -0,0 +1,8 @@ +package com.mohsenoid.rickandmorty.domain.episodes + +import com.mohsenoid.rickandmorty.domain.RepositoryGetResult +import com.mohsenoid.rickandmorty.domain.episodes.model.Episode + +interface EpisodesRepository { + suspend fun getEpisodes(page: Int = 0): RepositoryGetResult> +} diff --git a/app/src/main/java/com/mohsenoid/rickandmorty/domain/episodes/model/Episode.kt b/app/src/main/java/com/mohsenoid/rickandmorty/domain/episodes/model/Episode.kt new file mode 100644 index 0000000..36f7eda --- /dev/null +++ b/app/src/main/java/com/mohsenoid/rickandmorty/domain/episodes/model/Episode.kt @@ -0,0 +1,12 @@ +package com.mohsenoid.rickandmorty.domain.episodes.model + +import androidx.compose.runtime.Immutable + +@Immutable +data class Episode( + val id: Int, + val name: String, + val airDate: String, + val episode: String, + val characters: Set, +) diff --git a/app/src/main/java/com/mohsenoid/rickandmorty/ui/LoadingScreen.kt b/app/src/main/java/com/mohsenoid/rickandmorty/ui/LoadingScreen.kt new file mode 100644 index 0000000..011fea6 --- /dev/null +++ b/app/src/main/java/com/mohsenoid/rickandmorty/ui/LoadingScreen.kt @@ -0,0 +1,40 @@ +package com.mohsenoid.rickandmorty.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import com.mohsenoid.rickandmorty.ui.theme.RickAndMortyTheme + +@Composable +fun LoadingScreen(modifier: Modifier = Modifier) { + Column( + modifier = modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + CircularProgressIndicator() + Text("Loading...") + } +} + +@Preview +@Composable +fun LoadingScreenPreview() { + RickAndMortyTheme(darkTheme = false) { + LoadingScreen() + } +} + +@Preview +@Composable +fun LoadingScreenDarkPreview() { + RickAndMortyTheme(darkTheme = true) { + LoadingScreen() + } +} diff --git a/app/src/main/java/com/mohsenoid/rickandmorty/ui/NavRoute.kt b/app/src/main/java/com/mohsenoid/rickandmorty/ui/NavRoute.kt new file mode 100644 index 0000000..cefcf10 --- /dev/null +++ b/app/src/main/java/com/mohsenoid/rickandmorty/ui/NavRoute.kt @@ -0,0 +1,18 @@ +package com.mohsenoid.rickandmorty.ui + +sealed class NavRoute(val endpoint: String, vararg args: String) { + val deeplink: String = BASE_URL + endpoint + args.joinToString(separator = "&") { "{$it}" } + val route = endpoint + args.joinToString(separator = "&") { "{$it}" } + + data object EpisodesScreen : NavRoute(endpoint = "episodes/") + + data object CharactersScreen : NavRoute(endpoint = "characters/", CHARACTERS_ARG) + + data object CharacterDetailsScreen : NavRoute(endpoint = "character/", CHARACTER_ARG) + + companion object { + const val BASE_URL = "https://rickandmorty.com/" + const val CHARACTERS_ARG = "characters" + const val CHARACTER_ARG = "character" + } +} diff --git a/app/src/main/java/com/mohsenoid/rickandmorty/ui/NoConnectionErrorScreen.kt b/app/src/main/java/com/mohsenoid/rickandmorty/ui/NoConnectionErrorScreen.kt new file mode 100644 index 0000000..cc068b6 --- /dev/null +++ b/app/src/main/java/com/mohsenoid/rickandmorty/ui/NoConnectionErrorScreen.kt @@ -0,0 +1,49 @@ +package com.mohsenoid.rickandmorty.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.WifiOff +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.mohsenoid.rickandmorty.ui.theme.RickAndMortyTheme + +@Composable +fun NoConnectionErrorScreen(modifier: Modifier = Modifier) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + imageVector = Icons.Filled.WifiOff, + contentDescription = "No Connection Error", + modifier = Modifier.size(48.dp), + tint = Color.Red, + ) + Text("No Connection Error!") + } +} + +@Preview +@Composable +fun NoConnectionErrorScreenPreview() { + RickAndMortyTheme(darkTheme = false) { + NoConnectionErrorScreen() + } +} + +@Preview +@Composable +fun NoConnectionErrorScreenDarkPreview() { + RickAndMortyTheme(darkTheme = true) { + NoConnectionErrorScreen() + } +} diff --git a/app/src/main/java/com/mohsenoid/rickandmorty/ui/UiModule.kt b/app/src/main/java/com/mohsenoid/rickandmorty/ui/UiModule.kt new file mode 100644 index 0000000..129269a --- /dev/null +++ b/app/src/main/java/com/mohsenoid/rickandmorty/ui/UiModule.kt @@ -0,0 +1,17 @@ +package com.mohsenoid.rickandmorty.ui + +import com.mohsenoid.rickandmorty.ui.characters.details.CharacterDetailsViewModel +import com.mohsenoid.rickandmorty.ui.characters.list.CharactersViewModel +import com.mohsenoid.rickandmorty.ui.episodes.EpisodesViewModel +import org.koin.androidx.viewmodel.dsl.viewModelOf +import org.koin.dsl.module + + +val uiModule = module { + + viewModelOf(::EpisodesViewModel) + + viewModelOf(::CharactersViewModel) + + viewModelOf(::CharacterDetailsViewModel) +} diff --git a/app/src/main/java/com/mohsenoid/rickandmorty/ui/UnknownErrorScreen.kt b/app/src/main/java/com/mohsenoid/rickandmorty/ui/UnknownErrorScreen.kt new file mode 100644 index 0000000..d10e4a7 --- /dev/null +++ b/app/src/main/java/com/mohsenoid/rickandmorty/ui/UnknownErrorScreen.kt @@ -0,0 +1,49 @@ +package com.mohsenoid.rickandmorty.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Error +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.mohsenoid.rickandmorty.ui.theme.RickAndMortyTheme + +@Composable +fun UnknownErrorScreen(message: String, modifier: Modifier = Modifier) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + imageVector = Icons.Filled.Error, + contentDescription = "Error", + modifier = Modifier.size(48.dp), + tint = Color.Red, + ) + Text(message) + } +} + +@Preview +@Composable +fun UnknownErrorScreenPreview() { + RickAndMortyTheme(darkTheme = false) { + UnknownErrorScreen(message = "Something is wrong!") + } +} + +@Preview +@Composable +fun UnknownErrorScreenDarkPreview() { + RickAndMortyTheme(darkTheme = true) { + UnknownErrorScreen(message = "Something is wrong!") + } +} diff --git a/app/src/main/java/com/mohsenoid/rickandmorty/ui/characters/details/CharacterDetailsScreen.kt b/app/src/main/java/com/mohsenoid/rickandmorty/ui/characters/details/CharacterDetailsScreen.kt new file mode 100644 index 0000000..fa2fc90 --- /dev/null +++ b/app/src/main/java/com/mohsenoid/rickandmorty/ui/characters/details/CharacterDetailsScreen.kt @@ -0,0 +1,212 @@ +package com.mohsenoid.rickandmorty.ui.characters.details + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshContainer +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.mohsenoid.rickandmorty.domain.characters.model.Character +import com.mohsenoid.rickandmorty.ui.LoadingScreen +import com.mohsenoid.rickandmorty.ui.NoConnectionErrorScreen +import com.mohsenoid.rickandmorty.ui.UnknownErrorScreen +import com.mohsenoid.rickandmorty.ui.theme.RickAndMortyTheme +import com.mohsenoid.rickandmorty.ui.util.AsyncImageWithPreview +import kotlinx.coroutines.delay +import org.koin.androidx.compose.koinViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CharacterDetailsScreen( + characterId: Int, + modifier: Modifier = Modifier, +) { + val viewModel: CharacterDetailsViewModel = koinViewModel() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(true) { + viewModel.loadCharacter(characterId) + } + + val pullToRefreshState = rememberPullToRefreshState() + if (pullToRefreshState.isRefreshing) { + LaunchedEffect(Unit) { + viewModel.loadCharacter(characterId) + // Fail safety timeout + delay(500) + if (!uiState.isLoading) { + pullToRefreshState.endRefresh() + } + } + } + + if (!uiState.isLoading) { + LaunchedEffect(Unit) { + pullToRefreshState.endRefresh() + } + } + + Box( + modifier = modifier + .fillMaxSize() + .nestedScroll(pullToRefreshState.nestedScrollConnection), + ) { + if (uiState.isLoading) { + LoadingScreen() + } else if (uiState.isNoConnectionError) { + NoConnectionErrorScreen( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + ) + } else if (uiState.unknownError != null) { + UnknownErrorScreen( + message = uiState.unknownError!!, + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + ) + } else if (uiState.character == null) { + UnknownErrorScreen( + message = "Error!", + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + ) + } else { + CharacterDetails( + character = uiState.character!!, + ) + } + PullToRefreshContainer( + state = pullToRefreshState, + modifier = Modifier.align(Alignment.TopCenter), + ) + } +} + +@Composable +fun CharacterDetails( + modifier: Modifier = Modifier, + character: Character, +) { + Column(modifier = modifier.fillMaxSize()) { + AsyncImageWithPreview( + model = character.image, + contentDescription = character.name, + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.inverseOnSurface), + ) + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = "#${character.id}", + modifier = Modifier + .fillMaxWidth(), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.labelMedium, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = character.name, + modifier = Modifier + .fillMaxWidth(), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleLarge, + ) + Spacer(modifier = Modifier.height(16.dp)) + CharacterDetailRow("Species", character.species) + Spacer(modifier = Modifier.height(8.dp)) + CharacterDetailRow("Origin", character.origin) + Spacer(modifier = Modifier.height(8.dp)) + CharacterDetailRow("Type", character.type) + Spacer(modifier = Modifier.height(8.dp)) + CharacterDetailRow("Gender", character.gender) + Spacer(modifier = Modifier.height(8.dp)) + CharacterDetailRow("Origin", character.origin) + Spacer(modifier = Modifier.height(8.dp)) + CharacterDetailRow("Location", character.location) + } + } +} + +@Composable +private fun CharacterDetailRow(label: String, value: String) { + Row(modifier = Modifier.fillMaxWidth()) { + Text( + text = label, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.labelLarge, + ) + Text( + text = value.ifEmpty { "N/A" }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, + ) + } +} + +@Preview(showSystemUi = true, showBackground = true) +@Composable +fun CharacterDetailsDarkPreview() { + RickAndMortyTheme(darkTheme = true) { + CharacterDetails( + character = Character( + id = 1, + name = "Rick Sanchez", + status = "Alive", + species = "Human", + type = "", + gender = "Male", + origin = "Earth (C-137)", + location = "Citadel of Ricks", + image = "https://rickandmortyapi.com/api/character/avatar/1.jpeg", + ), + ) + } +} + +@Preview(showSystemUi = true, showBackground = true) +@Composable +fun CharacterDetailsPreview() { + RickAndMortyTheme(darkTheme = false) { + CharacterDetails( + character = Character( + id = 1, + name = "Rick Sanchez", + status = "Alive", + species = "Human", + type = "", + gender = "Male", + origin = "Earth (C-137)", + location = "Citadel of Ricks", + image = "https://rickandmortyapi.com/api/character/avatar/1.jpeg", + ), + ) + } +} diff --git a/app/src/main/java/com/mohsenoid/rickandmorty/ui/characters/details/CharacterDetailsUiState.kt b/app/src/main/java/com/mohsenoid/rickandmorty/ui/characters/details/CharacterDetailsUiState.kt new file mode 100644 index 0000000..6db2e13 --- /dev/null +++ b/app/src/main/java/com/mohsenoid/rickandmorty/ui/characters/details/CharacterDetailsUiState.kt @@ -0,0 +1,11 @@ +package com.mohsenoid.rickandmorty.ui.characters.details + +import com.mohsenoid.rickandmorty.domain.characters.model.Character + + +data class CharacterDetailsUiState( + val isLoading: Boolean = false, + val character: Character? = null, + val isNoConnectionError: Boolean = false, + val unknownError: String? = null, +) diff --git a/app/src/main/java/com/mohsenoid/rickandmorty/ui/characters/details/CharacterDetailsViewModel.kt b/app/src/main/java/com/mohsenoid/rickandmorty/ui/characters/details/CharacterDetailsViewModel.kt new file mode 100644 index 0000000..3e9f85d --- /dev/null +++ b/app/src/main/java/com/mohsenoid/rickandmorty/ui/characters/details/CharacterDetailsViewModel.kt @@ -0,0 +1,68 @@ +package com.mohsenoid.rickandmorty.ui.characters.details + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.mohsenoid.rickandmorty.domain.RepositoryGetResult +import com.mohsenoid.rickandmorty.domain.characters.CharactersRepository +import com.mohsenoid.rickandmorty.domain.characters.model.Character +import com.mohsenoid.rickandmorty.ui.characters.list.CharactersViewModel.State +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +class CharacterDetailsViewModel( + private val charactersRepository: CharactersRepository, +) : ViewModel() { + + private val state: MutableStateFlow = MutableStateFlow(State.Loading) + + val uiState: StateFlow = state.map { it.toUiState() } + .stateIn(viewModelScope, SharingStarted.Eagerly, CharacterDetailsUiState(isLoading = true)) + + fun loadCharacter(characterId: Int) { + state.value = State.Loading + + viewModelScope.launch { + when (val result = charactersRepository.getCharacter(characterId)) { + is RepositoryGetResult.Success -> { + state.value = State.Success(character = result.data) + } + + is RepositoryGetResult.Failure.EndOfList -> { + state.value = State.LoadingUnknownError(result.message) + } + + is RepositoryGetResult.Failure.NoConnection -> { + state.value = State.LoadingNoConnectionError + } + + is RepositoryGetResult.Failure.Unknown -> { + state.value = State.LoadingUnknownError(result.message) + } + } + } + } + + internal sealed interface State { + + data object Loading : State + + data class Success(val character: Character) : State + + data object LoadingNoConnectionError : State + + data class LoadingUnknownError(val message: String) : State + + fun toUiState(): CharacterDetailsUiState { + return when (this) { + Loading -> CharacterDetailsUiState(isLoading = true) + is Success -> CharacterDetailsUiState(character = character) + is LoadingNoConnectionError -> CharacterDetailsUiState(isNoConnectionError = true) + is LoadingUnknownError -> CharacterDetailsUiState(unknownError = message) + } + } + } +} diff --git a/app/src/main/java/com/mohsenoid/rickandmorty/ui/characters/list/CharactersScreen.kt b/app/src/main/java/com/mohsenoid/rickandmorty/ui/characters/list/CharactersScreen.kt new file mode 100644 index 0000000..abbe992 --- /dev/null +++ b/app/src/main/java/com/mohsenoid/rickandmorty/ui/characters/list/CharactersScreen.kt @@ -0,0 +1,213 @@ +package com.mohsenoid.rickandmorty.ui.characters.list + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshContainer +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavHostController +import com.mohsenoid.rickandmorty.domain.characters.model.Character +import com.mohsenoid.rickandmorty.ui.LoadingScreen +import com.mohsenoid.rickandmorty.ui.NavRoute +import com.mohsenoid.rickandmorty.ui.NoConnectionErrorScreen +import com.mohsenoid.rickandmorty.ui.UnknownErrorScreen +import com.mohsenoid.rickandmorty.ui.theme.RickAndMortyTheme +import com.mohsenoid.rickandmorty.ui.util.AsyncImageWithPreview +import kotlinx.coroutines.delay +import org.koin.androidx.compose.koinViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CharactersScreen( + navController: NavHostController, + charactersIds: Set, + modifier: Modifier = Modifier, +) { + val viewModel: CharactersViewModel = koinViewModel() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(true) { + viewModel.loadCharacters(charactersIds) + } + + val pullToRefreshState = rememberPullToRefreshState() + if (pullToRefreshState.isRefreshing) { + LaunchedEffect(Unit) { + viewModel.loadCharacters(charactersIds) + // Fail safety timeout + delay(500) + if (!uiState.isLoading) { + pullToRefreshState.endRefresh() + } + } + } + + if (!uiState.isLoading) { + LaunchedEffect(Unit) { + pullToRefreshState.endRefresh() + } + } + + Box( + modifier = modifier + .fillMaxSize() + .nestedScroll(pullToRefreshState.nestedScrollConnection), + ) { + if (uiState.isLoading) { + LoadingScreen() + } else if (uiState.isNoConnectionError) { + NoConnectionErrorScreen( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + ) + } else if (uiState.unknownError != null) { + UnknownErrorScreen( + message = uiState.unknownError!!, + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + ) + } else { + CharactersList( + onCharacterClicked = { character -> + navController.navigate(NavRoute.CharacterDetailsScreen.endpoint + character.id) + }, + characters = uiState.characters, + ) + } + PullToRefreshContainer( + state = pullToRefreshState, + modifier = Modifier.align(Alignment.TopCenter), + ) + } +} + +@Composable +fun CharactersList( + modifier: Modifier = Modifier, + onCharacterClicked: (Character) -> Unit = {}, + characters: Set, +) { + LazyColumn( + modifier + .fillMaxSize() + .padding(8.dp), + contentPadding = PaddingValues(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(characters.toList(), key = Character::id) { character -> + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { onCharacterClicked(character) }, + colors = CardDefaults.cardColors( + containerColor = if (character.status == "Dead") { + MaterialTheme.colorScheme.error + } else { + Color.Unspecified + }, + ), + ) { + Row(modifier = Modifier.height(80.dp)) { + AsyncImageWithPreview( + model = character.image, + contentDescription = character.name, + modifier = Modifier + .fillMaxHeight() + .aspectRatio(1f), + ) + + Column( + modifier = Modifier + .fillMaxHeight() + .weight(1f) + .padding(8.dp), + ) { + Text( + text = "#${character.id}", + modifier = Modifier + .fillMaxWidth(), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.labelMedium, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = character.name, + modifier = Modifier + .fillMaxWidth(), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleMedium, + ) + } + } + } + } + } +} + +@Preview +@Composable +fun CharactersScreenSuccessPreview() { + RickAndMortyTheme(darkTheme = true) { + CharactersList( + characters = setOf( + Character( + id = 1, + name = "Rick Sanchez", + status = "Alive", + species = "Human", + type = "", + gender = "Male", + origin = "Earth (C-137)", + location = "Citadel of Ricks", + image = "https://rickandmortyapi.com/api/character/avatar/1.jpeg", + + ), + Character( + id = 2, + name = "Morty Smith", + status = "Dead", + species = "Human", + type = "", + gender = "Male", + origin = "unknown", + location = "Citadel of Ricks", + image = "https://rickandmortyapi.com/api/character/avatar/2.jpeg", + ), + ), + ) + } +} diff --git a/app/src/main/java/com/mohsenoid/rickandmorty/ui/characters/list/CharactersUiState.kt b/app/src/main/java/com/mohsenoid/rickandmorty/ui/characters/list/CharactersUiState.kt new file mode 100644 index 0000000..3ff7bc7 --- /dev/null +++ b/app/src/main/java/com/mohsenoid/rickandmorty/ui/characters/list/CharactersUiState.kt @@ -0,0 +1,11 @@ +package com.mohsenoid.rickandmorty.ui.characters.list + +import com.mohsenoid.rickandmorty.domain.characters.model.Character + + +data class CharactersUiState( + val isLoading: Boolean = false, + val characters: Set = emptySet(), + val isNoConnectionError: Boolean = false, + val unknownError: String? = null, +) diff --git a/app/src/main/java/com/mohsenoid/rickandmorty/ui/characters/list/CharactersViewModel.kt b/app/src/main/java/com/mohsenoid/rickandmorty/ui/characters/list/CharactersViewModel.kt new file mode 100644 index 0000000..fad6e30 --- /dev/null +++ b/app/src/main/java/com/mohsenoid/rickandmorty/ui/characters/list/CharactersViewModel.kt @@ -0,0 +1,67 @@ +package com.mohsenoid.rickandmorty.ui.characters.list + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.mohsenoid.rickandmorty.domain.RepositoryGetResult +import com.mohsenoid.rickandmorty.domain.characters.CharactersRepository +import com.mohsenoid.rickandmorty.domain.characters.model.Character +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +class CharactersViewModel( + private val charactersRepository: CharactersRepository, +) : ViewModel() { + + private val state: MutableStateFlow = MutableStateFlow(State.Loading) + + val uiState: StateFlow = state.map { it.toUiState() } + .stateIn(viewModelScope, SharingStarted.Eagerly, CharactersUiState(isLoading = true)) + + fun loadCharacters(characters: Set) { + state.value = State.Loading + + viewModelScope.launch { + when (val result = charactersRepository.getCharacters(characters)) { + is RepositoryGetResult.Success -> { + state.value = State.Success(characters = result.data) + } + + is RepositoryGetResult.Failure.EndOfList -> { + state.value = State.LoadingUnknownError(result.message) + } + + is RepositoryGetResult.Failure.NoConnection -> { + state.value = State.LoadingNoConnectionError + } + + is RepositoryGetResult.Failure.Unknown -> { + state.value = State.LoadingUnknownError(result.message) + } + } + } + } + + internal sealed interface State { + + data object Loading : State + + data class Success(val characters: Set) : State + + data object LoadingNoConnectionError : State + + data class LoadingUnknownError(val message: String) : State + + fun toUiState(): CharactersUiState { + return when (this) { + Loading -> CharactersUiState(isLoading = true) + is Success -> CharactersUiState(characters = characters) + is LoadingNoConnectionError -> CharactersUiState(isNoConnectionError = true) + is LoadingUnknownError -> CharactersUiState(unknownError = message) + } + } + } +} diff --git a/app/src/main/java/com/mohsenoid/rickandmorty/ui/episodes/EpisodesScreen.kt b/app/src/main/java/com/mohsenoid/rickandmorty/ui/episodes/EpisodesScreen.kt new file mode 100644 index 0000000..7b6f0e2 --- /dev/null +++ b/app/src/main/java/com/mohsenoid/rickandmorty/ui/episodes/EpisodesScreen.kt @@ -0,0 +1,257 @@ +package com.mohsenoid.rickandmorty.ui.episodes + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.WifiOff +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshContainer +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavHostController +import com.mohsenoid.rickandmorty.domain.episodes.model.Episode +import com.mohsenoid.rickandmorty.ui.LoadingScreen +import com.mohsenoid.rickandmorty.ui.NavRoute +import com.mohsenoid.rickandmorty.ui.NoConnectionErrorScreen +import com.mohsenoid.rickandmorty.ui.UnknownErrorScreen +import com.mohsenoid.rickandmorty.ui.theme.RickAndMortyTheme +import com.mohsenoid.rickandmorty.ui.util.EndlessLazyColumn +import kotlinx.coroutines.delay +import org.koin.androidx.compose.koinViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EpisodesScreen( + navController: NavHostController, + modifier: Modifier = Modifier, +) { + val viewModel: EpisodesViewModel = koinViewModel() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + viewModel.loadEpisodes() + } + + val pullToRefreshState = rememberPullToRefreshState() + if (pullToRefreshState.isRefreshing) { + LaunchedEffect(Unit) { + viewModel.loadEpisodes() + // Fail safety timeout + delay(500) + if (!uiState.isLoading) { + pullToRefreshState.endRefresh() + } + } + } + + if (!uiState.isLoading) { + LaunchedEffect(Unit) { + pullToRefreshState.endRefresh() + } + } + + Box( + modifier = modifier + .fillMaxSize() + .nestedScroll(pullToRefreshState.nestedScrollConnection), + ) { + if (uiState.isLoading) { + LoadingScreen() + } else if (uiState.isNoConnectionError) { + NoConnectionErrorScreen( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + ) + } else if (uiState.unknownError != null) { + UnknownErrorScreen( + message = uiState.unknownError!!, + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + ) + } else { + EpisodesList( + isNoConnectionError = uiState.isLoadMoreNoConnectionError, + isLoadingMore = uiState.isLoadingMore, + isEndOfList = uiState.isEndOfList, + onEndOfListReached = viewModel::onEndOfListReached, + onEpisodeClicked = { episode -> + val charactersArg = episode.characters.joinToString(",") + navController.navigate(NavRoute.CharactersScreen.endpoint + charactersArg) + }, + episodes = uiState.episodes, + ) + } + PullToRefreshContainer( + state = pullToRefreshState, + modifier = Modifier.align(Alignment.TopCenter), + ) + } +} + +@Composable +fun EpisodesList( + modifier: Modifier = Modifier, + isNoConnectionError: Boolean = false, + isLoadingMore: Boolean = false, + isEndOfList: Boolean = false, + onEndOfListReached: () -> Unit = {}, + onEpisodeClicked: (Episode) -> Unit = {}, + episodes: List, +) { + EndlessLazyColumn( + modifier = modifier + .fillMaxSize() + .padding(8.dp), + isNoConnectionError = isNoConnectionError, + isLoading = isLoadingMore, + isEndOfList = isEndOfList, + contentPadding = PaddingValues(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + items = episodes, + itemKey = Episode::id, + loadMore = onEndOfListReached, + noConnectionItem = { + Icon( + imageVector = Icons.Filled.WifiOff, + contentDescription = "No Connection Error", + modifier = Modifier.size(48.dp), + tint = Color.Red, + ) + }, + loadingItem = { + CircularProgressIndicator(Modifier.size(48.dp)) + }, + itemContent = { episode -> + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { onEpisodeClicked(episode) }, + ) { + Row(modifier = Modifier.height(80.dp)) { + Box( + modifier = Modifier + .fillMaxHeight() + .width(100.dp) + .background(MaterialTheme.colorScheme.inverseOnSurface), + contentAlignment = Alignment.Center, + ) { + Text( + text = episode.episode, + style = MaterialTheme.typography.titleLarge, + ) + } + + Column( + modifier = Modifier + .fillMaxHeight() + .weight(1f) + .padding(8.dp), + ) { + Text( + text = episode.airDate, + modifier = Modifier.fillMaxWidth(), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.labelMedium, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = episode.name, + modifier = Modifier + .fillMaxWidth(), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleMedium, + ) + } + } + } + }, + ) +} + +@Preview +@Composable +fun EpisodesListDarkPreview() { + RickAndMortyTheme(darkTheme = true) { + EpisodesList( + isLoadingMore = true, + isEndOfList = false, + episodes = listOf( + Episode( + id = 1, + name = "Pilot", + airDate = "December 2, 2013", + episode = "S01E01", + characters = setOf(1, 2, 3), + ), + Episode( + id = 2, + name = "Lawnmower Dog", + airDate = "December 9, 2013", + episode = "S01E02", + characters = setOf(1, 2, 5, 6), + ), + ), + ) + } +} + +@Preview +@Composable +fun EpisodesListPreview() { + RickAndMortyTheme(darkTheme = false) { + EpisodesList( + isLoadingMore = true, + isEndOfList = false, + episodes = listOf( + Episode( + id = 1, + name = "Pilot", + airDate = "December 2, 2013", + episode = "S01E01", + characters = setOf(1, 2, 3), + ), + Episode( + id = 2, + name = "Lawnmower Dog", + airDate = "December 9, 2013", + episode = "S01E02", + characters = setOf(1, 2, 5, 6), + ), + ), + ) + } +} diff --git a/app/src/main/java/com/mohsenoid/rickandmorty/ui/episodes/EpisodesUiState.kt b/app/src/main/java/com/mohsenoid/rickandmorty/ui/episodes/EpisodesUiState.kt new file mode 100644 index 0000000..ca6bba1 --- /dev/null +++ b/app/src/main/java/com/mohsenoid/rickandmorty/ui/episodes/EpisodesUiState.kt @@ -0,0 +1,14 @@ +package com.mohsenoid.rickandmorty.ui.episodes + +import com.mohsenoid.rickandmorty.domain.episodes.model.Episode + +data class EpisodesUiState( + val isLoading: Boolean = false, + val episodes: List = emptyList(), + val isNoConnectionError: Boolean = false, + val unknownError: String? = null, + val isLoadingMore: Boolean = false, + val isLoadMoreNoConnectionError: Boolean = false, + val moreError: String? = null, + val isEndOfList: Boolean = false, +) diff --git a/app/src/main/java/com/mohsenoid/rickandmorty/ui/episodes/EpisodesViewModel.kt b/app/src/main/java/com/mohsenoid/rickandmorty/ui/episodes/EpisodesViewModel.kt new file mode 100644 index 0000000..0ba4fac --- /dev/null +++ b/app/src/main/java/com/mohsenoid/rickandmorty/ui/episodes/EpisodesViewModel.kt @@ -0,0 +1,244 @@ +package com.mohsenoid.rickandmorty.ui.episodes + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.mohsenoid.rickandmorty.domain.RepositoryGetResult +import com.mohsenoid.rickandmorty.domain.episodes.EpisodesRepository +import com.mohsenoid.rickandmorty.domain.episodes.model.Episode +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class EpisodesViewModel( + private val episodesRepository: EpisodesRepository, +) : ViewModel() { + + private val state: MutableStateFlow = MutableStateFlow(State.Loading) + + val uiState: StateFlow = state.map { it.toUiState() } + .stateIn(viewModelScope, SharingStarted.Eagerly, EpisodesUiState()) + + fun loadEpisodes() { + state.value = State.Loading + + viewModelScope.launch { + when (val result = episodesRepository.getEpisodes()) { + is RepositoryGetResult.Success -> { + state.value = State.Success(page = 0, episodes = result.data) + } + + is RepositoryGetResult.Failure.EndOfList -> { + state.value = State.LoadingUnknownError(result.message) + } + + is RepositoryGetResult.Failure.NoConnection -> { + state.value = State.LoadingNoConnectionError + } + + is RepositoryGetResult.Failure.Unknown -> { + state.value = State.LoadingUnknownError(result.message) + } + } + } + } + + fun onEndOfListReached() { + state.update { currentState -> + when (currentState) { + State.Loading, + is State.LoadingNoConnectionError, + is State.LoadingUnknownError, + is State.LoadingMore, + -> { + // no op + return + } + + is State.Success -> { + loadMoreEpisodes(currentState.page + 1) + currentState.toLoadingMore() + } + + is State.LoadingMoreNoConnectionError -> { + loadMoreEpisodes(currentState.page + 1) + currentState.toLoadingMore() + } + + is State.LoadingMoreUnknownError -> { + loadMoreEpisodes(currentState.page + 1) + currentState.toLoadingMore() + } + } + } + } + + private fun loadMoreEpisodes(page: Int) { + viewModelScope.launch { + when (val result = episodesRepository.getEpisodes(page)) { + is RepositoryGetResult.Success -> { + state.update { currentState -> + when (currentState) { + State.Loading, + is State.LoadingUnknownError, + is State.LoadingNoConnectionError, + is State.LoadingMoreUnknownError, + is State.LoadingMoreNoConnectionError, + is State.Success, + -> { + // no op + return@launch + } + + is State.LoadingMore -> { + State.Success( + page = page + 1, + episodes = currentState.episodes + result.data, + ) + } + } + } + } + + is RepositoryGetResult.Failure.EndOfList -> { + state.update { currentState -> + when (currentState) { + State.Loading, + is State.LoadingNoConnectionError, + is State.LoadingUnknownError, + is State.LoadingMoreNoConnectionError, + is State.LoadingMoreUnknownError, + is State.Success, + -> { + // no op + return@launch + } + + is State.LoadingMore -> { + currentState.toSuccess(isEndOfList = true) + } + } + } + } + + is RepositoryGetResult.Failure.NoConnection -> { + state.update { currentState -> + when (currentState) { + State.Loading, + is State.LoadingNoConnectionError, + is State.LoadingUnknownError, + is State.LoadingMoreNoConnectionError, + is State.LoadingMoreUnknownError, + is State.Success, + -> { + // no op + return@launch + } + + is State.LoadingMore -> { + currentState.toLoadingMoreNoConnectionError() + } + } + } + } + + is RepositoryGetResult.Failure.Unknown -> { + state.update { currentState -> + when (currentState) { + State.Loading, + is State.LoadingNoConnectionError, + is State.LoadingUnknownError, + is State.LoadingMoreNoConnectionError, + is State.LoadingMoreUnknownError, + is State.Success, + -> { + // no op + return@launch + } + + is State.LoadingMore -> { + currentState.toLoadingMoreUnknownError(result.message) + } + } + } + } + } + } + } + + internal sealed interface State { + + data object Loading : State + + data class Success( + val page: Int, + val episodes: List, + val isEndOfList: Boolean = false, + ) : State { + fun toLoadingMore() = LoadingMore(page, episodes) + } + + data object LoadingNoConnectionError : State + + data class LoadingUnknownError(val message: String) : State + + data class LoadingMore(val page: Int, val episodes: List) : State { + fun toSuccess(isEndOfList: Boolean) = Success(page, episodes, isEndOfList) + fun toLoadingMoreNoConnectionError() = + LoadingMoreNoConnectionError(page, episodes) + + fun toLoadingMoreUnknownError(message: String) = + LoadingMoreUnknownError(page, episodes, message) + } + + data class LoadingMoreNoConnectionError( + val page: Int, + val episodes: List, + ) : State { + fun toLoadingMore() = LoadingMore(page, episodes) + } + + data class LoadingMoreUnknownError( + val page: Int, + val episodes: List, + val message: String, + ) : State { + fun toLoadingMore() = LoadingMore(page, episodes) + } + + fun toUiState(): EpisodesUiState { + return when (this) { + Loading -> { + EpisodesUiState(isLoading = true) + } + + is Success -> { + EpisodesUiState(episodes = episodes, isEndOfList = isEndOfList) + } + + is LoadingNoConnectionError -> { + EpisodesUiState(isNoConnectionError = true) + } + + is LoadingUnknownError -> { + EpisodesUiState(unknownError = message) + } + + is LoadingMore -> { + EpisodesUiState(episodes = episodes, isLoadingMore = true) + } + + is LoadingMoreNoConnectionError -> { + EpisodesUiState(episodes = episodes, isLoadMoreNoConnectionError = true) + } + + is LoadingMoreUnknownError -> { + EpisodesUiState(episodes = episodes, moreError = message) + } + } + } + } +} diff --git a/app/src/main/java/com/mohsenoid/rickandmorty/ui/theme/Color.kt b/app/src/main/java/com/mohsenoid/rickandmorty/ui/theme/Color.kt new file mode 100644 index 0000000..b8b5a83 --- /dev/null +++ b/app/src/main/java/com/mohsenoid/rickandmorty/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package com.mohsenoid.rickandmorty.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) diff --git a/app/src/main/java/com/mohsenoid/rickandmorty/ui/theme/Theme.kt b/app/src/main/java/com/mohsenoid/rickandmorty/ui/theme/Theme.kt new file mode 100644 index 0000000..3614b49 --- /dev/null +++ b/app/src/main/java/com/mohsenoid/rickandmorty/ui/theme/Theme.kt @@ -0,0 +1,57 @@ +package com.mohsenoid.rickandmorty.ui.theme + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +private val DarkColorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80, +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40, + + /* Other default colors to override + background = Color(0xFFFFFBFE), + surface = Color(0xFFFFFBFE), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F), + */ +) + +@Composable +fun RickAndMortyTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + content: @Composable () -> Unit, +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content, + ) +} diff --git a/app/src/main/java/com/mohsenoid/rickandmorty/ui/theme/Type.kt b/app/src/main/java/com/mohsenoid/rickandmorty/ui/theme/Type.kt new file mode 100644 index 0000000..5b944e2 --- /dev/null +++ b/app/src/main/java/com/mohsenoid/rickandmorty/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package com.mohsenoid.rickandmorty.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp, + ), + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) diff --git a/app/src/main/java/com/mohsenoid/rickandmorty/ui/util/AsyncImageWithPreview.kt b/app/src/main/java/com/mohsenoid/rickandmorty/ui/util/AsyncImageWithPreview.kt new file mode 100644 index 0000000..273bf59 --- /dev/null +++ b/app/src/main/java/com/mohsenoid/rickandmorty/ui/util/AsyncImageWithPreview.kt @@ -0,0 +1,54 @@ +package com.mohsenoid.rickandmorty.ui.util + +import androidx.compose.foundation.Image +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.DefaultAlpha +import androidx.compose.ui.graphics.FilterQuality +import androidx.compose.ui.graphics.drawscope.DrawScope.Companion.DefaultFilterQuality +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.res.painterResource +import coil.compose.AsyncImage +import coil.compose.AsyncImagePainter.Companion.DefaultTransform +import coil.compose.AsyncImagePainter.State +import com.mohsenoid.rickandmorty.R + +@Composable +fun AsyncImageWithPreview( + model: Any?, + contentDescription: String?, + modifier: Modifier = Modifier, + transform: (State) -> State = DefaultTransform, + onState: ((State) -> Unit)? = null, + alignment: Alignment = Alignment.Center, + contentScale: ContentScale = ContentScale.Fit, + alpha: Float = DefaultAlpha, + colorFilter: ColorFilter? = null, + filterQuality: FilterQuality = DefaultFilterQuality, +) { + if (LocalInspectionMode.current) { + // Show a placeholder in preview mode + Image( + painter = painterResource(id = R.drawable.ic_launcher_foreground), + contentDescription = null, + modifier = modifier, + ) + } else { + // Use AsyncImage in the actual app + AsyncImage( + model = model, + contentDescription = contentDescription, + modifier = modifier, + transform = transform, + onState = onState, + alignment = alignment, + contentScale = contentScale, + alpha = alpha, + colorFilter = colorFilter, + filterQuality = filterQuality, + ) + } +} diff --git a/app/src/main/java/com/mohsenoid/rickandmorty/ui/util/EndlessLazyColumn.kt b/app/src/main/java/com/mohsenoid/rickandmorty/ui/util/EndlessLazyColumn.kt new file mode 100644 index 0000000..e3ce0ab --- /dev/null +++ b/app/src/main/java/com/mohsenoid/rickandmorty/ui/util/EndlessLazyColumn.kt @@ -0,0 +1,118 @@ +package com.mohsenoid.rickandmorty.ui.util + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +@Composable +internal fun EndlessLazyColumn( + modifier: Modifier = Modifier, + isNoConnectionError: Boolean = false, + isLoading: Boolean = false, + isEndOfList: Boolean = false, + listState: LazyListState = rememberLazyListState(), + contentPadding: PaddingValues = PaddingValues(0.dp), + reverseLayout: Boolean = false, + verticalArrangement: Arrangement.Vertical = + if (!reverseLayout) Arrangement.Top else Arrangement.Bottom, + items: List, + itemKey: (T) -> Any, + loadMore: () -> Unit, + noConnectionItem: @Composable () -> Unit, + loadingItem: @Composable () -> Unit, + itemContent: @Composable (T) -> Unit, +) { + val reachedBottom: Boolean by remember { derivedStateOf { listState.reachedBottom() } } + + // load more if scrolled to bottom + LaunchedEffect(reachedBottom) { + if (reachedBottom && !isLoading && !isEndOfList) { + loadMore() + } + } + + Column(modifier = modifier, verticalArrangement = Arrangement.Center) { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + state = listState, + contentPadding = contentPadding, + reverseLayout = reverseLayout, + verticalArrangement = verticalArrangement, + ) { + items( + items = items, + key = { item: T -> itemKey(item) }, + ) { item -> + itemContent(item) + } + + item { + if (!isEndOfList) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(48.dp), + contentAlignment = Alignment.Center, + ) { + if (isNoConnectionError) { + noConnectionItem() + } else if (isLoading) { + loadingItem() + } + } + } + } + } + + + } +} + +@Preview(showBackground = true) +@Composable +fun EndlessLazyColumnPreview() { + EndlessLazyColumn( + modifier = Modifier.fillMaxSize(), + isNoConnectionError = false, + isLoading = true, + isEndOfList = false, + items = listOf("1", "2", "3", "4", "5"), + itemKey = { it }, + noConnectionItem = {}, + loadMore = {}, + loadingItem = { CircularProgressIndicator() }, + ) { + Text( + text = it, + textAlign = TextAlign.Center, + ) + } +} + +private fun LazyListState.reachedBottom(): Boolean { + val lastVisibleItem = this.layoutInfo.visibleItemsInfo.lastOrNull() + return lastVisibleItem?.index != 0 && lastVisibleItem?.index == this.layoutInfo.totalItemsCount - 1 +} + diff --git a/app/src/main/kotlin/com/mohsenoid/rickandmorty/RickAndMortyApplication.kt b/app/src/main/kotlin/com/mohsenoid/rickandmorty/RickAndMortyApplication.kt deleted file mode 100644 index 682f6a1..0000000 --- a/app/src/main/kotlin/com/mohsenoid/rickandmorty/RickAndMortyApplication.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.mohsenoid.rickandmorty - -import androidx.multidex.MultiDexApplication -import com.mohsenoid.rickandmorty.data.dataModule -import com.mohsenoid.rickandmorty.util.KoinQualifiersNames -import org.koin.android.ext.koin.androidContext -import org.koin.android.ext.koin.androidLogger -import org.koin.core.component.KoinComponent -import org.koin.core.context.startKoin - -class RickAndMortyApplication : MultiDexApplication(), KoinComponent { - - private val isDebug: Boolean = BuildConfig.DEBUG - - override fun onCreate() { - super.onCreate() - - startKoin { - val appProperties: Map = mapOf( - KoinQualifiersNames.BASE_URL to BuildConfig.BASE_URL, - ) - properties(appProperties) - - if (isDebug) androidLogger() - - androidContext(this@RickAndMortyApplication) - modules(appModule + dataModule) - } - } -} diff --git a/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/RepositoryImpl.kt b/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/RepositoryImpl.kt deleted file mode 100644 index 0adec23..0000000 --- a/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/RepositoryImpl.kt +++ /dev/null @@ -1,136 +0,0 @@ -package com.mohsenoid.rickandmorty.data - -import com.mohsenoid.rickandmorty.data.api.ApiRickAndMorty -import com.mohsenoid.rickandmorty.data.api.model.ApiCharacter -import com.mohsenoid.rickandmorty.data.api.model.ApiEpisode -import com.mohsenoid.rickandmorty.data.api.model.ApiResult -import com.mohsenoid.rickandmorty.data.db.DbRickAndMorty -import com.mohsenoid.rickandmorty.data.db.model.DbCharacter -import com.mohsenoid.rickandmorty.data.db.model.DbEpisode -import com.mohsenoid.rickandmorty.data.mapper.toDbCharacter -import com.mohsenoid.rickandmorty.data.mapper.toDbEpisode -import com.mohsenoid.rickandmorty.data.mapper.toModelCharacter -import com.mohsenoid.rickandmorty.data.mapper.toModelEpisode -import com.mohsenoid.rickandmorty.domain.Repository -import com.mohsenoid.rickandmorty.domain.model.ModelCharacter -import com.mohsenoid.rickandmorty.domain.model.ModelEpisode -import com.mohsenoid.rickandmorty.domain.model.PageQueryResult -import com.mohsenoid.rickandmorty.domain.model.QueryResult -import com.mohsenoid.rickandmorty.util.StatusProvider - -@Suppress("TooManyFunctions") -class RepositoryImpl internal constructor( - private val db: DbRickAndMorty, - private val api: ApiRickAndMorty, - private val statusProvider: StatusProvider, -) : Repository { - - override suspend fun getEpisodes(page: Int): PageQueryResult> { - if (statusProvider.isOnline()) { - val result = fetchNetworkEpisodes(page) - if (result is PageQueryResult.Successful) cacheNetworkEpisodes(result.data) - } - - return queryDbEpisodes(page, PAGE_SIZE) - } - - private suspend fun fetchNetworkEpisodes(page: Int): PageQueryResult> = - when (val result = api.fetchEpisodes(page)) { - is ApiResult.Success -> PageQueryResult.Successful(result.data.results) - is ApiResult.Error.ServerError, - is ApiResult.Error.UnknownError, - -> PageQueryResult.Error - } - - private suspend fun cacheNetworkEpisodes(episodes: List) { - episodes.map(ApiEpisode::toDbEpisode) - .forEach { db.insertEpisode(it) } - } - - private suspend fun queryDbEpisodes( - page: Int, - pageSize: Int, - ): PageQueryResult> { - val dbEntityEpisodes: List = db.queryAllEpisodesByPage(page, pageSize) - val episodes: List = dbEntityEpisodes.map(DbEpisode::toModelEpisode) - - if (episodes.isEmpty()) return PageQueryResult.EndOfList - - return PageQueryResult.Successful(episodes) - } - - override suspend fun getCharactersByIds( - characterIds: List, - ): QueryResult> { - if (statusProvider.isOnline()) { - val result = fetchNetworkCharactersByIds(characterIds) - if (result is QueryResult.Successful) cacheNetworkCharacters(result.data) - } - - return queryDbCharactersByIds(characterIds) - } - - private suspend fun fetchNetworkCharactersByIds( - characterIds: List, - ): QueryResult> = - when (val result = api.fetchCharactersByIds(characterIds.joinToString(","))) { - is ApiResult.Success -> QueryResult.Successful(result.data) - is ApiResult.Error.ServerError, - is ApiResult.Error.UnknownError, - -> QueryResult.Error - } - - private suspend fun cacheNetworkCharacters(characters: List) { - characters.map(ApiCharacter::toDbCharacter) - .forEach { db.insertOrUpdateCharacter(it) } - } - - private suspend fun queryDbCharactersByIds( - characterIds: List, - ): QueryResult> { - val dbCharacters: List = db.queryCharactersByIds(characterIds) - - val characters: List = dbCharacters.map(DbCharacter::toModelCharacter) - - if (characters.isEmpty()) QueryResult.NoCache - - return QueryResult.Successful(characters) - } - - override suspend fun getCharacterDetails(characterId: Int): QueryResult { - if (statusProvider.isOnline()) { - val result = fetchNetworkCharacterDetails(characterId) - if (result is QueryResult.Successful) cacheNetworkCharacter(result.data) - } - - return queryDbCharacterDetails(characterId) - } - - private suspend fun fetchNetworkCharacterDetails(characterId: Int): QueryResult = - when (val result = api.fetchCharacterDetails(characterId)) { - is ApiResult.Success -> QueryResult.Successful(result.data) - is ApiResult.Error.ServerError, - is ApiResult.Error.UnknownError, - -> QueryResult.Error - } - - private suspend fun cacheNetworkCharacter(character: ApiCharacter) { - db.insertOrUpdateCharacter(character.toDbCharacter()) - } - - private suspend fun queryDbCharacterDetails(characterId: Int): QueryResult { - val dbCharacter: DbCharacter = - db.queryCharacter(characterId) ?: return QueryResult.NoCache - - return QueryResult.Successful(dbCharacter.toModelCharacter()) - } - - override suspend fun killCharacter(characterId: Int): QueryResult { - db.killCharacter(characterId) - return getCharacterDetails(characterId) - } - - companion object { - const val PAGE_SIZE: Int = 20 - } -} diff --git a/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/api/ApiRickAndMorty.kt b/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/api/ApiRickAndMorty.kt deleted file mode 100644 index 3f9abcc..0000000 --- a/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/api/ApiRickAndMorty.kt +++ /dev/null @@ -1,107 +0,0 @@ -package com.mohsenoid.rickandmorty.data.api - -import android.content.Context -import com.chuckerteam.chucker.api.ChuckerCollector -import com.chuckerteam.chucker.api.ChuckerInterceptor -import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory -import com.mohsenoid.rickandmorty.BuildConfig -import com.mohsenoid.rickandmorty.data.api.mapper.ApiResultMapper.toApiResult -import com.mohsenoid.rickandmorty.data.api.model.ApiCharacter -import com.mohsenoid.rickandmorty.data.api.model.ApiEpisodes -import com.mohsenoid.rickandmorty.data.api.model.ApiResult -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.json.Json -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.OkHttpClient -import okhttp3.logging.HttpLoggingInterceptor -import retrofit2.Response -import retrofit2.Retrofit -import retrofit2.http.GET -import retrofit2.http.Path -import retrofit2.http.Query -import java.net.UnknownHostException - -@OptIn(ExperimentalSerializationApi::class) -internal class ApiRickAndMorty(applicationContext: Context, baseUrl: String) { - - private val api = ApiClient.create(applicationContext, baseUrl) - - suspend fun fetchEpisodes(page: Int): ApiResult = - toApiResult { - api.fetchEpisodes(page) - } - - suspend fun fetchCharactersByIds(characterIds: String): ApiResult> = - toApiResult { - api.fetchCharactersByIds(characterIds) - } - - suspend fun fetchCharacterDetails(characterId: Int): ApiResult = - toApiResult { - api.fetchCharacterDetails(characterId) - } - - internal interface ApiClient { - - @GET(value = EPISODE_ENDPOINT) - suspend fun fetchEpisodes( - @Query(value = PARAM_KEY_PAGE) - page: Int, - ): Response - - @GET(value = "$CHARACTER_ENDPOINT{$PATH_KEY_CHARACTER_IDS}") - suspend fun fetchCharactersByIds( - @Path(value = PATH_KEY_CHARACTER_IDS) - characterIds: String, - ): Response> - - @GET(value = "$CHARACTER_ENDPOINT{$PATH_KEY_CHARACTER_ID}") - suspend fun fetchCharacterDetails( - @Path(value = PATH_KEY_CHARACTER_ID) - characterId: Int, - ): Response - - companion object { - private const val EPISODE_ENDPOINT: String = "episode/" - private const val CHARACTER_ENDPOINT: String = "character/" - - private const val PARAM_KEY_PAGE: String = "page" - - private const val PATH_KEY_CHARACTER_IDS: String = "characterIds" - private const val PATH_KEY_CHARACTER_ID: String = "characterId" - - internal fun create(applicationContext: Context, baseUrl: String): ApiClient { - val httpUrl = baseUrl.toHttpUrlOrNull() - ?: throw UnknownHostException("Invalid host: $baseUrl") - - val contentType = "application/json".toMediaType() - val converterFactory = Json.asConverterFactory(contentType) - - val loggerInterceptor = HttpLoggingInterceptor().apply { - level = HttpLoggingInterceptor.Level.BODY - } - - val chuckerInterceptor = ChuckerInterceptor.Builder(applicationContext) - .collector(ChuckerCollector(applicationContext)) - .build() - - val okHttpClient = OkHttpClient.Builder().apply { - val isDebug: Boolean = BuildConfig.DEBUG - if (isDebug) { - addInterceptor(loggerInterceptor) - } - addInterceptor(chuckerInterceptor) - }.build() - - val retrofit = Retrofit.Builder() - .baseUrl(httpUrl) - .addConverterFactory(converterFactory) - .client(okHttpClient) - .build() - - return retrofit.create(ApiClient::class.java) - } - } - } -} diff --git a/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/api/mapper/ApiResultMapper.kt b/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/api/mapper/ApiResultMapper.kt deleted file mode 100644 index b9f8867..0000000 --- a/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/api/mapper/ApiResultMapper.kt +++ /dev/null @@ -1,55 +0,0 @@ -package com.mohsenoid.rickandmorty.data.api.mapper - -import com.mohsenoid.rickandmorty.data.api.model.ApiResult -import retrofit2.Response -import timber.log.Timber -import java.net.UnknownHostException - -object ApiResultMapper { - - private const val HTTP_CODE_400_BAD_REQUEST = 400 - private const val HTTP_CODE_401_UNAUTHORIZED = 401 - private const val HTTP_CODE_403_FORBIDDEN = 403 - private const val HTTP_CODE_503_SERVICE_UNAVAILABLE = 503 - - suspend inline fun toApiResult( - crossinline apiCall: suspend () -> Response, - ): ApiResult = kotlin.runCatching { - val response = apiCall() - if (response.isSuccessful) { - handleSuccessfulApiResponse(response) - } else { - handleErrorApiResponse(response) - } - }.getOrElse { throwable -> - Timber.e(throwable) - - when (throwable) { - is UnknownHostException -> ApiResult.Error.ServerError( - ApiResult.Error.ServerError.Reason.SERVER_UNREACHABLE, - ) - else -> ApiResult.Error.UnknownError(throwable) - } - } - - inline fun handleSuccessfulApiResponse(response: Response): ApiResult { - val body = response.body() - return when { - body != null -> ApiResult.Success(body) - else -> ApiResult.Error.UnknownError( - exception = IllegalStateException("response body is null"), - ) - } - } - - fun handleErrorApiResponse(response: Response<*>): ApiResult.Error.ServerError { - val reason = when (response.code()) { - HTTP_CODE_400_BAD_REQUEST -> ApiResult.Error.ServerError.Reason.BAD_REQUEST - HTTP_CODE_401_UNAUTHORIZED -> ApiResult.Error.ServerError.Reason.UNAUTHORIZED - HTTP_CODE_403_FORBIDDEN -> ApiResult.Error.ServerError.Reason.FORBIDDEN - HTTP_CODE_503_SERVICE_UNAVAILABLE -> ApiResult.Error.ServerError.Reason.SERVICE_UNAVAILABLE - else -> ApiResult.Error.ServerError.Reason.UNKNOWN - } - return ApiResult.Error.ServerError(reason = reason) - } -} diff --git a/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/api/model/ApiCharacter.kt b/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/api/model/ApiCharacter.kt deleted file mode 100644 index d2a89c1..0000000 --- a/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/api/model/ApiCharacter.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.mohsenoid.rickandmorty.data.api.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -internal data class ApiCharacter( - - @SerialName(value = "id") - val id: Int, - - @SerialName(value = "name") - val name: String, - - @SerialName(value = "status") - val status: String, - - @SerialName(value = "species") - val species: String, - - @SerialName(value = "type") - val type: String, - - @SerialName(value = "gender") - val gender: String, - - @SerialName(value = "origin") - val origin: ApiOrigin, - - @SerialName(value = "location") - val location: ApiLocation, - - @SerialName(value = "image") - val image: String, - - @SerialName(value = "episode") - val episodes: List, - - @SerialName(value = "url") - val url: String, - - @SerialName(value = "created") - val created: String, -) diff --git a/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/api/model/ApiEpisode.kt b/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/api/model/ApiEpisode.kt deleted file mode 100644 index b84f052..0000000 --- a/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/api/model/ApiEpisode.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.mohsenoid.rickandmorty.data.api.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -internal data class ApiEpisode( - - @SerialName(value = "id") - val id: Int, - - @SerialName(value = "name") - val name: String, - - @SerialName(value = "air_date") - val airDate: String, - - @SerialName(value = "episode") - val episode: String, - - @SerialName(value = "characters") - val characters: List, - - @SerialName(value = "url") - val url: String, - - @SerialName(value = "created") - val created: String, -) diff --git a/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/api/model/ApiEpisodes.kt b/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/api/model/ApiEpisodes.kt deleted file mode 100644 index fef9b1e..0000000 --- a/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/api/model/ApiEpisodes.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.mohsenoid.rickandmorty.data.api.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -internal data class ApiEpisodes( - - @SerialName(value = "info") - val info: ApiInfo, - - @SerialName(value = "results") - val results: List, -) diff --git a/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/api/model/ApiErrorResponse.kt b/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/api/model/ApiErrorResponse.kt deleted file mode 100644 index ad9ce61..0000000 --- a/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/api/model/ApiErrorResponse.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.mohsenoid.rickandmorty.data.api.model - -import kotlinx.serialization.SerialName - -data class ApiErrorResponse( - - @SerialName(value = "error") - val error: String? = null, - - @SerialName(value = "message") - val message: String? = null, - - @SerialName(value = "orgMessage") - val orgMessage: String? = null, - - @SerialName(value = "httpStatus") - val httpStatus: Int? = null, -) diff --git a/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/api/model/ApiInfo.kt b/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/api/model/ApiInfo.kt deleted file mode 100644 index b2e099a..0000000 --- a/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/api/model/ApiInfo.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.mohsenoid.rickandmorty.data.api.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -internal data class ApiInfo( - - @SerialName(value = "count") - val count: Int, - - @SerialName(value = "pages") - val pages: Int, - - @SerialName(value = "next") - val next: String?, - - @SerialName(value = "prev") - val prev: String?, -) diff --git a/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/api/model/ApiLocation.kt b/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/api/model/ApiLocation.kt deleted file mode 100644 index c2e369a..0000000 --- a/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/api/model/ApiLocation.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.mohsenoid.rickandmorty.data.api.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -internal data class ApiLocation( - - @SerialName(value = "name") - val name: String, - - @SerialName(value = "url") - val url: String, -) diff --git a/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/api/model/ApiOrigin.kt b/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/api/model/ApiOrigin.kt deleted file mode 100644 index 7602f13..0000000 --- a/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/api/model/ApiOrigin.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.mohsenoid.rickandmorty.data.api.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -internal data class ApiOrigin( - - @SerialName(value = "name") - val name: String, - - @SerialName(value = "url") - val url: String, -) diff --git a/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/api/model/ApiResult.kt b/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/api/model/ApiResult.kt deleted file mode 100644 index 2a2c3d7..0000000 --- a/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/api/model/ApiResult.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.mohsenoid.rickandmorty.data.api.model - -sealed class ApiResult { - - data class Success(val data: R) : ApiResult() - - sealed class Error : ApiResult() { - - data class ServerError(val reason: Reason) : Error() { - - enum class Reason { - UNAUTHORIZED, - BAD_REQUEST, - FORBIDDEN, - SERVICE_UNAVAILABLE, - SERVER_UNREACHABLE, - UNKNOWN - } - } - - data class UnknownError(val exception: Throwable) : Error() - } -} diff --git a/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/api/module.kt b/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/api/module.kt deleted file mode 100644 index 8705d1f..0000000 --- a/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/api/module.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.mohsenoid.rickandmorty.data.api - -import com.mohsenoid.rickandmorty.util.KoinQualifiersNames -import kotlinx.serialization.ExperimentalSerializationApi -import org.koin.android.ext.koin.androidApplication -import org.koin.dsl.module - -@OptIn(ExperimentalSerializationApi::class) -val apiModule = module { - - single { - ApiRickAndMorty( - applicationContext = androidApplication(), - baseUrl = getProperty(KoinQualifiersNames.BASE_URL), - ) - } -} diff --git a/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/db/DbRickAndMorty.kt b/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/db/DbRickAndMorty.kt deleted file mode 100644 index 1b1c66d..0000000 --- a/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/db/DbRickAndMorty.kt +++ /dev/null @@ -1,77 +0,0 @@ -package com.mohsenoid.rickandmorty.data.db - -import android.content.Context -import androidx.room.Database -import androidx.room.Room -import androidx.room.RoomDatabase -import androidx.room.TypeConverters -import com.mohsenoid.rickandmorty.data.db.converters.DbTypeConverters -import com.mohsenoid.rickandmorty.data.db.dao.DbCharacterDaoAbs -import com.mohsenoid.rickandmorty.data.db.dao.DbEpisodeDao -import com.mohsenoid.rickandmorty.data.db.model.DbCharacter -import com.mohsenoid.rickandmorty.data.db.model.DbEpisode -import com.mohsenoid.rickandmorty.data.db.model.DbLocation -import com.mohsenoid.rickandmorty.data.db.model.DbOrigin - -internal class DbRickAndMorty(context: Context) { - - private val db = Db.create(context) - - suspend fun insertEpisode(entityEpisode: DbEpisode) = - db.episodeDao.insertEpisode(entityEpisode) - - suspend fun queryAllEpisodes(): List = - db.episodeDao.queryAllEpisodes() - - suspend fun queryAllEpisodesByPage(page: Int, pageSize: Int): List = - db.episodeDao.queryAllEpisodesByPage(page, pageSize) - - suspend fun insertCharacter(character: DbCharacter) = - db.characterDao.insertCharacter(character) - - suspend fun queryCharactersByIds(characterIds: List): List = - db.characterDao.queryCharactersByIds(characterIds) - - suspend fun queryCharacter(characterId: Int): DbCharacter? = - db.characterDao.queryCharacter(characterId) - - suspend fun killCharacter(characterId: Int) = - db.characterDao.killCharacter(characterId) - - suspend fun insertOrUpdateCharacter(character: DbCharacter) = - db.characterDao.insertOrUpdateCharacter(character) - - @Database( - entities = [ - DbCharacter::class, - DbEpisode::class, - DbLocation::class, - DbOrigin::class, - ], - version = DATABASE_VERSION, - ) - @TypeConverters(DbTypeConverters::class) - internal abstract class Db : RoomDatabase() { - - abstract val episodeDao: DbEpisodeDao - - abstract val characterDao: DbCharacterDaoAbs - - companion object { - fun create(context: Context): Db { - return Room.databaseBuilder( - context, - Db::class.java, - DATABASE_NAME, - ) - .fallbackToDestructiveMigration() - .build() - } - } - } - - companion object { - private const val DATABASE_NAME: String = "rickandmorty.db" - private const val DATABASE_VERSION: Int = 5 - } -} diff --git a/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/db/converters/DbTypeConverters.kt b/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/db/converters/DbTypeConverters.kt deleted file mode 100644 index 0a6011f..0000000 --- a/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/db/converters/DbTypeConverters.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.mohsenoid.rickandmorty.data.db.converters - -import androidx.room.TypeConverter -import com.mohsenoid.rickandmorty.data.db.model.DbLocation -import com.mohsenoid.rickandmorty.data.db.model.DbOrigin -import kotlinx.serialization.builtins.ListSerializer -import kotlinx.serialization.builtins.serializer -import kotlinx.serialization.json.Json - -internal class DbTypeConverters { - - @TypeConverter - fun locationToJson(location: DbLocation?): String? { - return location?.let { Json.encodeToString(DbLocation.serializer(), it) } - } - - @TypeConverter - fun jsonToLocation(locationJson: String?): DbLocation? { - return locationJson?.let { Json.decodeFromString(DbLocation.serializer(), it) } - } - - @TypeConverter - fun originToJson(origin: DbOrigin?): String? { - return origin?.let { Json.encodeToString(DbOrigin.serializer(), it) } - } - - @TypeConverter - fun jsonToOrigin(originJson: String?): DbOrigin? { - return originJson?.let { Json.decodeFromString(DbOrigin.serializer(), it) } - } - - @TypeConverter - fun listOfIntToString(list: List?): String? { - return list?.let { Json.encodeToString(ListSerializer(Int.serializer()), it) } - } - - @TypeConverter - fun stringToListOfInt(string: String?): List? { - return string?.let { Json.decodeFromString(ListSerializer(Int.serializer()), it) } - } -} diff --git a/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/db/dao/DbCharacterDao.kt b/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/db/dao/DbCharacterDao.kt deleted file mode 100644 index 0903eb5..0000000 --- a/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/db/dao/DbCharacterDao.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.mohsenoid.rickandmorty.data.db.dao - -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Query -import com.mohsenoid.rickandmorty.data.db.model.DbCharacter - -internal interface DbCharacterDao { - - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insertCharacter(character: DbCharacter) - - @Query(value = "SELECT * FROM characters WHERE _id IN (:characterIds)") - suspend fun queryCharactersByIds(characterIds: List): List - - @Query(value = "SELECT * FROM characters WHERE _id = :characterId LIMIT 1") - suspend fun queryCharacter(characterId: Int): DbCharacter? - - @Query(value = "UPDATE characters SET is_killed_by_user = 1 WHERE _id = :characterId") - suspend fun killCharacter(characterId: Int) - - suspend fun insertOrUpdateCharacter(character: DbCharacter) -} diff --git a/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/db/dao/DbCharacterDaoAbs.kt b/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/db/dao/DbCharacterDaoAbs.kt deleted file mode 100644 index 20fdfcc..0000000 --- a/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/db/dao/DbCharacterDaoAbs.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.mohsenoid.rickandmorty.data.db.dao - -import androidx.room.Dao -import com.mohsenoid.rickandmorty.data.db.model.DbCharacter - -@Dao -internal abstract class DbCharacterDaoAbs : DbCharacterDao { - - override suspend fun insertOrUpdateCharacter(character: DbCharacter) { - val oldCharacter: DbCharacter? = queryCharacter(characterId = character.id) - - insertCharacter( - character = if (oldCharacter != null) { - character.copy(isKilledByUser = oldCharacter.isKilledByUser) - } else { - character - }, - ) - } -} diff --git a/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/db/dao/DbEpisodeDao.kt b/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/db/dao/DbEpisodeDao.kt deleted file mode 100644 index 2c6034a..0000000 --- a/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/db/dao/DbEpisodeDao.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.mohsenoid.rickandmorty.data.db.dao - -import androidx.room.Dao -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Query -import com.mohsenoid.rickandmorty.data.db.model.DbEpisode - -@Dao -internal interface DbEpisodeDao { - - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insertEpisode(entityEpisode: DbEpisode) - - @Query(value = "SELECT * FROM episodes") - suspend fun queryAllEpisodes(): List - - @Query(value = "SELECT * FROM episodes LIMIT :pageSize OFFSET (:page - 1) * :pageSize") - suspend fun queryAllEpisodesByPage(page: Int, pageSize: Int): List -} diff --git a/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/db/model/DbCharacter.kt b/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/db/model/DbCharacter.kt deleted file mode 100644 index bb2bb75..0000000 --- a/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/db/model/DbCharacter.kt +++ /dev/null @@ -1,54 +0,0 @@ -package com.mohsenoid.rickandmorty.data.db.model - -import androidx.room.ColumnInfo -import androidx.room.Entity -import androidx.room.PrimaryKey -import kotlinx.serialization.Serializable - -@Serializable -@Entity(tableName = "characters") -internal data class DbCharacter( - - @PrimaryKey - @ColumnInfo(name = "_id") - val id: Int, - - @ColumnInfo(name = "name") - val name: String, - - @ColumnInfo(name = "status") - val status: String, - - @ColumnInfo(name = "is_alive") - val isAlive: Boolean, - - @ColumnInfo(name = "species") - val species: String, - - @ColumnInfo(name = "type") - val type: String, - - @ColumnInfo(name = "gender") - val gender: String, - - @ColumnInfo(name = "origin") - val origin: DbOrigin, - - @ColumnInfo(name = "location") - val location: DbLocation, - - @ColumnInfo(name = "image") - val image: String, - - @ColumnInfo(name = "episode_ids") - val episodeIds: List, - - @ColumnInfo(name = "url") - val url: String, - - @ColumnInfo(name = "created") - val created: String, - - @ColumnInfo(name = "is_killed_by_user", defaultValue = "0") - val isKilledByUser: Boolean, -) diff --git a/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/db/model/DbEpisode.kt b/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/db/model/DbEpisode.kt deleted file mode 100644 index b0c18ab..0000000 --- a/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/db/model/DbEpisode.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.mohsenoid.rickandmorty.data.db.model - -import androidx.room.ColumnInfo -import androidx.room.Entity -import androidx.room.PrimaryKey -import kotlinx.serialization.Serializable - -@Serializable -@Entity(tableName = "episodes") -internal data class DbEpisode( - - @PrimaryKey - @ColumnInfo(name = "_id") - val id: Int, - - @ColumnInfo(name = "name") - val name: String, - - @ColumnInfo(name = "air_date") - val airDate: String, - - @ColumnInfo(name = "episode") - val episode: String, - - @ColumnInfo(name = "character_ids") - val characterIds: List, - - @ColumnInfo(name = "url") - val url: String, - - @ColumnInfo(name = "created") - val created: String, -) diff --git a/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/db/model/DbLocation.kt b/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/db/model/DbLocation.kt deleted file mode 100644 index b020611..0000000 --- a/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/db/model/DbLocation.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.mohsenoid.rickandmorty.data.db.model - -import androidx.room.ColumnInfo -import androidx.room.Entity -import androidx.room.PrimaryKey -import kotlinx.serialization.Serializable - -@Serializable -@Entity(tableName = "locations") -internal data class DbLocation( - - @PrimaryKey - @ColumnInfo(name = "name") - val name: String, - - @ColumnInfo(name = "url") - val url: String, -) diff --git a/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/db/model/DbOrigin.kt b/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/db/model/DbOrigin.kt deleted file mode 100644 index 7ea1d67..0000000 --- a/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/db/model/DbOrigin.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.mohsenoid.rickandmorty.data.db.model - -import androidx.room.ColumnInfo -import androidx.room.Entity -import androidx.room.PrimaryKey -import kotlinx.serialization.Serializable - -@Serializable -@Entity(tableName = "origins") -internal data class DbOrigin( - - @PrimaryKey - @ColumnInfo(name = "name") - val name: String, - - @ColumnInfo(name = "url") - val url: String, -) diff --git a/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/db/module.kt b/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/db/module.kt deleted file mode 100644 index 87e4c0a..0000000 --- a/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/db/module.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.mohsenoid.rickandmorty.data.db - -import org.koin.core.annotation.KoinReflectAPI -import org.koin.dsl.module -import org.koin.dsl.single - -@OptIn(KoinReflectAPI::class) -val dbModule = module { - single() -} diff --git a/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/mapper/CharacterMapper.kt b/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/mapper/CharacterMapper.kt deleted file mode 100644 index 52a5a43..0000000 --- a/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/mapper/CharacterMapper.kt +++ /dev/null @@ -1,79 +0,0 @@ -package com.mohsenoid.rickandmorty.data.mapper - -import com.mohsenoid.rickandmorty.data.api.model.ApiCharacter -import com.mohsenoid.rickandmorty.data.api.model.ApiLocation -import com.mohsenoid.rickandmorty.data.api.model.ApiOrigin -import com.mohsenoid.rickandmorty.data.db.model.DbCharacter -import com.mohsenoid.rickandmorty.data.db.model.DbLocation -import com.mohsenoid.rickandmorty.data.db.model.DbOrigin -import com.mohsenoid.rickandmorty.domain.model.ModelCharacter -import com.mohsenoid.rickandmorty.domain.model.ModelLocation -import com.mohsenoid.rickandmorty.domain.model.ModelOrigin - -private const val DEAD: String = "dead" - -private const val SEPARATOR: Char = '/' - -internal fun extractEpisodeIds(episodes: List): List { - return episodes.map { it.split(SEPARATOR).last().toInt() } -} - -internal fun ApiCharacter.toDbCharacter() = - DbCharacter( - id = id, - name = name, - status = status, - isAlive = !status.equals(DEAD, ignoreCase = true), - species = species, - type = type, - gender = gender, - origin = origin.toDbOrigin(), - location = location.toDbLocation(), - image = image, - episodeIds = extractEpisodeIds(episodes), - url = url, - created = created, - isKilledByUser = false, - ) - -private fun ApiOrigin.toDbOrigin() = - DbOrigin( - name = name, - url = url, - ) - -private fun ApiLocation.toDbLocation() = - DbLocation( - name = name, - url = url, - ) - -internal fun DbCharacter.toModelCharacter() = - ModelCharacter( - id = id, - name = name, - status = status, - isAlive = isAlive, - species = species, - type = type, - gender = gender, - origin = origin.toModelOrigin(), - location = location.toModelLocation(), - imageUrl = image, - episodeIds = episodeIds, - url = url, - created = created, - isKilledByUser = isKilledByUser, - ) - -private fun DbOrigin.toModelOrigin() = - ModelOrigin( - name = name, - url = url, - ) - -private fun DbLocation.toModelLocation() = - ModelLocation( - name = name, - url = url, - ) diff --git a/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/mapper/EpisodeMapper.kt b/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/mapper/EpisodeMapper.kt deleted file mode 100644 index 1c3fe3b..0000000 --- a/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/mapper/EpisodeMapper.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.mohsenoid.rickandmorty.data.mapper - -import com.mohsenoid.rickandmorty.data.api.model.ApiEpisode -import com.mohsenoid.rickandmorty.data.db.model.DbEpisode -import com.mohsenoid.rickandmorty.domain.model.ModelEpisode - -private const val SEPARATOR: Char = '/' - -internal fun extractCharacterIds(characters: List): List { - return characters.map { it.split(SEPARATOR).last().toInt() } -} - -internal fun ApiEpisode.toDbEpisode() = - DbEpisode( - id = id, - name = name, - airDate = airDate, - episode = episode, - characterIds = extractCharacterIds(characters), - url = url, - created = created, - ) - -internal fun DbEpisode.toModelEpisode() = - ModelEpisode( - id = id, - name = name, - airDate = airDate, - episode = episode, - characterIds = characterIds, - url = url, - created = created, - ) diff --git a/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/module.kt b/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/module.kt deleted file mode 100644 index a07587e..0000000 --- a/app/src/main/kotlin/com/mohsenoid/rickandmorty/data/module.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.mohsenoid.rickandmorty.data - -import com.mohsenoid.rickandmorty.data.api.apiModule -import com.mohsenoid.rickandmorty.data.db.dbModule -import com.mohsenoid.rickandmorty.domain.Repository -import org.koin.dsl.module - -val dataModule = dbModule + apiModule + module { - - single { - RepositoryImpl( - db = get(), - api = get(), - statusProvider = get(), - ) - } -} diff --git a/app/src/main/kotlin/com/mohsenoid/rickandmorty/domain/Repository.kt b/app/src/main/kotlin/com/mohsenoid/rickandmorty/domain/Repository.kt deleted file mode 100644 index e92aaaa..0000000 --- a/app/src/main/kotlin/com/mohsenoid/rickandmorty/domain/Repository.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.mohsenoid.rickandmorty.domain - -import com.mohsenoid.rickandmorty.domain.model.ModelCharacter -import com.mohsenoid.rickandmorty.domain.model.ModelEpisode -import com.mohsenoid.rickandmorty.domain.model.PageQueryResult -import com.mohsenoid.rickandmorty.domain.model.QueryResult - -interface Repository { - - suspend fun getEpisodes(page: Int): PageQueryResult> - - suspend fun getCharactersByIds(characterIds: List): QueryResult> - - suspend fun getCharacterDetails(characterId: Int): QueryResult - - suspend fun killCharacter(characterId: Int): QueryResult -} diff --git a/app/src/main/kotlin/com/mohsenoid/rickandmorty/domain/model/ModelCharacter.kt b/app/src/main/kotlin/com/mohsenoid/rickandmorty/domain/model/ModelCharacter.kt deleted file mode 100644 index 06d7ed2..0000000 --- a/app/src/main/kotlin/com/mohsenoid/rickandmorty/domain/model/ModelCharacter.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.mohsenoid.rickandmorty.domain.model - -data class ModelCharacter( - val id: Int, - val name: String, - val status: String, - val isAlive: Boolean, - val species: String, - val type: String, - val gender: String, - val origin: ModelOrigin, - val location: ModelLocation, - val imageUrl: String, - val episodeIds: List, - val url: String, - val created: String, - val isKilledByUser: Boolean, -) { - - val isAliveAndNotKilledByUser: Boolean - get() = isAlive && !isKilledByUser -} diff --git a/app/src/main/kotlin/com/mohsenoid/rickandmorty/domain/model/ModelEpisode.kt b/app/src/main/kotlin/com/mohsenoid/rickandmorty/domain/model/ModelEpisode.kt deleted file mode 100644 index 70a3140..0000000 --- a/app/src/main/kotlin/com/mohsenoid/rickandmorty/domain/model/ModelEpisode.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.mohsenoid.rickandmorty.domain.model - -data class ModelEpisode( - val id: Int, - val name: String, - val airDate: String, - val episode: String, - val characterIds: List, - val url: String, - val created: String, -) diff --git a/app/src/main/kotlin/com/mohsenoid/rickandmorty/domain/model/ModelLocation.kt b/app/src/main/kotlin/com/mohsenoid/rickandmorty/domain/model/ModelLocation.kt deleted file mode 100644 index f01deab..0000000 --- a/app/src/main/kotlin/com/mohsenoid/rickandmorty/domain/model/ModelLocation.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.mohsenoid.rickandmorty.domain.model - -data class ModelLocation( - val name: String, - val url: String, -) diff --git a/app/src/main/kotlin/com/mohsenoid/rickandmorty/domain/model/ModelOrigin.kt b/app/src/main/kotlin/com/mohsenoid/rickandmorty/domain/model/ModelOrigin.kt deleted file mode 100644 index ed5fb7d..0000000 --- a/app/src/main/kotlin/com/mohsenoid/rickandmorty/domain/model/ModelOrigin.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.mohsenoid.rickandmorty.domain.model - -data class ModelOrigin( - val name: String, - val url: String, -) diff --git a/app/src/main/kotlin/com/mohsenoid/rickandmorty/domain/model/PageQueryResult.kt b/app/src/main/kotlin/com/mohsenoid/rickandmorty/domain/model/PageQueryResult.kt deleted file mode 100644 index 315d28a..0000000 --- a/app/src/main/kotlin/com/mohsenoid/rickandmorty/domain/model/PageQueryResult.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.mohsenoid.rickandmorty.domain.model - -sealed interface PageQueryResult { - data class Successful(val data: T) : PageQueryResult - object EndOfList : PageQueryResult - object Error : PageQueryResult -} diff --git a/app/src/main/kotlin/com/mohsenoid/rickandmorty/domain/model/QueryResult.kt b/app/src/main/kotlin/com/mohsenoid/rickandmorty/domain/model/QueryResult.kt deleted file mode 100644 index 3afbc60..0000000 --- a/app/src/main/kotlin/com/mohsenoid/rickandmorty/domain/model/QueryResult.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.mohsenoid.rickandmorty.domain.model - -sealed interface QueryResult { - data class Successful(val data: T) : QueryResult - object NoCache : QueryResult - object Error : QueryResult -} diff --git a/app/src/main/kotlin/com/mohsenoid/rickandmorty/module.kt b/app/src/main/kotlin/com/mohsenoid/rickandmorty/module.kt deleted file mode 100644 index 94d307f..0000000 --- a/app/src/main/kotlin/com/mohsenoid/rickandmorty/module.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.mohsenoid.rickandmorty - -import com.mohsenoid.rickandmorty.util.StatusProvider -import com.mohsenoid.rickandmorty.util.StatusProviderImpl -import org.koin.android.ext.koin.androidContext -import org.koin.dsl.module - -val appModule = module { - single { StatusProviderImpl(context = androidContext()) } -} diff --git a/app/src/main/kotlin/com/mohsenoid/rickandmorty/startup/TimberInitializer.kt b/app/src/main/kotlin/com/mohsenoid/rickandmorty/startup/TimberInitializer.kt deleted file mode 100644 index 1927ffe..0000000 --- a/app/src/main/kotlin/com/mohsenoid/rickandmorty/startup/TimberInitializer.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.mohsenoid.rickandmorty.startup - -import android.content.Context -import androidx.startup.Initializer -import com.mohsenoid.rickandmorty.BuildConfig -import timber.log.Timber - -class TimberInitializer : Initializer { - - private val isDebug: Boolean = BuildConfig.DEBUG - - override fun create(context: Context) { - if (isDebug) setupTimber() - } - - private fun setupTimber() { - Timber.plant( - object : Timber.DebugTree() { - override fun createStackElementTag(element: StackTraceElement): String { - // adding file name and line number link to logs - return "${super.createStackElementTag(element)}(${element.fileName}:${element.lineNumber})" - } - }, - ) - } - - override fun dependencies(): MutableList>> = mutableListOf() -} diff --git a/app/src/main/kotlin/com/mohsenoid/rickandmorty/util/KoinQualifiersNames.kt b/app/src/main/kotlin/com/mohsenoid/rickandmorty/util/KoinQualifiersNames.kt deleted file mode 100644 index 4c447e2..0000000 --- a/app/src/main/kotlin/com/mohsenoid/rickandmorty/util/KoinQualifiersNames.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.mohsenoid.rickandmorty.util - -object KoinQualifiersNames { - const val BASE_URL = "baseUrl" -} diff --git a/app/src/main/kotlin/com/mohsenoid/rickandmorty/util/StatusProvider.kt b/app/src/main/kotlin/com/mohsenoid/rickandmorty/util/StatusProvider.kt deleted file mode 100644 index 4ed9e8f..0000000 --- a/app/src/main/kotlin/com/mohsenoid/rickandmorty/util/StatusProvider.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.mohsenoid.rickandmorty.util - -/** - * Application Status Provider. - */ -interface StatusProvider { - - /** - * Checks network connectivity status. - */ - fun isOnline(): Boolean -} diff --git a/app/src/main/kotlin/com/mohsenoid/rickandmorty/util/StatusProviderImpl.kt b/app/src/main/kotlin/com/mohsenoid/rickandmorty/util/StatusProviderImpl.kt deleted file mode 100644 index 13d33cb..0000000 --- a/app/src/main/kotlin/com/mohsenoid/rickandmorty/util/StatusProviderImpl.kt +++ /dev/null @@ -1,38 +0,0 @@ -package com.mohsenoid.rickandmorty.util - -import android.content.Context -import android.net.ConnectivityManager -import android.net.Network -import android.net.NetworkCapabilities - -/** - * - * This class implements [StatusProvider] and provides configurations required on runtime. - * - * @constructor Creates a ConfigProvider using the Android Application context. - * @param context the Android Application context. - */ -class StatusProviderImpl(private val context: Context) : StatusProvider { - - /** - * This function uses [ConnectivityManager] to check active Network Capabilities to know - * the Android phone network connectivity status. - * - * @return is the phone connected to a network through cellular or WiFi. - */ - @Suppress("ReturnCount") - override fun isOnline(): Boolean { - val connectivityManager: ConnectivityManager = - context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager - ?: return false - - val activeNetwork: Network = connectivityManager.activeNetwork ?: return false - - val networkCapabilities: NetworkCapabilities = - connectivityManager.getNetworkCapabilities(activeNetwork) ?: return false - - return networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) || - networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) || - networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) - } -} diff --git a/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/MainActivity.kt b/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/MainActivity.kt deleted file mode 100644 index fa454c7..0000000 --- a/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/MainActivity.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.mohsenoid.rickandmorty.view - -import android.os.Bundle -import androidx.appcompat.app.AppCompatActivity -import com.mohsenoid.rickandmorty.R - -class MainActivity : AppCompatActivity() { - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(R.layout.activity_main) - } -} diff --git a/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/character/details/CharacterDetailsFragment.kt b/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/character/details/CharacterDetailsFragment.kt deleted file mode 100644 index 66b474c..0000000 --- a/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/character/details/CharacterDetailsFragment.kt +++ /dev/null @@ -1,97 +0,0 @@ -package com.mohsenoid.rickandmorty.view.character.details - -import android.app.Activity -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.Toast -import androidx.fragment.app.Fragment -import androidx.navigation.fragment.navArgs -import com.mohsenoid.rickandmorty.R -import com.mohsenoid.rickandmorty.databinding.FragmentCharacterDetailsBinding -import com.mohsenoid.rickandmorty.view.util.launchWhileResumed -import kotlinx.coroutines.flow.collectLatest -import org.koin.androidx.viewmodel.ext.android.viewModel -import org.koin.core.context.loadKoinModules -import org.koin.core.context.unloadKoinModules -import org.koin.core.parameter.parametersOf - -@Suppress("TooManyFunctions") -class CharacterDetailsFragment : Fragment() { - - private var _binding: FragmentCharacterDetailsBinding? = null - private val binding get() = _binding!! - - private val args: CharacterDetailsFragmentArgs by navArgs() - - private val viewModel: CharacterDetailsViewModel by viewModel { - parametersOf(args.characterId) - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - loadKoinModules(characterDetailsFragmentModule) - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View { - _binding = FragmentCharacterDetailsBinding.inflate(inflater, container, false) - binding.lifecycleOwner = viewLifecycleOwner - binding.viewModel = viewModel - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - lifecycle.launchWhileResumed { - viewModel.onError.collectLatest { - if (it) showErrorMessage() - } - } - - lifecycle.launchWhileResumed { - viewModel.isOffline.collectLatest { - if (it) showOfflineMessage() - } - } - - lifecycle.launchWhileResumed { - viewModel.isNoCache.collectLatest { - if (it) onNoOfflineData() - } - } - } - - private fun showErrorMessage() { - Toast.makeText(context, R.string.error_message, Toast.LENGTH_LONG).show() - } - - private fun showOfflineMessage() { - Toast.makeText(context, R.string.offline_app, Toast.LENGTH_LONG).show() - } - - private fun onNoOfflineData() { - Toast.makeText(context, R.string.no_offline_data, Toast.LENGTH_LONG).show() - parentActivityOnBackPressed() - } - - private fun parentActivityOnBackPressed() { - val parentActivity: Activity? = activity - parentActivity?.onBackPressed() - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } - - override fun onDestroy() { - unloadKoinModules(characterDetailsFragmentModule) - super.onDestroy() - } -} diff --git a/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/character/details/CharacterDetailsViewModel.kt b/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/character/details/CharacterDetailsViewModel.kt deleted file mode 100644 index b748581..0000000 --- a/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/character/details/CharacterDetailsViewModel.kt +++ /dev/null @@ -1,63 +0,0 @@ -package com.mohsenoid.rickandmorty.view.character.details - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.mohsenoid.rickandmorty.domain.Repository -import com.mohsenoid.rickandmorty.domain.model.QueryResult -import com.mohsenoid.rickandmorty.util.StatusProvider -import com.mohsenoid.rickandmorty.view.mapper.toViewCharacterDetails -import com.mohsenoid.rickandmorty.view.model.LoadingState -import com.mohsenoid.rickandmorty.view.model.ViewCharacterDetails -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.launch - -class CharacterDetailsViewModel( - private val characterId: Int, - private val repository: Repository, - private val statusProvider: StatusProvider, -) : ViewModel() { - - private val _loadingState: MutableStateFlow = MutableStateFlow(LoadingState.None) - val loadingState: StateFlow = _loadingState - - private val _isOffline: MutableStateFlow = MutableStateFlow(false) - val isOffline: StateFlow = _isOffline - - private val _isNoCache: MutableStateFlow = MutableStateFlow(false) - val isNoCache: StateFlow = _isNoCache - - private val _onError: MutableStateFlow = MutableStateFlow(false) - val onError: StateFlow = _onError - - private val _character: MutableStateFlow = MutableStateFlow(null) - val character: StateFlow = _character - - init { - loadCharacter() - } - - private fun loadCharacter() { - _loadingState.value = LoadingState.Loading - _onError.value = false - _isNoCache.value = false - _isOffline.value = false - queryCharacter() - } - - private fun queryCharacter() { - if (!statusProvider.isOnline()) { - _isOffline.value = true - } - - viewModelScope.launch { - when (val result = repository.getCharacterDetails(characterId)) { - is QueryResult.Successful -> _character.value = result.data.toViewCharacterDetails() - QueryResult.NoCache -> _isNoCache.value = true - QueryResult.Error -> _onError.value = true - } - - _loadingState.value = LoadingState.None - } - } -} diff --git a/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/character/details/module.kt b/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/character/details/module.kt deleted file mode 100644 index fb983ec..0000000 --- a/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/character/details/module.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.mohsenoid.rickandmorty.view.character.details - -import org.koin.androidx.viewmodel.dsl.viewModel -import org.koin.dsl.module - -val characterDetailsFragmentModule = module { - - viewModel { (characterId: Int) -> - CharacterDetailsViewModel( - characterId = characterId, - repository = get(), - statusProvider = get(), - ) - } -} diff --git a/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/character/list/CharacterListFragment.kt b/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/character/list/CharacterListFragment.kt deleted file mode 100644 index 83a8c61..0000000 --- a/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/character/list/CharacterListFragment.kt +++ /dev/null @@ -1,104 +0,0 @@ -package com.mohsenoid.rickandmorty.view.character.list - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.Toast -import androidx.fragment.app.Fragment -import androidx.navigation.findNavController -import androidx.navigation.fragment.navArgs -import com.mohsenoid.rickandmorty.R -import com.mohsenoid.rickandmorty.databinding.FragmentCharacterListBinding -import com.mohsenoid.rickandmorty.view.util.launchWhileResumed -import kotlinx.coroutines.flow.collectLatest -import org.koin.androidx.viewmodel.ext.android.viewModel -import org.koin.core.context.loadKoinModules -import org.koin.core.context.unloadKoinModules -import org.koin.core.parameter.parametersOf - -@Suppress("TooManyFunctions") -class CharacterListFragment : Fragment() { - - private var _binding: FragmentCharacterListBinding? = null - private val binding get() = _binding!! - - private val args: CharacterListFragmentArgs by navArgs() - - private val viewModel: CharacterListViewModel by viewModel { - parametersOf(args.characterIds.toList()) - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - loadKoinModules(characterListFragmentModule) - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View { - _binding = FragmentCharacterListBinding.inflate(inflater, container, false) - binding.lifecycleOwner = viewLifecycleOwner - binding.viewModel = viewModel - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - lifecycle.launchWhileResumed { - viewModel.onError.collectLatest { - if (it) showErrorMessage() - } - } - - lifecycle.launchWhileResumed { - viewModel.isOffline.collectLatest { - if (it) showOfflineMessage() - } - } - - lifecycle.launchWhileResumed { - viewModel.isNoCache.collectLatest { - if (it) onNoOfflineData() - } - } - - lifecycle.launchWhileResumed { - viewModel.selectedCharacterId.collectLatest { characterId -> - onCharacterRowClick(characterId) - } - } - } - - private fun showErrorMessage() { - Toast.makeText(context, R.string.error_message, Toast.LENGTH_LONG).show() - } - - private fun showOfflineMessage() { - Toast.makeText(context, R.string.offline_app, Toast.LENGTH_LONG).show() - } - - private fun onNoOfflineData() { - Toast.makeText(context, R.string.no_offline_data, Toast.LENGTH_LONG).show() - activity?.onBackPressed() - } - - fun onCharacterRowClick(characterId: Int) { - val action = CharacterListFragmentDirections - .actionCharacterListFragmentToCharacterDetailsFragment(characterId) - view?.findNavController()?.navigate(action) - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } - - override fun onDestroy() { - unloadKoinModules(characterListFragmentModule) - super.onDestroy() - } -} diff --git a/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/character/list/CharacterListViewModel.kt b/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/character/list/CharacterListViewModel.kt deleted file mode 100644 index 72ec3b8..0000000 --- a/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/character/list/CharacterListViewModel.kt +++ /dev/null @@ -1,101 +0,0 @@ -package com.mohsenoid.rickandmorty.view.character.list - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.mohsenoid.rickandmorty.domain.Repository -import com.mohsenoid.rickandmorty.domain.model.ModelCharacter -import com.mohsenoid.rickandmorty.domain.model.QueryResult -import com.mohsenoid.rickandmorty.util.StatusProvider -import com.mohsenoid.rickandmorty.view.mapper.toViewCharacterItem -import com.mohsenoid.rickandmorty.view.model.LoadingState -import com.mohsenoid.rickandmorty.view.model.ViewCharacterItem -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.launch - -class CharacterListViewModel( - private val characterIds: List, - private val repository: Repository, - private val statusProvider: StatusProvider, -) : ViewModel() { - - private val _loadingState: MutableStateFlow = MutableStateFlow(LoadingState.None) - val loadingState: StateFlow = _loadingState - - private val _isOffline: MutableStateFlow = MutableStateFlow(false) - val isOffline: StateFlow = _isOffline - - private val _isNoCache: MutableStateFlow = MutableStateFlow(false) - val isNoCache: StateFlow = _isNoCache - - private val _onError: MutableStateFlow = MutableStateFlow(false) - val onError: StateFlow = _onError - - private val _characters: MutableStateFlow> = - MutableStateFlow(mutableListOf()) - val characters: StateFlow> = _characters - - private val _selectedCharacterId: Channel = Channel() - val selectedCharacterId: Flow = _selectedCharacterId.receiveAsFlow() - - init { - loadCharacters() - } - - private fun loadCharacters() { - _loadingState.value = LoadingState.Loading - _onError.value = false - _isNoCache.value = false - _isOffline.value = false - queryCharacters() - } - - private fun queryCharacters() { - if (!statusProvider.isOnline()) { - _isOffline.value = true - } - - viewModelScope.launch { - when (val result = repository.getCharactersByIds(characterIds)) { - is QueryResult.Successful -> _characters.value = result.data.toViewCharacterItems() - QueryResult.NoCache -> _isNoCache.value = true - QueryResult.Error -> _onError.value = true - } - - _loadingState.value = LoadingState.None - } - } - - private fun killCharacter(character: ModelCharacter) { - if (!character.isAliveAndNotKilledByUser) return - - if (!statusProvider.isOnline()) { - _isOffline.value = true - } - - viewModelScope.launch { - when (repository.killCharacter(character.id)) { - is QueryResult.Successful -> { - queryCharacters() - } - QueryResult.NoCache -> _isNoCache.value = true - QueryResult.Error -> _onError.value = true - } - } - } - - private fun List.toViewCharacterItems(): MutableList = - map { character -> - character.toViewCharacterItem( - onKill = { - killCharacter(character) - }, - onClick = { - _selectedCharacterId.trySend(character.id) - }, - ) - }.toMutableList() -} diff --git a/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/character/list/adapter/CharacterListAdapter.kt b/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/character/list/adapter/CharacterListAdapter.kt deleted file mode 100644 index 4dc014d..0000000 --- a/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/character/list/adapter/CharacterListAdapter.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.mohsenoid.rickandmorty.view.character.list.adapter - -import android.annotation.SuppressLint -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.RecyclerView -import com.mohsenoid.rickandmorty.databinding.ItemCharacterBinding -import com.mohsenoid.rickandmorty.view.model.ViewCharacterItem -import java.util.ArrayList - -class CharacterListAdapter : RecyclerView.Adapter() { - - private var characters: MutableList = ArrayList() - - @SuppressLint("NotifyDataSetChanged") - fun setCharacters(characters: List) { - this.characters = characters.toMutableList() - notifyDataSetChanged() - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CharacterViewHolder { - val binding = - ItemCharacterBinding.inflate(LayoutInflater.from(parent.context), parent, false) - return CharacterViewHolder(binding) - } - - override fun onBindViewHolder(holder: CharacterViewHolder, position: Int) { - val character: ViewCharacterItem = characters[position] - holder.setCharacter(character) - } - - override fun getItemCount(): Int { - return characters.size - } -} diff --git a/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/character/list/adapter/CharacterViewHolder.kt b/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/character/list/adapter/CharacterViewHolder.kt deleted file mode 100644 index 33556c1..0000000 --- a/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/character/list/adapter/CharacterViewHolder.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.mohsenoid.rickandmorty.view.character.list.adapter - -import androidx.recyclerview.widget.RecyclerView.ViewHolder -import com.mohsenoid.rickandmorty.databinding.ItemCharacterBinding -import com.mohsenoid.rickandmorty.view.model.ViewCharacterItem - -class CharacterViewHolder internal constructor( - internal val binding: ItemCharacterBinding, -) : ViewHolder(binding.root) { - - fun setCharacter(character: ViewCharacterItem) { - binding.character = character - } -} diff --git a/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/character/list/module.kt b/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/character/list/module.kt deleted file mode 100644 index e6a621c..0000000 --- a/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/character/list/module.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.mohsenoid.rickandmorty.view.character.list - -import org.koin.androidx.viewmodel.dsl.viewModel -import org.koin.dsl.module - -val characterListFragmentModule = module { - - viewModel { (characterIds: List) -> - CharacterListViewModel( - characterIds = characterIds, - repository = get(), - statusProvider = get(), - ) - } -} diff --git a/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/episode/list/EpisodeListFragment.kt b/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/episode/list/EpisodeListFragment.kt deleted file mode 100644 index 474a1e7..0000000 --- a/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/episode/list/EpisodeListFragment.kt +++ /dev/null @@ -1,113 +0,0 @@ -package com.mohsenoid.rickandmorty.view.episode.list - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.Toast -import androidx.fragment.app.Fragment -import androidx.navigation.findNavController -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import com.mohsenoid.rickandmorty.R -import com.mohsenoid.rickandmorty.databinding.FragmentEpisodeListBinding -import com.mohsenoid.rickandmorty.view.util.EndlessRecyclerViewScrollListener -import com.mohsenoid.rickandmorty.view.util.launchWhileResumed -import kotlinx.coroutines.flow.collectLatest -import org.koin.androidx.viewmodel.ext.android.viewModel -import org.koin.core.context.loadKoinModules -import org.koin.core.context.unloadKoinModules - -@Suppress("TooManyFunctions") -class EpisodeListFragment : Fragment() { - - private var _binding: FragmentEpisodeListBinding? = null - private val binding get() = _binding!! - - private val viewModel: EpisodeListViewModel by viewModel() - - private var endlessScrollListener: EndlessRecyclerViewScrollListener? = null - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - loadKoinModules(episodeListFragmentModule) - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View { - _binding = FragmentEpisodeListBinding.inflate(inflater, container, false) - binding.lifecycleOwner = viewLifecycleOwner - binding.viewModel = viewModel - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - val linearLayoutManager = LinearLayoutManager(context) - binding.episodeList.layoutManager = linearLayoutManager - endlessScrollListener = object : EndlessRecyclerViewScrollListener(linearLayoutManager) { - override fun onLoadMore(page: Int, totalItemsCount: Int, view: RecyclerView) { - viewModel.loadMoreEpisodes(page = page + 1) - } - }.also { - binding.episodeList.addOnScrollListener(it) - } - - lifecycle.launchWhileResumed { - viewModel.onError.collectLatest { - if (it) showErrorMessage() - } - } - - lifecycle.launchWhileResumed { - viewModel.isOffline.collectLatest { - if (it) showOfflineMessage() - } - } - - lifecycle.launchWhileResumed { - viewModel.isEndOfList.collectLatest { - if (it) reachedEndOfList() - } - } - - lifecycle.launchWhileResumed { - viewModel.selectedEpisodeCharacterIds.collectLatest { characterIds -> - onEpisodeRowClick(characterIds) - } - } - } - - private fun showErrorMessage() { - Toast.makeText(context, R.string.error_message, Toast.LENGTH_LONG).show() - } - - private fun showOfflineMessage() { - Toast.makeText(context, R.string.offline_app, Toast.LENGTH_SHORT).show() - } - - private fun reachedEndOfList() { - binding.episodeListProgress.visibility = View.GONE - } - - private fun onEpisodeRowClick(characterIds: IntArray) { - val action = EpisodeListFragmentDirections - .actionEpisodeListFragmentToCharacterListFragment(characterIds) - view?.findNavController()?.navigate(action) - } - - override fun onDestroyView() { - super.onDestroyView() - endlessScrollListener = null - _binding = null - } - - override fun onDestroy() { - unloadKoinModules(episodeListFragmentModule) - super.onDestroy() - } -} diff --git a/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/episode/list/EpisodeListViewModel.kt b/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/episode/list/EpisodeListViewModel.kt deleted file mode 100644 index f343f46..0000000 --- a/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/episode/list/EpisodeListViewModel.kt +++ /dev/null @@ -1,92 +0,0 @@ -package com.mohsenoid.rickandmorty.view.episode.list - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.mohsenoid.rickandmorty.domain.Repository -import com.mohsenoid.rickandmorty.domain.model.ModelEpisode -import com.mohsenoid.rickandmorty.domain.model.PageQueryResult -import com.mohsenoid.rickandmorty.util.StatusProvider -import com.mohsenoid.rickandmorty.view.mapper.toViewEpisodeItem -import com.mohsenoid.rickandmorty.view.model.LoadingState -import com.mohsenoid.rickandmorty.view.model.ViewEpisodeItem -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.launch - -class EpisodeListViewModel( - private val repository: Repository, - private val statusProvider: StatusProvider, -) : ViewModel() { - - private val _loadingState: MutableStateFlow = MutableStateFlow(LoadingState.None) - val loadingState: StateFlow = _loadingState - - private val _isOffline: MutableStateFlow = MutableStateFlow(false) - val isOffline: StateFlow = _isOffline - - private val _isEndOfList: MutableStateFlow = MutableStateFlow(false) - val isEndOfList: StateFlow = _isEndOfList - - private val _onError: MutableStateFlow = MutableStateFlow(false) - val onError: StateFlow = _onError - - private val _episodes: MutableStateFlow> = MutableStateFlow(emptyList()) - val episodes: StateFlow> = _episodes - - private val _selectedEpisodeCharacterIds: Channel = Channel() - val selectedEpisodeCharacterIds: Flow = _selectedEpisodeCharacterIds.receiveAsFlow() - - private var page = 1 - - init { - loadEpisodes() - } - - fun loadEpisodes() { - _loadingState.value = LoadingState.Loading - _isEndOfList.value = false - _onError.value = false - _isOffline.value = false - page = 1 - getEpisodes() - } - - fun loadMoreEpisodes(page: Int) { - _loadingState.value = LoadingState.LoadingMore - this.page = page - getEpisodes() - } - - private fun getEpisodes() { - if (!statusProvider.isOnline()) { - _isOffline.value = true - } - - viewModelScope.launch { - when (val result = repository.getEpisodes(page)) { - is PageQueryResult.Successful -> { - if (page == 1) { - _episodes.value = - result.data.toViewEpisodeItems() - } else { - _episodes.value = _episodes.value + result.data.toViewEpisodeItems() - } - } - PageQueryResult.EndOfList -> _isEndOfList.value = true - PageQueryResult.Error -> _onError.value = true - } - - _loadingState.value = LoadingState.None - } - } - - private fun List.toViewEpisodeItems(): List = - map { episode -> - episode.toViewEpisodeItem { - _selectedEpisodeCharacterIds.trySend(episode.characterIds.toIntArray()) - } - } -} diff --git a/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/episode/list/adapter/EpisodeListAdapter.kt b/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/episode/list/adapter/EpisodeListAdapter.kt deleted file mode 100644 index 94b7859..0000000 --- a/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/episode/list/adapter/EpisodeListAdapter.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.mohsenoid.rickandmorty.view.episode.list.adapter - -import android.annotation.SuppressLint -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.RecyclerView -import com.mohsenoid.rickandmorty.databinding.ItemEpisodeBinding -import com.mohsenoid.rickandmorty.view.model.ViewEpisodeItem -import java.util.ArrayList - -class EpisodeListAdapter : - RecyclerView.Adapter() { - - private var episodes: MutableList = ArrayList() - - @SuppressLint("NotifyDataSetChanged") - fun setEpisodes(episodes: List) { - this.episodes = episodes.toMutableList() - notifyDataSetChanged() - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): EpisodeViewHolder { - val binding = ItemEpisodeBinding.inflate(LayoutInflater.from(parent.context), parent, false) - return EpisodeViewHolder(binding) - } - - override fun onBindViewHolder(holder: EpisodeViewHolder, position: Int) { - val episode: ViewEpisodeItem = episodes[position] - holder.setEpisode(episode) - } - - override fun getItemCount(): Int { - return episodes.size - } -} diff --git a/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/episode/list/adapter/EpisodeViewHolder.kt b/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/episode/list/adapter/EpisodeViewHolder.kt deleted file mode 100644 index d4e7981..0000000 --- a/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/episode/list/adapter/EpisodeViewHolder.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.mohsenoid.rickandmorty.view.episode.list.adapter - -import androidx.recyclerview.widget.RecyclerView.ViewHolder -import com.mohsenoid.rickandmorty.databinding.ItemEpisodeBinding -import com.mohsenoid.rickandmorty.view.model.ViewEpisodeItem - -class EpisodeViewHolder internal constructor(internal val binding: ItemEpisodeBinding) : - ViewHolder(binding.root) { - - fun setEpisode(episode: ViewEpisodeItem) { - binding.episode = episode - } -} diff --git a/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/episode/list/module.kt b/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/episode/list/module.kt deleted file mode 100644 index 415d7d4..0000000 --- a/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/episode/list/module.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.mohsenoid.rickandmorty.view.episode.list - -import org.koin.androidx.viewmodel.dsl.viewModel -import org.koin.core.annotation.KoinReflectAPI -import org.koin.dsl.module - -@OptIn(KoinReflectAPI::class) -val episodeListFragmentModule = module { - - viewModel() -} diff --git a/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/mapper/CharacterMapper.kt b/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/mapper/CharacterMapper.kt deleted file mode 100644 index 6d52806..0000000 --- a/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/mapper/CharacterMapper.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.mohsenoid.rickandmorty.view.mapper - -import com.mohsenoid.rickandmorty.domain.model.ModelCharacter -import com.mohsenoid.rickandmorty.view.model.ViewCharacterDetails -import com.mohsenoid.rickandmorty.view.model.ViewCharacterItem - -fun ModelCharacter.toViewCharacterItem(onKill: () -> Unit, onClick: () -> Unit) = ViewCharacterItem( - name = name, - imageUrl = imageUrl, - isAliveAndNotKilledByUser = isAliveAndNotKilledByUser, - onKill = onKill, - onClick = onClick, -) - -fun ModelCharacter.toViewCharacterDetails() = ViewCharacterDetails( - id = id, - name = name, - status = status, - species = species, - gender = gender, - origin = origin.name, - location = location.name, - imageUrl = imageUrl, - created = created, - isKilledByUser = isKilledByUser, -) diff --git a/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/mapper/EpisodeMapper.kt b/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/mapper/EpisodeMapper.kt deleted file mode 100644 index f199d72..0000000 --- a/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/mapper/EpisodeMapper.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.mohsenoid.rickandmorty.view.mapper - -import com.mohsenoid.rickandmorty.domain.model.ModelEpisode -import com.mohsenoid.rickandmorty.view.model.ViewEpisodeItem - -fun ModelEpisode.toViewEpisodeItem(onClick: () -> Unit) = ViewEpisodeItem( - name = name, - airDate = airDate, - episode = episode, - onClick = onClick, -) diff --git a/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/model/LoadingState.kt b/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/model/LoadingState.kt deleted file mode 100644 index d96f823..0000000 --- a/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/model/LoadingState.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.mohsenoid.rickandmorty.view.model - -sealed interface LoadingState { - object None : LoadingState - object Loading : LoadingState - object LoadingMore : LoadingState -} diff --git a/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/model/ViewCharacterDetails.kt b/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/model/ViewCharacterDetails.kt deleted file mode 100644 index 6698cea..0000000 --- a/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/model/ViewCharacterDetails.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.mohsenoid.rickandmorty.view.model - -data class ViewCharacterDetails( - val id: Int, - val name: String, - val status: String, - val species: String, - val gender: String, - val origin: String, - val location: String, - val imageUrl: String, - val created: String, - val isKilledByUser: Boolean, -) diff --git a/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/model/ViewCharacterItem.kt b/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/model/ViewCharacterItem.kt deleted file mode 100644 index eb3e00a..0000000 --- a/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/model/ViewCharacterItem.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.mohsenoid.rickandmorty.view.model - -data class ViewCharacterItem( - val name: String, - val imageUrl: String, - val isAliveAndNotKilledByUser: Boolean, - val onKill: () -> Unit, - val onClick: () -> Unit, -) { - fun onKill() = onKill.invoke() - fun onClick() = onClick.invoke() -} diff --git a/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/model/ViewEpisodeItem.kt b/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/model/ViewEpisodeItem.kt deleted file mode 100644 index 41b39ab..0000000 --- a/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/model/ViewEpisodeItem.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.mohsenoid.rickandmorty.view.model - -data class ViewEpisodeItem( - val name: String, - val airDate: String, - val episode: String, - val onClick: () -> Unit, -) { - fun onClick() = onClick.invoke() -} diff --git a/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/util/BindingAdapters.kt b/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/util/BindingAdapters.kt deleted file mode 100644 index 6effd20..0000000 --- a/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/util/BindingAdapters.kt +++ /dev/null @@ -1,80 +0,0 @@ -package com.mohsenoid.rickandmorty.view.util - -import android.view.View -import android.widget.ImageView -import android.widget.ProgressBar -import androidx.databinding.BindingAdapter -import androidx.recyclerview.widget.RecyclerView -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout -import com.mohsenoid.rickandmorty.R -import com.mohsenoid.rickandmorty.view.character.list.adapter.CharacterListAdapter -import com.mohsenoid.rickandmorty.view.episode.list.adapter.EpisodeListAdapter -import com.mohsenoid.rickandmorty.view.model.LoadingState -import com.mohsenoid.rickandmorty.view.model.ViewCharacterItem -import com.mohsenoid.rickandmorty.view.model.ViewEpisodeItem -import com.squareup.picasso.Picasso - -@BindingAdapter("visibility") -fun View.setVisibility(isVisible: Boolean) { - visibility = if (isVisible) View.VISIBLE else View.GONE -} - -@BindingAdapter("onRefreshListener") -fun SwipeRefreshLayout.onRefreshListener(listener: SwipeRefreshLayout.OnRefreshListener?) { - setOnRefreshListener(listener) -} - -@BindingAdapter("isRefreshing") -fun SwipeRefreshLayout.setIsRefreshing(loadingState: LoadingState?) { - isRefreshing = loadingState == LoadingState.Loading -} - -@BindingAdapter("isLoading") -fun ProgressBar.setIsLoading(loadingState: LoadingState?) { - visibility = if (loadingState == LoadingState.Loading) View.VISIBLE else View.GONE -} - -@BindingAdapter("isLoadingMore") -fun ProgressBar.setIsLoadingMore(loadingState: LoadingState?) { - visibility = if (loadingState == LoadingState.LoadingMore) View.VISIBLE else View.GONE -} - -@BindingAdapter("episodes") -fun RecyclerView.setEpisodes(itemViewModels: List?) { - val adapter = getOrCreateEpisodeListAdapter() - adapter.setEpisodes(itemViewModels ?: emptyList()) -} - -private fun RecyclerView.getOrCreateEpisodeListAdapter(): EpisodeListAdapter { - return if (adapter != null && adapter is EpisodeListAdapter) { - adapter as EpisodeListAdapter - } else { - val newAdapter = EpisodeListAdapter() - adapter = newAdapter - newAdapter - } -} - -@BindingAdapter("characters") -fun RecyclerView.setCharacters(itemViewModels: List?) { - val adapter = getOrCreateCharacterListAdapter() - adapter.setCharacters(itemViewModels ?: emptyList()) -} - -private fun RecyclerView.getOrCreateCharacterListAdapter(): CharacterListAdapter { - return if (adapter != null && adapter is CharacterListAdapter) { - adapter as CharacterListAdapter - } else { - val newAdapter = CharacterListAdapter() - adapter = newAdapter - newAdapter - } -} - -@BindingAdapter("imageUrl") -fun ImageView.setImageUrl(imageUrl: String?) { - Picasso.get() - .load(imageUrl) - .placeholder(R.drawable.ic_placeholder) - .into(this) -} diff --git a/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/util/EndlessRecyclerViewScrollListener.kt b/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/util/EndlessRecyclerViewScrollListener.kt deleted file mode 100644 index a1ff170..0000000 --- a/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/util/EndlessRecyclerViewScrollListener.kt +++ /dev/null @@ -1,48 +0,0 @@ -package com.mohsenoid.rickandmorty.view.util - -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView - -abstract class EndlessRecyclerViewScrollListener(private val layoutManager: LinearLayoutManager) : - RecyclerView.OnScrollListener() { - - private var visibleThreshold = DEFAULT_VISIBLE_THRESHOLD - private var currentPage = 0 - private var previousTotalItemCount = 0 - private var loading = true - private val startingPageIndex = 0 - - override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) { - val totalItemCount: Int = layoutManager.itemCount - val lastVisibleItemPosition: Int = layoutManager.findLastVisibleItemPosition() - - if (totalItemCount < previousTotalItemCount) { - currentPage = startingPageIndex - previousTotalItemCount = totalItemCount - if (totalItemCount == 0) { - loading = true - } - } - if (loading && totalItemCount > previousTotalItemCount) { - loading = false - previousTotalItemCount = totalItemCount - } - if (!loading && lastVisibleItemPosition + visibleThreshold > totalItemCount) { - currentPage++ - onLoadMore(currentPage, totalItemCount, view) - loading = true - } - } - - fun resetState() { - currentPage = startingPageIndex - previousTotalItemCount = 0 - loading = true - } - - abstract fun onLoadMore(page: Int, totalItemsCount: Int, view: RecyclerView) - - companion object { - const val DEFAULT_VISIBLE_THRESHOLD = 5 - } -} diff --git a/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/util/LifecycleExtensions.kt b/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/util/LifecycleExtensions.kt deleted file mode 100644 index 2028bb8..0000000 --- a/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/util/LifecycleExtensions.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.mohsenoid.rickandmorty.view.util - -import androidx.lifecycle.DefaultLifecycleObserver -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.coroutineScope -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job - -fun Lifecycle.launchWhileResumed(block: suspend CoroutineScope.() -> Unit): Job { - val job = coroutineScope.launchWhenResumed(block) - addObserver( - object : DefaultLifecycleObserver { - override fun onPause(owner: LifecycleOwner) { - job.cancel() - removeObserver(this) - } - }, - ) - return job -} diff --git a/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/util/SquareImageView.kt b/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/util/SquareImageView.kt deleted file mode 100644 index 95e71cd..0000000 --- a/app/src/main/kotlin/com/mohsenoid/rickandmorty/view/util/SquareImageView.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.mohsenoid.rickandmorty.view.util - -import android.content.Context -import android.util.AttributeSet -import androidx.appcompat.widget.AppCompatImageView - -class SquareImageView : AppCompatImageView { - - constructor(context: Context) : super(context) - - constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) - - constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super( - context, - attrs, - defStyleAttr, - ) - - override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { - super.onMeasure(widthMeasureSpec, heightMeasureSpec) - val width: Int = measuredWidth - setMeasuredDimension(width, width) - } -} diff --git a/app/src/main/res/drawable/ic_alive.xml b/app/src/main/res/drawable/ic_alive.xml deleted file mode 100644 index eac7637..0000000 --- a/app/src/main/res/drawable/ic_alive.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - diff --git a/app/src/main/res/drawable/ic_dead.xml b/app/src/main/res/drawable/ic_dead.xml deleted file mode 100644 index a59b991..0000000 --- a/app/src/main/res/drawable/ic_dead.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..58c24f1 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,18 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_placeholder.xml b/app/src/main/res/drawable/ic_placeholder.xml deleted file mode 100644 index 11e94c0..0000000 --- a/app/src/main/res/drawable/ic_placeholder.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/rick_morty.xml b/app/src/main/res/drawable/rick_morty.xml new file mode 100644 index 0000000..ad67e25 --- /dev/null +++ b/app/src/main/res/drawable/rick_morty.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml deleted file mode 100644 index ed1c066..0000000 --- a/app/src/main/res/layout/activity_main.xml +++ /dev/null @@ -1,11 +0,0 @@ - - diff --git a/app/src/main/res/layout/fragment_character_details.xml b/app/src/main/res/layout/fragment_character_details.xml deleted file mode 100644 index 1bd6360..0000000 --- a/app/src/main/res/layout/fragment_character_details.xml +++ /dev/null @@ -1,192 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/fragment_character_list.xml b/app/src/main/res/layout/fragment_character_list.xml deleted file mode 100644 index d6c4cfa..0000000 --- a/app/src/main/res/layout/fragment_character_list.xml +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/fragment_episode_list.xml b/app/src/main/res/layout/fragment_episode_list.xml deleted file mode 100644 index 445490b..0000000 --- a/app/src/main/res/layout/fragment_episode_list.xml +++ /dev/null @@ -1,48 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/item_character.xml b/app/src/main/res/layout/item_character.xml deleted file mode 100644 index a38d81d..0000000 --- a/app/src/main/res/layout/item_character.xml +++ /dev/null @@ -1,52 +0,0 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/item_episode.xml b/app/src/main/res/layout/item_episode.xml deleted file mode 100644 index 30c7493..0000000 --- a/app/src/main/res/layout/item_episode.xml +++ /dev/null @@ -1,53 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index c9ad5f9..ac94b34 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -1,5 +1,5 @@ - + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml index c9ad5f9..ac94b34 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -1,5 +1,5 @@ - + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png deleted file mode 100644 index 2c416f7..0000000 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher.png and /dev/null differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..2def37f Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_background.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_background.webp new file mode 100644 index 0000000..9bd2855 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_background.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png deleted file mode 100644 index ec199a5..0000000 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png deleted file mode 100644 index 819854b..0000000 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png and /dev/null differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..2def37f Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png deleted file mode 100644 index 23b2d43..0000000 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher.png and /dev/null differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..c66ae4b Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_background.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_background.webp new file mode 100644 index 0000000..c8cb4f5 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_background.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png deleted file mode 100644 index f18a35c..0000000 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png deleted file mode 100644 index 88d6634..0000000 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png and /dev/null differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..c66ae4b Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png deleted file mode 100644 index 82f0040..0000000 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher.png and /dev/null differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..ca13eac Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_background.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_background.webp new file mode 100644 index 0000000..f883daf Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_background.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png deleted file mode 100644 index 91cfb44..0000000 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png deleted file mode 100644 index a3c8b23..0000000 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png and /dev/null differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..ca13eac Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png deleted file mode 100644 index 297179b..0000000 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and /dev/null differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..39e9bfa Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.webp new file mode 100644 index 0000000..d801e0e Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png deleted file mode 100644 index 3a73d92..0000000 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png deleted file mode 100644 index 08d0930..0000000 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png and /dev/null differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..39e9bfa Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png deleted file mode 100644 index cb62cb8..0000000 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..1f0ef11 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.webp new file mode 100644 index 0000000..376093b Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png deleted file mode 100644 index f72aa91..0000000 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png deleted file mode 100644 index 0d31c90..0000000 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png and /dev/null differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1f0ef11 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/navigation/nav_graph.xml b/app/src/main/res/navigation/nav_graph.xml deleted file mode 100644 index 85e1e7c..0000000 --- a/app/src/main/res/navigation/nav_graph.xml +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - - - - - - - - - diff --git a/app/src/main/res/values-w600dp/dimens.xml b/app/src/main/res/values-w600dp/dimens.xml deleted file mode 100644 index 10e5b96..0000000 --- a/app/src/main/res/values-w600dp/dimens.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - 24dp - - diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 9f42545..f8c6127 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,11 +1,10 @@ - #008577 - #00574B - #D81B60 - - #E6555555 - - @color/transparentGray - @color/transparentGray - + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml deleted file mode 100644 index ac7f5df..0000000 --- a/app/src/main/res/values/dimens.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - 16dp - diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml index f42ada6..beab31f 100644 --- a/app/src/main/res/values/ic_launcher_background.xml +++ b/app/src/main/res/values/ic_launcher_background.xml @@ -1,4 +1,4 @@ - #FFFFFF - + #000000 + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5832c69..b6c105f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,18 +1,3 @@ Rick and Morty - - No network connections - Something went wrong! - - STATUS - SPECIES - GENDER - ORIGIN - LAST LOCATION - - id: %1$d - created: %2$s - - No offline data!\nConnect network connection and try again… - - (Killed) - + \ No newline at end of file diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml deleted file mode 100644 index f4b555d..0000000 --- a/app/src/main/res/values/styles.xml +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - - - - - diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..4eff989 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +