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 @@
-
-
-
+
+
@@ -124,15 +119,6 @@
-
-
-
-
-
-
-
-
-
\ 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)
+
## 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 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml
new file mode 100644
index 0000000..fa0f996
--- /dev/null
+++ b/app/src/main/res/xml/backup_rules.xml
@@ -0,0 +1,13 @@
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 0000000..9ee9997
--- /dev/null
+++ b/app/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/release/res/xml/network_security_config.xml b/app/src/release/res/xml/network_security_config.xml
deleted file mode 100644
index 82cc113..0000000
--- a/app/src/release/res/xml/network_security_config.xml
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
-
-
diff --git a/app/src/test/java/com/mohsenoid/rickandmorty/ExampleUnitTest.kt b/app/src/test/java/com/mohsenoid/rickandmorty/ExampleUnitTest.kt
new file mode 100644
index 0000000..c37c2a1
--- /dev/null
+++ b/app/src/test/java/com/mohsenoid/rickandmorty/ExampleUnitTest.kt
@@ -0,0 +1,16 @@
+package com.mohsenoid.rickandmorty
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+class ExampleUnitTest {
+ @Test
+ fun addition_isCorrect() {
+ assertEquals(4, 2 + 2)
+ }
+}
diff --git a/app/src/test/java/com/mohsenoid/testapplication/KoinModulesTest.kt b/app/src/test/java/com/mohsenoid/testapplication/KoinModulesTest.kt
new file mode 100644
index 0000000..44ebfd3
--- /dev/null
+++ b/app/src/test/java/com/mohsenoid/testapplication/KoinModulesTest.kt
@@ -0,0 +1,15 @@
+package com.mohsenoid.rickandmorty
+
+import org.junit.Test
+import org.koin.core.annotation.KoinExperimentalAPI
+import org.koin.test.KoinTest
+import org.koin.test.verify.verify
+
+@OptIn(KoinExperimentalAPI::class)
+class KoinModulesTest : KoinTest {
+
+ @Test
+ fun `Verify modules`() {
+ appModule.verify()
+ }
+}
diff --git a/app/src/test/kotlin/com/mohsenoid/rickandmorty/data/RepositoryGetCharacterDetailsTest.kt b/app/src/test/kotlin/com/mohsenoid/rickandmorty/data/RepositoryGetCharacterDetailsTest.kt
deleted file mode 100644
index 78fe2ea..0000000
--- a/app/src/test/kotlin/com/mohsenoid/rickandmorty/data/RepositoryGetCharacterDetailsTest.kt
+++ /dev/null
@@ -1,68 +0,0 @@
-package com.mohsenoid.rickandmorty.data
-
-import com.mohsenoid.rickandmorty.data.api.model.ApiCharacter
-import com.mohsenoid.rickandmorty.data.api.model.ApiResult
-import com.mohsenoid.rickandmorty.data.db.model.DbCharacter
-import com.mohsenoid.rickandmorty.test.ApiFactory
-import com.mohsenoid.rickandmorty.test.CharacterDataFactory
-import com.mohsenoid.rickandmorty.test.DataFactory
-import io.mockk.coEvery
-import io.mockk.coVerify
-import io.mockk.just
-import io.mockk.runs
-import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.test.runBlockingTest
-import org.junit.Test
-
-@OptIn(ExperimentalCoroutinesApi::class)
-class RepositoryGetCharacterDetailsTest : RepositoryTest() {
-
- @Test
- fun `test if getCharacterDetails calls networkClient when isOnline`() = runBlockingTest {
- // GIVEN
- val characterId: Int = DataFactory.randomInt()
- stubStatusProviderIsOnline(isOnline = true)
- stubApiFetchCharacterDetails(ApiFactory.CharacterDetails.makeCharacter())
- stubSbQueryCharacter(
- character = CharacterDataFactory.makeDbCharacter(characterId = characterId),
- )
- stubDbInsertOrUpdateCharacter()
-
- // WHEN
- repository.getCharacterDetails(characterId = characterId)
-
- // THEN
- coVerify(exactly = 1) { api.fetchCharacterDetails(characterId = any()) }
-
- coVerify(exactly = 1) { db.insertOrUpdateCharacter(character = any()) }
- coVerify(exactly = 1) { db.queryCharacter(characterId = any()) }
- }
-
- @Test
- fun `test if getCharacterDetails calls db only when isOffline`() = runBlockingTest {
- // GIVEN
- val characterId: Int = DataFactory.randomInt()
- stubStatusProviderIsOnline(isOnline = false)
- stubSbQueryCharacter(CharacterDataFactory.makeDbCharacter(characterId = characterId))
-
- // WHEN
- repository.getCharacterDetails(characterId = characterId)
-
- // THEN
- coVerify(exactly = 0) { api.fetchCharacterDetails(characterId = any()) }
- coVerify(exactly = 1) { db.queryCharacter(characterId = any()) }
- }
-
- private suspend fun stubApiFetchCharacterDetails(character: ApiCharacter) {
- coEvery { api.fetchCharacterDetails(characterId = any()) } returns
- ApiResult.Success(character)
- }
-
- private fun stubDbInsertOrUpdateCharacter() {
- coEvery { db.insertOrUpdateCharacter(any()) } just runs
- }
-
- private fun stubSbQueryCharacter(character: DbCharacter?) {
- coEvery { db.queryCharacter(characterId = any()) } returns character
- }
-}
diff --git a/app/src/test/kotlin/com/mohsenoid/rickandmorty/data/RepositoryGetCharactersTest.kt b/app/src/test/kotlin/com/mohsenoid/rickandmorty/data/RepositoryGetCharactersTest.kt
deleted file mode 100644
index 3dfce72..0000000
--- a/app/src/test/kotlin/com/mohsenoid/rickandmorty/data/RepositoryGetCharactersTest.kt
+++ /dev/null
@@ -1,66 +0,0 @@
-package com.mohsenoid.rickandmorty.data
-
-import com.mohsenoid.rickandmorty.data.api.model.ApiCharacter
-import com.mohsenoid.rickandmorty.data.api.model.ApiResult
-import com.mohsenoid.rickandmorty.data.db.model.DbCharacter
-import com.mohsenoid.rickandmorty.test.ApiFactory
-import com.mohsenoid.rickandmorty.test.CharacterDataFactory
-import com.mohsenoid.rickandmorty.test.DataFactory
-import io.mockk.coEvery
-import io.mockk.coVerify
-import io.mockk.just
-import io.mockk.runs
-import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.test.runBlockingTest
-import org.junit.Test
-
-@OptIn(ExperimentalCoroutinesApi::class)
-class RepositoryGetCharactersTest : RepositoryTest() {
-
- @Test
- fun `test if getCharactersByIds calls networkClient when isOnline`() = runBlockingTest {
- // GIVEN
- val characterIds: List = DataFactory.randomIntList(count = 5)
- stubStatusProviderIsOnline(isOnline = true)
- stubApiFetchCharactersByIds(ApiFactory.Characters.makeCharacters())
- stubDbQueryCharactersByIds(CharacterDataFactory.makeDbCharacters(count = 5))
- stubDbInsertOrUpdateCharacter()
-
- // WHEN
- repository.getCharactersByIds(characterIds = characterIds)
-
- // THEN
- coVerify(exactly = 1) { api.fetchCharactersByIds(characterIds = any()) }
-
- coVerify(exactly = 1) { db.insertOrUpdateCharacter(character = any()) }
- coVerify(exactly = 1) { db.queryCharactersByIds(characterIds = any()) }
- }
-
- @Test
- fun `test if getCharactersByIds calls db only when isOffline`() = runBlockingTest {
- // GIVEN
- val characterIds: List = DataFactory.randomIntList(count = 5)
- stubStatusProviderIsOnline(isOnline = false)
- stubDbQueryCharactersByIds(CharacterDataFactory.makeDbCharacters(count = 5))
-
- // WHEN
- repository.getCharactersByIds(characterIds = characterIds)
-
- // THEN
- coVerify(exactly = 0) { api.fetchCharactersByIds(characterIds = any()) }
- coVerify(exactly = 1) { db.queryCharactersByIds(characterIds = any()) }
- }
-
- private fun stubApiFetchCharactersByIds(characters: List) {
- coEvery { api.fetchCharactersByIds(characterIds = any()) } returns
- ApiResult.Success(characters)
- }
-
- private fun stubDbQueryCharactersByIds(characters: List) {
- coEvery { db.queryCharactersByIds(characterIds = any()) } returns characters
- }
-
- private fun stubDbInsertOrUpdateCharacter() {
- coEvery { db.insertOrUpdateCharacter(any()) } just runs
- }
-}
diff --git a/app/src/test/kotlin/com/mohsenoid/rickandmorty/data/RepositoryGetEpisodesTest.kt b/app/src/test/kotlin/com/mohsenoid/rickandmorty/data/RepositoryGetEpisodesTest.kt
deleted file mode 100644
index 971ccde..0000000
--- a/app/src/test/kotlin/com/mohsenoid/rickandmorty/data/RepositoryGetEpisodesTest.kt
+++ /dev/null
@@ -1,62 +0,0 @@
-package com.mohsenoid.rickandmorty.data
-
-import com.mohsenoid.rickandmorty.data.api.model.ApiEpisodes
-import com.mohsenoid.rickandmorty.data.api.model.ApiResult
-import com.mohsenoid.rickandmorty.data.db.model.DbEpisode
-import com.mohsenoid.rickandmorty.test.ApiFactory
-import com.mohsenoid.rickandmorty.test.EpisodeDataFactory
-import io.mockk.coEvery
-import io.mockk.coVerify
-import io.mockk.just
-import io.mockk.runs
-import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.test.runBlockingTest
-import org.junit.Test
-
-@OptIn(ExperimentalCoroutinesApi::class)
-class RepositoryGetEpisodesTest : RepositoryTest() {
-
- @Test
- fun `getEpisodes calls networkClient and db when isOnline`() = runBlockingTest {
- // GIVEN
- stubStatusProviderIsOnline(isOnline = true)
- stubApiFetchEpisodes(ApiFactory.Episode.makeApiEpisodes())
- val entityEpisodes = EpisodeDataFactory.makeDbEpisodes(count = 5)
- stubEpisodeDaoQueryAllEpisodesByPage(entityEpisodes)
- stubDbInsertEpisode()
-
- // WHEN
- repository.getEpisodes(page = 1)
-
- // THEN
- coVerify(exactly = 1) { api.fetchEpisodes(page = 1) }
- coVerify(exactly = 1) { db.insertEpisode(entityEpisode = any()) }
- coVerify(exactly = 1) { db.queryAllEpisodesByPage(page = any(), pageSize = any()) }
- }
-
- @Test
- fun `if getEpisodes calls db only when isOffline`() = runBlockingTest {
- // GIVEN
- stubStatusProviderIsOnline(isOnline = false)
- stubEpisodeDaoQueryAllEpisodesByPage(EpisodeDataFactory.makeDbEpisodes(count = 5))
-
- // WHEN
- repository.getEpisodes(page = 1)
-
- // THEN
- coVerify(exactly = 0) { api.fetchEpisodes(page = 1) }
- coVerify(exactly = 1) { db.queryAllEpisodesByPage(page = any(), pageSize = any()) }
- }
-
- private fun stubApiFetchEpisodes(episodes: ApiEpisodes) {
- coEvery { api.fetchEpisodes(page = any()) } returns ApiResult.Success(episodes)
- }
-
- private fun stubDbInsertEpisode() {
- coEvery { db.insertEpisode(any()) } just runs
- }
-
- private fun stubEpisodeDaoQueryAllEpisodesByPage(entityEpisodes: List) {
- coEvery { db.queryAllEpisodesByPage(page = any(), pageSize = any()) } returns entityEpisodes
- }
-}
diff --git a/app/src/test/kotlin/com/mohsenoid/rickandmorty/data/RepositoryTest.kt b/app/src/test/kotlin/com/mohsenoid/rickandmorty/data/RepositoryTest.kt
deleted file mode 100644
index 90960a7..0000000
--- a/app/src/test/kotlin/com/mohsenoid/rickandmorty/data/RepositoryTest.kt
+++ /dev/null
@@ -1,39 +0,0 @@
-package com.mohsenoid.rickandmorty.data
-
-import com.mohsenoid.rickandmorty.data.api.ApiRickAndMorty
-import com.mohsenoid.rickandmorty.data.db.DbRickAndMorty
-import com.mohsenoid.rickandmorty.domain.Repository
-import com.mohsenoid.rickandmorty.util.StatusProvider
-import io.mockk.MockKAnnotations
-import io.mockk.every
-import io.mockk.impl.annotations.MockK
-import org.junit.Before
-
-open class RepositoryTest {
-
- @MockK
- internal lateinit var db: DbRickAndMorty
-
- @MockK
- internal lateinit var api: ApiRickAndMorty
-
- @MockK
- internal lateinit var statusProvider: StatusProvider
-
- lateinit var repository: Repository
-
- @Before
- fun setUp() {
- MockKAnnotations.init(this)
-
- repository = RepositoryImpl(
- db = db,
- api = api,
- statusProvider = statusProvider,
- )
- }
-
- fun stubStatusProviderIsOnline(isOnline: Boolean) {
- every { statusProvider.isOnline() } returns isOnline
- }
-}
diff --git a/app/src/test/kotlin/com/mohsenoid/rickandmorty/data/db/DbRickAndMortyTest.kt b/app/src/test/kotlin/com/mohsenoid/rickandmorty/data/db/DbRickAndMortyTest.kt
deleted file mode 100644
index 2afbe60..0000000
--- a/app/src/test/kotlin/com/mohsenoid/rickandmorty/data/db/DbRickAndMortyTest.kt
+++ /dev/null
@@ -1,253 +0,0 @@
-package com.mohsenoid.rickandmorty.data.db
-
-import android.app.Application
-import android.os.Build
-import androidx.arch.core.executor.testing.InstantTaskExecutorRule
-import androidx.room.Room
-import androidx.test.core.app.ApplicationProvider
-import com.mohsenoid.rickandmorty.data.db.model.DbCharacter
-import com.mohsenoid.rickandmorty.data.db.model.DbEpisode
-import com.mohsenoid.rickandmorty.test.CharacterDataFactory
-import com.mohsenoid.rickandmorty.test.DataFactory
-import com.mohsenoid.rickandmorty.test.EpisodeDataFactory
-import io.mockk.every
-import io.mockk.mockkObject
-import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.test.runBlockingTest
-import org.junit.After
-import org.junit.Before
-import org.junit.Rule
-import org.junit.Test
-import org.junit.runner.RunWith
-import org.koin.core.context.stopKoin
-import org.robolectric.RobolectricTestRunner
-import org.robolectric.annotation.Config
-import java.io.IOException
-import kotlin.test.assertContains
-import kotlin.test.assertEquals
-import kotlin.test.assertFalse
-import kotlin.test.assertNotNull
-import kotlin.test.assertTrue
-
-@OptIn(ExperimentalCoroutinesApi::class)
-@Config(sdk = [Build.VERSION_CODES.P])
-@RunWith(RobolectricTestRunner::class)
-class DbRickAndMortyTest {
-
- @get:Rule
- val instantExecutorRule = InstantTaskExecutorRule()
-
- private lateinit var roomDb: DbRickAndMorty.Db
-
- private lateinit var db: DbRickAndMorty
-
- @Before
- fun setUp() {
- val application: Application = ApplicationProvider.getApplicationContext()
- mockkObject(DbRickAndMorty.Db) {
- roomDb = Room.inMemoryDatabaseBuilder(application, DbRickAndMorty.Db::class.java)
- .allowMainThreadQueries()
- .build()
-
- every { DbRickAndMorty.Db.create(application) } returns roomDb
-
- db = DbRickAndMorty(application)
- }
- }
-
- @After
- @Throws(IOException::class)
- fun tearDown() {
- roomDb.close()
- stopKoin()
- }
-
- @Test
- fun `insertEpisode can inserts a new Episode`() = runBlockingTest {
- // GIVEN
- val expectedEntityEpisode: DbEpisode = EpisodeDataFactory.makeDbEpisode()
-
- // WHEN
- db.insertEpisode(expectedEntityEpisode)
-
- // THEN
- val actualEntityEpisodes: List = db.queryAllEpisodes()
-
- assertFalse(actualEntityEpisodes.isEmpty())
- assertContains(actualEntityEpisodes, expectedEntityEpisode)
- }
-
- @Test
- fun `insertEpisode with same ID updates the old one`() = runBlockingTest {
- // GIVEN
- val episodeId: Int = DataFactory.randomInt()
- val oldEntityEpisode: DbEpisode =
- EpisodeDataFactory.makeDbEpisode(episodeId = episodeId)
- val updatedEntityEpisode: DbEpisode =
- EpisodeDataFactory.makeDbEpisode(episodeId = episodeId)
-
- // WHEN
- db.insertEpisode(oldEntityEpisode)
- db.insertEpisode(updatedEntityEpisode)
-
- // THEN
- val actualEntityEpisodes: List = db.queryAllEpisodes()
-
- assertFalse(actualEntityEpisodes.isEmpty())
- assertContains(actualEntityEpisodes, updatedEntityEpisode)
- // assertNotContains(actualEntityEpisodes, oldEntityEpisode)
- }
-
- @Test
- fun `insertCharacter can inserts a new Character`() = runBlockingTest {
- // GIVEN
- val expectedCharacter: DbCharacter = CharacterDataFactory.makeDbCharacter()
-
- // WHEN
- db.insertOrUpdateCharacter(expectedCharacter)
-
- // THEN
- val actualCharacter: DbCharacter? =
- db.queryCharacter(expectedCharacter.id)
-
- assertNotNull(actualCharacter)
- assertEquals(actualCharacter, expectedCharacter)
- }
-
- @Test
- fun `insertCharacter with same ID updates the old one`() = runBlockingTest {
- // GIVEN
- val characterId: Int = DataFactory.randomInt()
- val oldCharacter: DbCharacter =
- CharacterDataFactory.makeDbCharacter(characterId = characterId)
- val updatedCharacter: DbCharacter =
- CharacterDataFactory.makeDbCharacter(characterId = characterId)
-
- // WHEN
- db.insertOrUpdateCharacter(oldCharacter)
- db.insertOrUpdateCharacter(updatedCharacter)
-
- // THEN
- val actualCharacter: DbCharacter? = db.queryCharacter(characterId)
-
- assertNotNull(actualCharacter)
- assertEquals(actualCharacter, updatedCharacter)
- }
-
- @Test
- fun `queryCharacter returns the same Character details inserted`() = runBlockingTest {
- // GIVEN
- val characterId: Int = DataFactory.randomInt()
- val expectedCharacter: DbCharacter =
- CharacterDataFactory.makeDbCharacter(characterId = characterId)
-
- // WHEN
- db.insertOrUpdateCharacter(expectedCharacter)
-
- // THEN
- val actualCharacter: DbCharacter? = db.queryCharacter(characterId)
-
- assertNotNull(actualCharacter)
- assertEquals(actualCharacter, expectedCharacter)
- }
-
- @Test
- fun `queryCharactersByIds returns the Characters asked for`() = runBlockingTest {
- // GIVEN
- val expected: DbCharacter = CharacterDataFactory.makeDbCharacter()
- val notExpected: DbCharacter = CharacterDataFactory.makeDbCharacter()
- val expectedCharacterIds: List = listOf(expected.id)
-
- // WHEN
- db.insertOrUpdateCharacter(expected)
- db.insertOrUpdateCharacter(notExpected)
-
- // THEN
- val characters: List =
- db.queryCharactersByIds(expectedCharacterIds)
-
- assertFalse(characters.isEmpty())
- assertContains(characters, expected)
- // assertNotContains(characters, expected)
- }
-
- @Test
- fun `Character which status is Alive and is not KilledByUser isAlive`() = runBlockingTest {
- // GIVEN
- val expectedCharacterId: Int = DataFactory.randomInt()
- val expectedCharacter: DbCharacter = CharacterDataFactory.makeDbCharacter(
- characterId = expectedCharacterId,
- status = ALIVE,
- isAlive = true,
- isKilledByUser = false,
- )
-
- // WHEN
- db.insertOrUpdateCharacter(expectedCharacter)
-
- // THEN
- val actualCharacter: DbCharacter? =
- db.queryCharacter(expectedCharacterId)
-
- assertNotNull(actualCharacter)
- assertEquals(actualCharacter.status, ALIVE)
- assertTrue(actualCharacter.isAlive)
- assertFalse(actualCharacter.isKilledByUser)
- }
-
- @Test
- fun `test user can kill a Character`() = runBlockingTest {
- // GIVEN
- val expectedCharacterId: Int = DataFactory.randomInt()
- val expectedCharacter: DbCharacter = CharacterDataFactory.makeDbCharacter(
- characterId = expectedCharacterId,
- status = ALIVE,
- isAlive = true,
- isKilledByUser = false,
- )
-
- // WHEN
- db.insertOrUpdateCharacter(expectedCharacter)
- db.killCharacter(expectedCharacterId)
-
- // THEN
- val actualCharacter: DbCharacter? =
- db.queryCharacter(expectedCharacterId)
-
- assertNotNull(actualCharacter)
- assertEquals(actualCharacter.status, ALIVE)
- assertTrue(actualCharacter.isAlive)
- assertTrue(actualCharacter.isKilledByUser)
- }
-
- @Test
- fun `test a Character which isKilledByUser keeps dead after update by insert`() =
- runBlockingTest {
- // GIVEN
- val expectedCharacterId: Int = DataFactory.randomInt()
- val expectedCharacter: DbCharacter = CharacterDataFactory.makeDbCharacter(
- characterId = expectedCharacterId,
- status = ALIVE,
- isAlive = true,
- isKilledByUser = false,
- )
-
- // WHEN
- db.insertOrUpdateCharacter(expectedCharacter)
- db.killCharacter(expectedCharacterId)
- db.insertOrUpdateCharacter(expectedCharacter)
-
- // THEN
- val actualCharacter: DbCharacter? =
- db.queryCharacter(expectedCharacterId)
-
- assertNotNull(actualCharacter)
- assertEquals(actualCharacter.status, ALIVE)
- assertTrue(actualCharacter.isAlive)
- assertTrue(actualCharacter.isKilledByUser)
- }
-
- companion object {
- private const val ALIVE = "alive"
- }
-}
diff --git a/app/src/test/kotlin/com/mohsenoid/rickandmorty/data/mapper/CharacterMapperTest.kt b/app/src/test/kotlin/com/mohsenoid/rickandmorty/data/mapper/CharacterMapperTest.kt
deleted file mode 100644
index e63df3d..0000000
--- a/app/src/test/kotlin/com/mohsenoid/rickandmorty/data/mapper/CharacterMapperTest.kt
+++ /dev/null
@@ -1,126 +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
-import org.junit.Test
-import kotlin.test.assertEquals
-
-class CharacterMapperTest {
-
- @Test
- fun modelToDbCharacter() {
- // GIVEN
- val apiCharacter = ApiCharacter(
- id = CHARACTER_ID,
- name = CHARACTER_NAME,
- status = CHARACTER_STATUS,
- species = CHARACTER_SPECIES,
- type = CHARACTER_TYPE,
- gender = CHARACTER_GENDER,
- origin = ApiOrigin(CHARACTER_ORIGIN_NAME, CHARACTER_ORIGIN_URL),
- location = ApiLocation(CHARACTER_LOCATION_NAME, CHARACTER_LOCATION_URL),
- image = CHARACTER_IMAGE,
- episodes = CHARACTER_EPISODES,
- url = CHARACTER_URL,
- created = CHARACTER_CREATED,
- )
-
- val expectedDbCharacter = DbCharacter(
- id = CHARACTER_ID,
- name = CHARACTER_NAME,
- status = CHARACTER_STATUS,
- isAlive = CHARACTER_STATUS_ALIVE,
- species = CHARACTER_SPECIES,
- type = CHARACTER_TYPE,
- gender = CHARACTER_GENDER,
- origin = DbOrigin(CHARACTER_ORIGIN_NAME, CHARACTER_ORIGIN_URL),
- location = DbLocation(CHARACTER_LOCATION_NAME, CHARACTER_LOCATION_URL),
- image = CHARACTER_IMAGE,
- episodeIds = CHARACTER_EPISODE_IDS,
- url = CHARACTER_URL,
- created = CHARACTER_CREATED,
- isKilledByUser = false,
- )
-
- // WHEN
- val actualDbCharacter: DbCharacter = apiCharacter.toDbCharacter()
-
- // THEN
- assertEquals(expectedDbCharacter, actualDbCharacter)
- }
-
- @Test
- fun dbToModelCharacter() {
- // GIVEN
- val dbCharacter = DbCharacter(
- id = CHARACTER_ID,
- name = CHARACTER_NAME,
- status = CHARACTER_STATUS,
- isAlive = CHARACTER_STATUS_ALIVE,
- species = CHARACTER_SPECIES,
- type = CHARACTER_TYPE,
- gender = CHARACTER_GENDER,
- origin = DbOrigin(CHARACTER_ORIGIN_NAME, CHARACTER_ORIGIN_URL),
- location = DbLocation(CHARACTER_LOCATION_NAME, CHARACTER_LOCATION_URL),
- image = CHARACTER_IMAGE,
- episodeIds = CHARACTER_EPISODE_IDS,
- url = CHARACTER_URL,
- created = CHARACTER_CREATED,
- isKilledByUser = CHARACTER_IS_KILLED_BY_USER,
- )
-
- val expectedModelCharacter = ModelCharacter(
- id = CHARACTER_ID,
- name = CHARACTER_NAME,
- status = CHARACTER_STATUS,
- isAlive = CHARACTER_STATUS_ALIVE,
- species = CHARACTER_SPECIES,
- type = CHARACTER_TYPE,
- gender = CHARACTER_GENDER,
- origin = ModelOrigin(CHARACTER_ORIGIN_NAME, CHARACTER_ORIGIN_URL),
- location = ModelLocation(CHARACTER_LOCATION_NAME, CHARACTER_LOCATION_URL),
- imageUrl = CHARACTER_IMAGE,
- episodeIds = CHARACTER_EPISODE_IDS,
- url = CHARACTER_URL,
- created = CHARACTER_CREATED,
- isKilledByUser = CHARACTER_IS_KILLED_BY_USER,
- )
-
- // WHEN
- val actualModelCharacter: ModelCharacter = dbCharacter.toModelCharacter()
-
- // THEN
- assertEquals(expectedModelCharacter, actualModelCharacter)
- }
-
- companion object {
- private const val CHARACTER_ID = 123
- private const val CHARACTER_NAME = "some name"
- private const val CHARACTER_STATUS = "dead"
- private const val CHARACTER_STATUS_ALIVE = false
- private const val CHARACTER_SPECIES = "some species"
- private const val CHARACTER_TYPE = "some type"
- private const val CHARACTER_GENDER = "some gender"
- private const val CHARACTER_ORIGIN_NAME = "some origin name"
- private const val CHARACTER_ORIGIN_URL = "some origin url"
- private const val CHARACTER_LOCATION_NAME = "some location name"
- private const val CHARACTER_LOCATION_URL = "some location url"
- private const val CHARACTER_IMAGE = "some image"
- private val CHARACTER_EPISODES = listOf(
- "https://test.com/api/episode/1",
- "https://test.com/api/episode/2",
- "https://test.com/api/episode/3",
- )
- private val CHARACTER_EPISODE_IDS = listOf(1, 2, 3)
- private const val CHARACTER_URL = "some url"
- private const val CHARACTER_CREATED = "some created"
- private const val CHARACTER_IS_KILLED_BY_USER = true
- }
-}
diff --git a/app/src/test/kotlin/com/mohsenoid/rickandmorty/data/mapper/EpisodeMapperTest.kt b/app/src/test/kotlin/com/mohsenoid/rickandmorty/data/mapper/EpisodeMapperTest.kt
deleted file mode 100644
index 6581fb1..0000000
--- a/app/src/test/kotlin/com/mohsenoid/rickandmorty/data/mapper/EpisodeMapperTest.kt
+++ /dev/null
@@ -1,85 +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
-import org.junit.Test
-import kotlin.test.assertEquals
-
-class EpisodeMapperTest {
-
- @Test
- fun apiToDbEpisode() {
- // GIVEN
- val apiEpisode = ApiEpisode(
- id = EPISODE_ID,
- name = EPISODE_NAME,
- airDate = EPISODE_AIR_DATE,
- episode = EPISODE_EPISODE,
- characters = CHARACTER_CHARACTERS,
- url = EPISODE_URL,
- created = EPISODE_CREATED,
- )
-
- val expectedDbEpisode = DbEpisode(
- id = EPISODE_ID,
- name = EPISODE_NAME,
- airDate = EPISODE_AIR_DATE,
- episode = EPISODE_EPISODE,
- characterIds = CHARACTER_CHARACTER_IDS,
- url = EPISODE_URL,
- created = EPISODE_CREATED,
- )
-
- // WHEN
- val actualDbEpisode: DbEpisode = apiEpisode.toDbEpisode()
-
- // THEN
- assertEquals(expectedDbEpisode, actualDbEpisode)
- }
-
- @Test
- fun dbToModelEpisode() {
- // GIVEN
- val dbEpisode = DbEpisode(
- id = EPISODE_ID,
- name = EPISODE_NAME,
- airDate = EPISODE_AIR_DATE,
- episode = EPISODE_EPISODE,
- characterIds = CHARACTER_CHARACTER_IDS,
- url = EPISODE_URL,
- created = EPISODE_CREATED,
- )
-
- val expectedModelEpisode = ModelEpisode(
- id = EPISODE_ID,
- name = EPISODE_NAME,
- airDate = EPISODE_AIR_DATE,
- episode = EPISODE_EPISODE,
- characterIds = CHARACTER_CHARACTER_IDS,
- url = EPISODE_URL,
- created = EPISODE_CREATED,
- )
-
- // WHEN
- val actualModelEpisode: ModelEpisode = dbEpisode.toModelEpisode()
-
- // THEN
- assertEquals(expectedModelEpisode, actualModelEpisode)
- }
-
- companion object {
- private const val EPISODE_ID = 123
- private const val EPISODE_NAME = "some name"
- private const val EPISODE_AIR_DATE = "some air date"
- private const val EPISODE_EPISODE = "some episode"
- private val CHARACTER_CHARACTERS = listOf(
- "https://test.com/api/character/1",
- "https://test.com/api/character/2",
- "https://test.com/api/character/3",
- )
- private val CHARACTER_CHARACTER_IDS = listOf(1, 2, 3)
- private const val EPISODE_URL = "some url"
- private const val EPISODE_CREATED = "some created"
- }
-}
diff --git a/app/src/test/kotlin/com/mohsenoid/rickandmorty/injection/AppModuleTest.kt b/app/src/test/kotlin/com/mohsenoid/rickandmorty/injection/AppModuleTest.kt
deleted file mode 100644
index 09837d9..0000000
--- a/app/src/test/kotlin/com/mohsenoid/rickandmorty/injection/AppModuleTest.kt
+++ /dev/null
@@ -1,24 +0,0 @@
-package com.mohsenoid.rickandmorty.injection
-
-import android.os.Build
-import com.mohsenoid.rickandmorty.appModule
-import org.junit.Test
-import org.junit.runner.RunWith
-import org.koin.android.ext.koin.androidContext
-import org.koin.core.context.startKoin
-import org.koin.test.check.checkModules
-import org.robolectric.RobolectricTestRunner
-import org.robolectric.annotation.Config
-
-@Config(sdk = [Build.VERSION_CODES.P])
-@RunWith(RobolectricTestRunner::class)
-class AppModuleTest : ModuleTest() {
-
- @Test
- fun `check all definitions from appModule`() {
- startKoin {
- androidContext(application)
- modules(appModule)
- }.checkModules()
- }
-}
diff --git a/app/src/test/kotlin/com/mohsenoid/rickandmorty/injection/DataModuleTest.kt b/app/src/test/kotlin/com/mohsenoid/rickandmorty/injection/DataModuleTest.kt
deleted file mode 100644
index 20151be..0000000
--- a/app/src/test/kotlin/com/mohsenoid/rickandmorty/injection/DataModuleTest.kt
+++ /dev/null
@@ -1,35 +0,0 @@
-package com.mohsenoid.rickandmorty.injection
-
-import android.os.Build
-import com.mohsenoid.rickandmorty.appModule
-import com.mohsenoid.rickandmorty.data.dataModule
-import com.mohsenoid.rickandmorty.util.KoinQualifiersNames
-import org.junit.Test
-import org.junit.runner.RunWith
-import org.koin.android.ext.koin.androidContext
-import org.koin.core.context.startKoin
-import org.koin.test.check.checkModules
-import org.robolectric.RobolectricTestRunner
-import org.robolectric.annotation.Config
-
-@Config(sdk = [Build.VERSION_CODES.P])
-@RunWith(RobolectricTestRunner::class)
-class DataModuleTest : ModuleTest() {
-
- @Test
- fun `check all definitions from dataModule`() {
- startKoin {
- val appProperties: Map = mapOf(
- KoinQualifiersNames.BASE_URL to BASE_URL,
- )
- properties(appProperties)
-
- androidContext(application)
- modules(appModule + dataModule)
- }.checkModules()
- }
-
- companion object {
- private const val BASE_URL = "https://test.com/api/"
- }
-}
diff --git a/app/src/test/kotlin/com/mohsenoid/rickandmorty/injection/ModuleTest.kt b/app/src/test/kotlin/com/mohsenoid/rickandmorty/injection/ModuleTest.kt
deleted file mode 100644
index e97468b..0000000
--- a/app/src/test/kotlin/com/mohsenoid/rickandmorty/injection/ModuleTest.kt
+++ /dev/null
@@ -1,30 +0,0 @@
-package com.mohsenoid.rickandmorty.injection
-
-import android.app.Application
-import android.os.Build
-import androidx.test.core.app.ApplicationProvider
-import org.junit.After
-import org.junit.Before
-import org.junit.runner.RunWith
-import org.koin.core.component.KoinComponent
-import org.koin.core.context.stopKoin
-import org.robolectric.RobolectricTestRunner
-import org.robolectric.annotation.Config
-
-@Config(sdk = [Build.VERSION_CODES.P])
-@RunWith(RobolectricTestRunner::class)
-abstract class ModuleTest : KoinComponent {
-
- internal lateinit var application: Application
-
- @Before
- fun setUp() {
- application = ApplicationProvider.getApplicationContext()
- stopKoin()
- }
-
- @After
- fun tearDown() {
- stopKoin()
- }
-}
diff --git a/app/src/test/kotlin/com/mohsenoid/rickandmorty/test/ApiFactory.kt b/app/src/test/kotlin/com/mohsenoid/rickandmorty/test/ApiFactory.kt
deleted file mode 100644
index ff94490..0000000
--- a/app/src/test/kotlin/com/mohsenoid/rickandmorty/test/ApiFactory.kt
+++ /dev/null
@@ -1,149 +0,0 @@
-package com.mohsenoid.rickandmorty.test
-
-import com.mohsenoid.rickandmorty.data.api.model.ApiCharacter
-import com.mohsenoid.rickandmorty.data.api.model.ApiEpisode
-import com.mohsenoid.rickandmorty.data.api.model.ApiEpisodes
-import com.mohsenoid.rickandmorty.data.api.model.ApiInfo
-import com.mohsenoid.rickandmorty.data.api.model.ApiLocation
-import com.mohsenoid.rickandmorty.data.api.model.ApiOrigin
-
-object ApiFactory {
-
- object Episode {
- private const val VALUE_COUNT: Int = 1
- private const val VALUE_PAGE: Int = 1
- private const val VALUE_NEXT: String = ""
- private const val VALUE_PREV: String = ""
-
- private const val VALUE_ID: Int = 1
- private const val VALUE_NAME: String = "Pilot"
- private const val VALUE_AIR_DATE: String = "December 2, 2013"
- private const val VALUE_EPISODE: String = "S01E01"
- private const val VALUE_CHARACTER: String = "https://rickandmortyapi.com/api/character/1"
- private const val VALUE_URL: String = "https://rickandmortyapi.com/api/episode/1"
- private const val VALUE_CREATED: String = "2017-11-10T12:56:33.798Z"
-
- const val EPISODES_JSON: String = """{
- "info": {
- "count": $VALUE_COUNT,
- "pages": $VALUE_PAGE,
- "next": $VALUE_NEXT,
- "prev": $VALUE_PREV
- },
- "results": [
- {
- "id": $VALUE_ID,
- "name": $VALUE_NAME,
- "air_date": $VALUE_AIR_DATE,
- "episode": $VALUE_EPISODE,
- "characters": [
- $VALUE_CHARACTER
- ],
- "url": $VALUE_URL,
- "created": $VALUE_CREATED
- }
- ]
-}"""
-
- internal fun makeApiEpisodes(): ApiEpisodes {
- val info = ApiInfo(
- count = VALUE_COUNT,
- pages = VALUE_PAGE,
- next = VALUE_NEXT,
- prev = VALUE_PREV,
- )
-
- val episode = ApiEpisode(
- id = VALUE_ID,
- name = VALUE_NAME,
- airDate = VALUE_AIR_DATE,
- episode = VALUE_EPISODE,
- characters = arrayListOf(VALUE_CHARACTER),
- url = VALUE_URL,
- created = VALUE_CREATED,
- )
-
- return ApiEpisodes(
- info = info,
- results = arrayListOf(episode),
- )
- }
- }
-
- object Characters {
- const val CHARACTERS_JSON: String = "[\n ${CharacterDetails.CHARACTER_DETAILS_JSON}\n]"
-
- internal fun makeCharacters(): List {
- val character: ApiCharacter = CharacterDetails.makeCharacter()
- return arrayListOf(character)
- }
- }
-
- object CharacterDetails {
- private const val VALUE_ID: Int = 1
- private const val VALUE_NAME: String = "Rick Sanchez"
- private const val VALUE_STATUS: String = "Alive"
- private const val VALUE_SPECIES: String = "Human"
- private const val VALUE_TYPE: String = ""
- private const val VALUE_GENDER: String = "Male"
- private const val VALUE_ORIGIN_NAME: String = "Earth (C-137)"
- private const val VALUE_ORIGIN_URL: String = "https://rickandmortyapi.com/api/location/1"
- private const val VALUE_LOCATION_NAME: String = "Earth (Replacement Dimension)"
- private const val VALUE_LOCATION_URL: String = "https://rickandmortyapi.com/api/location/20"
- private const val VALUE_IMAGE: String =
- "https://rickandmortyapi.com/api/character/avatar/1.jpeg"
- private const val VALUE_EPISODE: String = "https://rickandmortyapi.com/api/episode/1"
- private const val VALUE_URL: String = "https://rickandmortyapi.com/api/character/1"
- private const val VALUE_CREATED: String = "2017-11-04T18:48:46.250Z"
-
- const val CHARACTER_DETAILS_JSON: String = """{
- "id": $VALUE_ID,
- "name": "$VALUE_NAME",
- "status": "$VALUE_STATUS",
- "species": "$VALUE_SPECIES",
- "type": "$VALUE_TYPE",
- "gender": "$VALUE_GENDER",
- "origin": {
- "name": "$VALUE_ORIGIN_NAME",
- "url": "$VALUE_ORIGIN_URL"
- },
- "location": {
- "name": "$VALUE_LOCATION_NAME",
- "url": "$VALUE_LOCATION_URL"
- },
- "image": "$VALUE_IMAGE",
- "episode": [
- "$VALUE_EPISODE"
- ],
- "url": "$VALUE_URL",
- "created": "$VALUE_CREATED"
-}"""
-
- internal fun makeCharacter(): ApiCharacter {
- val origin = ApiOrigin(
- name = VALUE_ORIGIN_NAME,
- url = VALUE_ORIGIN_URL,
- )
-
- val location = ApiLocation(
- name = VALUE_LOCATION_NAME,
- url = VALUE_LOCATION_URL,
- )
-
- return ApiCharacter(
- id = VALUE_ID,
- name = VALUE_NAME,
- status = VALUE_STATUS,
- species = VALUE_SPECIES,
- type = VALUE_TYPE,
- gender = VALUE_GENDER,
- origin = origin,
- location = location,
- image = VALUE_IMAGE,
- episodes = arrayListOf(VALUE_EPISODE),
- url = VALUE_URL,
- created = VALUE_CREATED,
- )
- }
- }
-}
diff --git a/app/src/test/kotlin/com/mohsenoid/rickandmorty/test/CharacterDataFactory.kt b/app/src/test/kotlin/com/mohsenoid/rickandmorty/test/CharacterDataFactory.kt
deleted file mode 100644
index 22bdd5d..0000000
--- a/app/src/test/kotlin/com/mohsenoid/rickandmorty/test/CharacterDataFactory.kt
+++ /dev/null
@@ -1,97 +0,0 @@
-package com.mohsenoid.rickandmorty.test
-
-import com.mohsenoid.rickandmorty.data.api.model.ApiCharacter
-import com.mohsenoid.rickandmorty.data.db.model.DbCharacter
-import com.mohsenoid.rickandmorty.domain.model.ModelCharacter
-
-object CharacterDataFactory {
-
- internal fun makeDbCharacter(
- characterId: Int = DataFactory.randomInt(),
- status: String = DataFactory.randomString(),
- isAlive: Boolean = DataFactory.randomBoolean(),
- isKilledByUser: Boolean = false,
- ): DbCharacter =
- DbCharacter(
- id = characterId,
- name = DataFactory.randomString(),
- status = status,
- isAlive = isAlive,
- species = DataFactory.randomString(),
- type = DataFactory.randomString(),
- gender = DataFactory.randomString(),
- origin = OriginDataFactory.makeDbOrigin(),
- location = LocationDataFactory.makeDbLocation(),
- image = DataFactory.randomString(),
- episodeIds = DataFactory.randomIntList(count = 5),
- url = DataFactory.randomString(),
- created = DataFactory.randomString(),
- isKilledByUser = isKilledByUser,
- )
-
- internal fun makeDbCharacters(count: Int): List {
- val characters: MutableList = ArrayList()
- repeat(count) {
- val character: DbCharacter = makeDbCharacter()
- characters.add(character)
- }
- return characters
- }
-
- internal fun makeApiCharacter(characterId: Int = DataFactory.randomInt()): ApiCharacter =
- ApiCharacter(
- id = characterId,
- name = DataFactory.randomString(),
- status = DataFactory.randomString(),
- species = DataFactory.randomString(),
- type = DataFactory.randomString(),
- gender = DataFactory.randomString(),
- origin = OriginDataFactory.makeApiOrigin(),
- location = LocationDataFactory.makeApiLocation(),
- image = DataFactory.randomString(),
- episodes = DataFactory.randomIntList(count = 5)
- .map { "${DataFactory.randomString()}/$it" },
- url = DataFactory.randomString(),
- created = DataFactory.randomString(),
- )
-
- internal fun makeApiCharacters(count: Int): List {
- val characters: MutableList = ArrayList()
- repeat(count) {
- val character: ApiCharacter = makeApiCharacter()
- characters.add(character)
- }
- return characters
- }
-
- internal fun makeCharacter(
- id: Int = DataFactory.randomInt(),
- status: String = DataFactory.randomString(),
- isAlive: Boolean = DataFactory.randomBoolean(),
- isKilledByUser: Boolean = false,
- ): ModelCharacter = ModelCharacter(
- id = id,
- name = DataFactory.randomString(),
- status = status,
- isAlive = isAlive,
- species = DataFactory.randomString(),
- type = DataFactory.randomString(),
- gender = DataFactory.randomString(),
- origin = OriginDataFactory.makeOrigin(),
- location = LocationDataFactory.makeLocation(),
- imageUrl = DataFactory.randomString(),
- episodeIds = DataFactory.randomIntList(count = 5),
- url = DataFactory.randomString(),
- created = DataFactory.randomString(),
- isKilledByUser = isKilledByUser,
- )
-
- internal fun makeCharacters(count: Int): List {
- val characters: MutableList = ArrayList()
- repeat(count) {
- val character: ModelCharacter = makeCharacter()
- characters.add(character)
- }
- return characters
- }
-}
diff --git a/app/src/test/kotlin/com/mohsenoid/rickandmorty/test/DataFactory.kt b/app/src/test/kotlin/com/mohsenoid/rickandmorty/test/DataFactory.kt
deleted file mode 100644
index 308e096..0000000
--- a/app/src/test/kotlin/com/mohsenoid/rickandmorty/test/DataFactory.kt
+++ /dev/null
@@ -1,35 +0,0 @@
-package com.mohsenoid.rickandmorty.test
-
-import java.util.ArrayList
-import java.util.UUID
-import java.util.concurrent.ThreadLocalRandom
-
-object DataFactory {
-
- fun randomString(): String = UUID.randomUUID().toString()
-
- fun randomStringList(count: Int): List {
- val list: MutableList = ArrayList()
-
- repeat(count) {
- list.add(randomString())
- }
-
- return list
- }
-
- fun randomInt(min: Int = Int.MIN_VALUE, max: Int = Int.MAX_VALUE): Int =
- ThreadLocalRandom.current().nextInt(min, max)
-
- fun randomIntList(count: Int, min: Int = Int.MIN_VALUE, max: Int = Int.MAX_VALUE): List {
- val list: MutableList = ArrayList()
-
- repeat(count) {
- list.add(randomInt(min, max))
- }
-
- return list
- }
-
- fun randomBoolean(): Boolean = Math.random() < 0.5
-}
diff --git a/app/src/test/kotlin/com/mohsenoid/rickandmorty/test/EpisodeDataFactory.kt b/app/src/test/kotlin/com/mohsenoid/rickandmorty/test/EpisodeDataFactory.kt
deleted file mode 100644
index ecb6be9..0000000
--- a/app/src/test/kotlin/com/mohsenoid/rickandmorty/test/EpisodeDataFactory.kt
+++ /dev/null
@@ -1,69 +0,0 @@
-package com.mohsenoid.rickandmorty.test
-
-import com.mohsenoid.rickandmorty.data.api.model.ApiEpisode
-import com.mohsenoid.rickandmorty.data.db.model.DbEpisode
-import com.mohsenoid.rickandmorty.domain.model.ModelEpisode
-
-object EpisodeDataFactory {
-
- internal fun makeDbEpisode(episodeId: Int = DataFactory.randomInt()): DbEpisode =
- DbEpisode(
- id = episodeId,
- name = DataFactory.randomString(),
- airDate = DataFactory.randomString(),
- episode = DataFactory.randomString(),
- characterIds = DataFactory.randomIntList(count = 5),
- url = DataFactory.randomString(),
- created = DataFactory.randomString(),
- )
-
- internal fun makeDbEpisodes(count: Int): List {
- val entityEpisodes: MutableList = ArrayList()
- repeat(count) {
- val entityEpisode: DbEpisode = makeDbEpisode()
- entityEpisodes.add(entityEpisode)
- }
- return entityEpisodes
- }
-
- internal fun makeApiEpisode(episodeId: Int = DataFactory.randomInt()): ApiEpisode =
- ApiEpisode(
- id = episodeId,
- name = DataFactory.randomString(),
- airDate = DataFactory.randomString(),
- episode = DataFactory.randomString(),
- characters = DataFactory.randomIntList(count = 5)
- .map { "${DataFactory.randomString()}/$it" },
- url = DataFactory.randomString(),
- created = DataFactory.randomString(),
- )
-
- internal fun makeApiEpisodes(count: Int): List {
- val episodes: MutableList = ArrayList()
- repeat(count) {
- val episode: ApiEpisode = makeApiEpisode()
- episodes.add(episode)
- }
- return episodes
- }
-
- internal fun makeEpisode(episodeId: Int = DataFactory.randomInt()): ModelEpisode =
- ModelEpisode(
- id = episodeId,
- name = DataFactory.randomString(),
- airDate = DataFactory.randomString(),
- episode = DataFactory.randomString(),
- characterIds = DataFactory.randomIntList(count = 5),
- url = DataFactory.randomString(),
- created = DataFactory.randomString(),
- )
-
- internal fun makeEpisodes(count: Int): List {
- val episodes: MutableList = ArrayList()
- repeat(count) {
- val episode: ModelEpisode = makeEpisode()
- episodes.add(episode)
- }
- return episodes
- }
-}
diff --git a/app/src/test/kotlin/com/mohsenoid/rickandmorty/test/LocationDataFactory.kt b/app/src/test/kotlin/com/mohsenoid/rickandmorty/test/LocationDataFactory.kt
deleted file mode 100644
index 0e13a06..0000000
--- a/app/src/test/kotlin/com/mohsenoid/rickandmorty/test/LocationDataFactory.kt
+++ /dev/null
@@ -1,26 +0,0 @@
-package com.mohsenoid.rickandmorty.test
-
-import com.mohsenoid.rickandmorty.data.api.model.ApiLocation
-import com.mohsenoid.rickandmorty.data.db.model.DbLocation
-import com.mohsenoid.rickandmorty.domain.model.ModelLocation
-
-object LocationDataFactory {
-
- internal fun makeDbLocation(): DbLocation =
- DbLocation(
- name = DataFactory.randomString(),
- url = DataFactory.randomString(),
- )
-
- internal fun makeApiLocation(): ApiLocation =
- ApiLocation(
- name = DataFactory.randomString(),
- url = DataFactory.randomString(),
- )
-
- internal fun makeLocation(): ModelLocation =
- ModelLocation(
- name = DataFactory.randomString(),
- url = DataFactory.randomString(),
- )
-}
diff --git a/app/src/test/kotlin/com/mohsenoid/rickandmorty/test/OriginDataFactory.kt b/app/src/test/kotlin/com/mohsenoid/rickandmorty/test/OriginDataFactory.kt
deleted file mode 100644
index 2285709..0000000
--- a/app/src/test/kotlin/com/mohsenoid/rickandmorty/test/OriginDataFactory.kt
+++ /dev/null
@@ -1,26 +0,0 @@
-package com.mohsenoid.rickandmorty.test
-
-import com.mohsenoid.rickandmorty.data.api.model.ApiOrigin
-import com.mohsenoid.rickandmorty.data.db.model.DbOrigin
-import com.mohsenoid.rickandmorty.domain.model.ModelOrigin
-
-object OriginDataFactory {
-
- internal fun makeDbOrigin(): DbOrigin =
- DbOrigin(
- name = DataFactory.randomString(),
- url = DataFactory.randomString(),
- )
-
- internal fun makeApiOrigin(): ApiOrigin =
- ApiOrigin(
- name = DataFactory.randomString(),
- url = DataFactory.randomString(),
- )
-
- internal fun makeOrigin(): ModelOrigin =
- ModelOrigin(
- name = DataFactory.randomString(),
- url = DataFactory.randomString(),
- )
-}
diff --git a/build.gradle.kts b/build.gradle.kts
index 8e0a4f6..0a7f377 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -1,26 +1,6 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
-
-buildscript {
- repositories {
- google()
- mavenCentral()
- }
-
- dependencies {
- classpath("com.android.tools.build:gradle:7.0.3")
- classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.5.31")
- classpath("androidx.navigation:navigation-safe-args-gradle-plugin:2.3.5")
- }
-}
-
-allprojects {
- repositories {
- google()
- mavenCentral()
- }
-}
-
-
-task("clean") {
- delete = setOf(rootProject.buildDir)
-}
+plugins {
+ alias(libs.plugins.android.application) apply false
+ alias(libs.plugins.kotlin.android) apply false
+ alias(libs.plugins.kotlin.ksp) apply false
+}
\ No newline at end of file
diff --git a/gradle.properties b/gradle.properties
index aa522a6..20e2a01 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -6,16 +6,18 @@
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
-org.gradle.jvmargs=-Xmx1536m
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
-# This option should only be used with decoupled projects. More details, visit
-# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
+# This option should only be used with decoupled projects. For more details, visit
+# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
-# Automatically convert third-party libraries to use AndroidX
-android.enableJetifier=true
-# DataNetworkModule.kt: (22, 2): This class can only be used with the compiler argument '-Xopt-in=kotlin.RequiresOptIn'
--Xopt-in=kotlin.RequiresOptIn
+# Kotlin code style for this project: "official" or "obsolete":
+kotlin.code.style=official
+# Enables namespacing of each library's R class so that its R class includes only the
+# resources declared in the library itself and none from the library's dependencies,
+# thereby reducing the size of the R class for that library
+android.nonTransitiveRClass=true
\ No newline at end of file
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
new file mode 100644
index 0000000..ec34a2a
--- /dev/null
+++ b/gradle/libs.versions.toml
@@ -0,0 +1,56 @@
+[versions]
+agp = "8.5.0"
+kotlin = "1.9.0"
+kotlinKsp = "1.9.0-1.0.13"
+coreKtx = "1.13.1"
+junit = "4.13.2"
+junitVersion = "1.1.5"
+espressoCore = "3.5.1"
+lifecycleRuntimeKtx = "2.8.2"
+activityCompose = "1.9.0"
+composeBom = "2024.06.00"
+navigationCompose = "2.7.7"
+koinBom = "3.5.6"
+retrofit = "2.11.0"
+okhttp = "4.12.0"
+room = "2.6.1"
+coil = "2.4.0"
+
+[libraries]
+androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
+junit = { group = "junit", name = "junit", version.ref = "junit" }
+androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
+androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
+androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
+androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" }
+androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
+androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
+androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
+androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
+androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
+androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
+androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
+androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
+androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
+androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
+androidx-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
+androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
+androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
+androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
+koin-bom = { module = "io.insert-koin:koin-bom", version.ref = "koinBom" }
+koin-core = { module = "io.insert-koin:koin-core" }
+koin-android = { module = "io.insert-koin:koin-android" }
+koin-androidx-compose = { module = "io.insert-koin:koin-androidx-compose" }
+koin-test = { module = "io.insert-koin:koin-test" }
+retrofit-retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
+retrofit-converter-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" }
+okhttp3-logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
+coil = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
+
+[bundles]
+retrofit = ["retrofit-retrofit", "retrofit-converter-gson"]
+
+[plugins]
+android-application = { id = "com.android.application", version.ref = "agp" }
+kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
+kotlin-ksp = { id = "com.google.devtools.ksp", version.ref = "kotlinKsp" }
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 29e4134..fd1a85c 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,5 +1,6 @@
+#Wed Jun 12 06:35:22 CEST 2024
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-7.0.2-all.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
diff --git a/settings.gradle b/settings.gradle
deleted file mode 100644
index 5eceb93..0000000
--- a/settings.gradle
+++ /dev/null
@@ -1,2 +0,0 @@
-include ':app'
-rootProject.name = 'Rick and Morty'
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 0000000..6253634
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,23 @@
+pluginManagement {
+ repositories {
+ google {
+ content {
+ includeGroupByRegex("com\\.android.*")
+ includeGroupByRegex("com\\.google.*")
+ includeGroupByRegex("androidx.*")
+ }
+ }
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "Rick and Morty"
+include(":app")