├── .gitignore
├── README.md
├── build.gradle.kts
├── darktheme
├── README.md
├── app
│ ├── build.gradle.kts
│ ├── proguard-rules.pro
│ └── src
│ │ └── main
│ │ ├── AndroidManifest.xml
│ │ ├── java
│ │ └── com
│ │ │ └── kfaraj
│ │ │ └── samples
│ │ │ └── darktheme
│ │ │ ├── MainActivity.kt
│ │ │ ├── MainApplication.kt
│ │ │ ├── SettingsActivity.kt
│ │ │ ├── data
│ │ │ ├── Settings.kt
│ │ │ └── SettingsRepository.kt
│ │ │ ├── domain
│ │ │ └── GetNightModeUseCase.kt
│ │ │ ├── ui
│ │ │ ├── MainFragment.kt
│ │ │ └── SettingsFragment.kt
│ │ │ └── util
│ │ │ └── EdgeToEdge.kt
│ │ └── res
│ │ ├── drawable
│ │ ├── ic_add_black_24dp.xml
│ │ ├── ic_arrow_back_24dp.xml
│ │ ├── ic_launcher_background.xml
│ │ ├── ic_launcher_foreground.xml
│ │ ├── ic_launcher_monochrome.xml
│ │ └── ic_settings_24dp.xml
│ │ ├── layout
│ │ ├── activity_main.xml
│ │ ├── activity_settings.xml
│ │ └── fragment_main.xml
│ │ ├── menu
│ │ └── activity_main.xml
│ │ ├── mipmap
│ │ ├── ic_launcher.xml
│ │ └── ic_launcher_round.xml
│ │ ├── values-night
│ │ └── themes.xml
│ │ ├── values
│ │ ├── colors.xml
│ │ ├── dimens.xml
│ │ ├── strings.xml
│ │ └── themes.xml
│ │ └── xml
│ │ └── preferences.xml
└── screenshots
│ └── darktheme.webp
├── gradle.properties
├── gradle
├── libs.versions.toml
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
├── pokedex
├── README.md
├── app
│ ├── build.gradle.kts
│ ├── proguard-rules.pro
│ ├── schemas
│ │ └── com.kfaraj.samples.pokedex.data.local.ApplicationDatabase
│ │ │ └── 1.json
│ └── src
│ │ ├── androidTest
│ │ └── java
│ │ │ └── com
│ │ │ └── kfaraj
│ │ │ └── samples
│ │ │ └── pokedex
│ │ │ └── MainActivityTest.kt
│ │ ├── main
│ │ ├── AndroidManifest.xml
│ │ ├── java
│ │ │ └── com
│ │ │ │ └── kfaraj
│ │ │ │ └── samples
│ │ │ │ └── pokedex
│ │ │ │ ├── MainActivity.kt
│ │ │ │ ├── MainApplication.kt
│ │ │ │ ├── data
│ │ │ │ ├── Pokemon.kt
│ │ │ │ ├── PokemonsRemoteMediator.kt
│ │ │ │ ├── PokemonsRepository.kt
│ │ │ │ ├── local
│ │ │ │ │ ├── ApplicationDatabase.kt
│ │ │ │ │ ├── PokemonDao.kt
│ │ │ │ │ ├── PokemonEntity.kt
│ │ │ │ │ └── PokemonsLocalDataSource.kt
│ │ │ │ └── remote
│ │ │ │ │ ├── DefaultPokeApiService.kt
│ │ │ │ │ ├── NamedApiResource.kt
│ │ │ │ │ ├── NamedApiResourceList.kt
│ │ │ │ │ ├── PokeApiService.kt
│ │ │ │ │ └── PokemonsRemoteDataSource.kt
│ │ │ │ ├── di
│ │ │ │ ├── CoroutinesModule.kt
│ │ │ │ ├── DatabaseModule.kt
│ │ │ │ └── NetworkModule.kt
│ │ │ │ ├── domain
│ │ │ │ ├── FormatIdUseCase.kt
│ │ │ │ ├── FormatNameUseCase.kt
│ │ │ │ └── GetSpriteUseCase.kt
│ │ │ │ ├── ui
│ │ │ │ ├── PokedexFragment.kt
│ │ │ │ ├── PokedexItemAdapter.kt
│ │ │ │ ├── PokedexItemCallback.kt
│ │ │ │ ├── PokedexItemUiState.kt
│ │ │ │ ├── PokedexItemViewHolder.kt
│ │ │ │ ├── PokedexNavigation.kt
│ │ │ │ ├── PokedexViewModel.kt
│ │ │ │ ├── PokemonFragment.kt
│ │ │ │ ├── PokemonNavigation.kt
│ │ │ │ ├── PokemonUiState.kt
│ │ │ │ └── PokemonViewModel.kt
│ │ │ │ └── util
│ │ │ │ └── EdgeToEdge.kt
│ │ └── res
│ │ │ ├── drawable
│ │ │ ├── ic_launcher_background.xml
│ │ │ ├── ic_launcher_foreground.xml
│ │ │ └── ic_launcher_monochrome.xml
│ │ │ ├── layout
│ │ │ ├── activity_main.xml
│ │ │ ├── fragment_pokedex.xml
│ │ │ ├── fragment_pokemon.xml
│ │ │ └── item_card.xml
│ │ │ ├── mipmap
│ │ │ ├── ic_launcher.xml
│ │ │ └── ic_launcher_round.xml
│ │ │ ├── values-night
│ │ │ └── themes.xml
│ │ │ └── values
│ │ │ ├── colors.xml
│ │ │ ├── dimens.xml
│ │ │ ├── strings.xml
│ │ │ └── themes.xml
│ │ └── test
│ │ └── java
│ │ └── com
│ │ └── kfaraj
│ │ └── samples
│ │ └── pokedex
│ │ ├── MainActivityTest.kt
│ │ ├── data
│ │ ├── PokemonsRemoteMediatorTest.kt
│ │ ├── PokemonsRepositoryTest.kt
│ │ ├── local
│ │ │ └── PokemonsLocalDataSourceTest.kt
│ │ └── remote
│ │ │ └── PokemonsRemoteDataSourceTest.kt
│ │ ├── domain
│ │ ├── FormatIdUseCaseTest.kt
│ │ ├── FormatNameUseCaseTest.kt
│ │ └── GetSpriteUseCaseTest.kt
│ │ ├── testutils
│ │ └── MainDispatcherRule.kt
│ │ └── ui
│ │ ├── PokedexViewModelTest.kt
│ │ └── PokemonViewModelTest.kt
└── screenshots
│ └── pokedex.webp
└── settings.gradle.kts
/.gitignore:
--------------------------------------------------------------------------------
1 | # macOS
2 | .DS_Store
3 |
4 | # Windows NT
5 | Thumbs.db
6 |
7 | # Android Studio
8 | *.iml
9 | .gradle/
10 | .idea/
11 | .kotlin/
12 | build/
13 | local.properties
14 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Samples
2 |
3 | This project is a collection of samples for Android.
4 |
5 | ## [Dark theme](darktheme)
6 |
7 | This sample demonstrates how to implement a
8 | [dark theme](https://developer.android.com/guide/topics/ui/look-and-feel/darktheme).
9 |
10 | ## [Pokédex](pokedex)
11 |
12 | This sample demonstrates best practices for
13 | [Modern Android Development](https://developer.android.com/modern-android-development).
14 |
--------------------------------------------------------------------------------
/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.com.android.application) apply false
3 | alias(libs.plugins.org.jetbrains.kotlin.android) apply false
4 | alias(libs.plugins.org.jetbrains.kotlin.plugin.serialization) apply false
5 | alias(libs.plugins.com.google.devtools.ksp) apply false
6 | alias(libs.plugins.com.google.dagger.hilt.android) apply false
7 | }
8 |
--------------------------------------------------------------------------------
/darktheme/README.md:
--------------------------------------------------------------------------------
1 | # Dark theme
2 |
3 | This sample demonstrates how to implement a
4 | [dark theme](https://developer.android.com/guide/topics/ui/look-and-feel/darktheme).
5 |
6 | ## Screenshots
7 |
8 | 
9 |
--------------------------------------------------------------------------------
/darktheme/app/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.com.android.application)
3 | alias(libs.plugins.org.jetbrains.kotlin.android)
4 | }
5 |
6 | android {
7 | namespace = "com.kfaraj.samples.darktheme"
8 | compileSdk = 35
9 | defaultConfig {
10 | applicationId = "com.kfaraj.samples.darktheme"
11 | minSdk = 26
12 | targetSdk = 35
13 | versionCode = 1
14 | versionName = "0.1.0"
15 | vectorDrawables {
16 | useSupportLibrary = true
17 | }
18 | }
19 | signingConfigs {
20 | register("release") {
21 | val storePath = properties["signingStorePath"] as String?
22 | storeFile = if (storePath != null) file(storePath) else null
23 | storePassword = properties["signingStorePassword"] as String?
24 | keyAlias = properties["signingKeyAlias"] as String?
25 | keyPassword = properties["signingKeyPassword"] as String?
26 | }
27 | }
28 | buildTypes {
29 | named("release") {
30 | isMinifyEnabled = true
31 | isShrinkResources = true
32 | proguardFiles(
33 | getDefaultProguardFile("proguard-android-optimize.txt"),
34 | "proguard-rules.pro"
35 | )
36 | signingConfig = signingConfigs.getByName("release")
37 | }
38 | }
39 | }
40 |
41 | kotlin {
42 | jvmToolchain(21)
43 | }
44 |
45 | dependencies {
46 | implementation(libs.androidx.activity)
47 | implementation(libs.androidx.appcompat)
48 | implementation(libs.androidx.coordinatorlayout)
49 | implementation(libs.androidx.core.ktx)
50 | implementation(libs.androidx.fragment.ktx)
51 | implementation(libs.androidx.preference.ktx)
52 | implementation(libs.com.google.android.material)
53 | }
54 |
--------------------------------------------------------------------------------
/darktheme/app/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | -keepattributes SourceFile,LineNumberTable
2 | -renamesourcefileattribute SourceFile
3 |
--------------------------------------------------------------------------------
/darktheme/app/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
15 |
16 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
28 |
29 |
30 |
31 |
32 |
--------------------------------------------------------------------------------
/darktheme/app/src/main/java/com/kfaraj/samples/darktheme/MainActivity.kt:
--------------------------------------------------------------------------------
1 | package com.kfaraj.samples.darktheme
2 |
3 | import android.content.Intent
4 | import android.os.Bundle
5 | import android.view.MenuItem
6 | import android.view.View
7 | import android.view.View.OnClickListener
8 | import androidx.activity.enableEdgeToEdge
9 | import androidx.appcompat.app.AppCompatActivity
10 | import androidx.appcompat.widget.Toolbar.OnMenuItemClickListener
11 | import androidx.core.app.ActivityCompat
12 | import androidx.core.view.WindowInsetsCompat
13 | import com.google.android.material.appbar.AppBarLayout
14 | import com.google.android.material.appbar.MaterialToolbar
15 | import com.google.android.material.floatingactionbutton.FloatingActionButton
16 | import com.google.android.material.snackbar.Snackbar
17 | import com.kfaraj.samples.darktheme.util.applyWindowInsetsPadding
18 |
19 | /**
20 | * Demonstrates how to implement a dark theme.
21 | */
22 | class MainActivity : AppCompatActivity(R.layout.activity_main),
23 | OnMenuItemClickListener,
24 | OnClickListener {
25 |
26 | override fun onCreate(savedInstanceState: Bundle?) {
27 | enableEdgeToEdge()
28 | super.onCreate(savedInstanceState)
29 | val toolbar = ActivityCompat.requireViewById(this, R.id.toolbar)
30 | val fab = ActivityCompat.requireViewById(this, R.id.fab)
31 | toolbar.setOnMenuItemClickListener(this)
32 | (toolbar.parent as AppBarLayout).applyWindowInsetsPadding(
33 | WindowInsetsCompat.Type.systemBars() or
34 | WindowInsetsCompat.Type.displayCutout(),
35 | applyLeft = true,
36 | applyTop = true,
37 | applyRight = true
38 | )
39 | fab.setOnClickListener(this)
40 | }
41 |
42 | override fun onMenuItemClick(item: MenuItem): Boolean {
43 | return if (item.itemId == R.id.settings) {
44 | val intent = Intent(this, SettingsActivity::class.java)
45 | startActivity(intent)
46 | true
47 | } else {
48 | false
49 | }
50 | }
51 |
52 | override fun onClick(v: View) {
53 | Snackbar.make(v, R.string.lorem_ipsum, Snackbar.LENGTH_SHORT)
54 | .setAnchorView(R.id.fab)
55 | .show()
56 | }
57 |
58 | }
59 |
--------------------------------------------------------------------------------
/darktheme/app/src/main/java/com/kfaraj/samples/darktheme/MainApplication.kt:
--------------------------------------------------------------------------------
1 | package com.kfaraj.samples.darktheme
2 |
3 | import android.app.Application
4 | import androidx.appcompat.app.AppCompatDelegate
5 | import com.kfaraj.samples.darktheme.data.SettingsRepository
6 | import com.kfaraj.samples.darktheme.domain.GetNightModeUseCase
7 |
8 | /**
9 | * Demonstrates how to implement a dark theme.
10 | */
11 | class MainApplication : Application() {
12 |
13 | private lateinit var getNightModeUseCase: GetNightModeUseCase
14 |
15 | override fun onCreate() {
16 | super.onCreate()
17 | val settingsRepository = SettingsRepository.getInstance(this)
18 | getNightModeUseCase = GetNightModeUseCase(settingsRepository)
19 | val nightMode = getNightModeUseCase()
20 | AppCompatDelegate.setDefaultNightMode(nightMode)
21 | }
22 |
23 | }
24 |
--------------------------------------------------------------------------------
/darktheme/app/src/main/java/com/kfaraj/samples/darktheme/SettingsActivity.kt:
--------------------------------------------------------------------------------
1 | package com.kfaraj.samples.darktheme
2 |
3 | import android.os.Bundle
4 | import android.view.View
5 | import android.view.View.OnClickListener
6 | import androidx.activity.enableEdgeToEdge
7 | import androidx.appcompat.app.AppCompatActivity
8 | import androidx.core.app.ActivityCompat
9 | import androidx.core.view.WindowInsetsCompat
10 | import com.google.android.material.appbar.AppBarLayout
11 | import com.google.android.material.appbar.MaterialToolbar
12 | import com.kfaraj.samples.darktheme.util.applyWindowInsetsPadding
13 |
14 | /**
15 | * Contains settings.
16 | */
17 | class SettingsActivity : AppCompatActivity(R.layout.activity_settings),
18 | OnClickListener {
19 |
20 | override fun onCreate(savedInstanceState: Bundle?) {
21 | enableEdgeToEdge()
22 | super.onCreate(savedInstanceState)
23 | val toolbar = ActivityCompat.requireViewById(this, R.id.toolbar)
24 | toolbar.setNavigationOnClickListener(this)
25 | (toolbar.parent as AppBarLayout).applyWindowInsetsPadding(
26 | WindowInsetsCompat.Type.systemBars() or
27 | WindowInsetsCompat.Type.displayCutout(),
28 | applyLeft = true,
29 | applyTop = true,
30 | applyRight = true
31 | )
32 | }
33 |
34 | override fun onClick(v: View) {
35 | onNavigateUp()
36 | }
37 |
38 | }
39 |
--------------------------------------------------------------------------------
/darktheme/app/src/main/java/com/kfaraj/samples/darktheme/data/Settings.kt:
--------------------------------------------------------------------------------
1 | package com.kfaraj.samples.darktheme.data
2 |
3 | /**
4 | * Contains settings.
5 | */
6 | object Settings {
7 |
8 | /**
9 | * The theme.
10 | */
11 | const val THEME = "theme"
12 |
13 | /**
14 | * The theme is light.
15 | */
16 | const val THEME_LIGHT = "light"
17 |
18 | /**
19 | * The theme is dark.
20 | */
21 | const val THEME_DARK = "dark"
22 |
23 | /**
24 | * The theme is system default.
25 | */
26 | const val THEME_SYSTEM_DEFAULT = "system_default"
27 |
28 | }
29 |
--------------------------------------------------------------------------------
/darktheme/app/src/main/java/com/kfaraj/samples/darktheme/data/SettingsRepository.kt:
--------------------------------------------------------------------------------
1 | package com.kfaraj.samples.darktheme.data
2 |
3 | import android.content.Context
4 | import androidx.preference.PreferenceManager
5 | import com.kfaraj.samples.darktheme.R
6 |
7 | /**
8 | * Exposes settings data.
9 | */
10 | class SettingsRepository private constructor(
11 | context: Context
12 | ) {
13 |
14 | private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
15 |
16 | init {
17 | PreferenceManager.setDefaultValues(context, R.xml.preferences, true)
18 | }
19 |
20 | /**
21 | * Returns the theme.
22 | */
23 | fun getTheme(defValue: String?): String? {
24 | return prefs.getString(Settings.THEME, defValue)
25 | }
26 |
27 | companion object {
28 |
29 | private var instance: SettingsRepository? = null
30 |
31 | /**
32 | * Returns the [SettingsRepository] instance.
33 | */
34 | fun getInstance(
35 | context: Context
36 | ): SettingsRepository {
37 | return instance ?: SettingsRepository(context).also { instance = it }
38 | }
39 |
40 | }
41 |
42 | }
43 |
--------------------------------------------------------------------------------
/darktheme/app/src/main/java/com/kfaraj/samples/darktheme/domain/GetNightModeUseCase.kt:
--------------------------------------------------------------------------------
1 | package com.kfaraj.samples.darktheme.domain
2 |
3 | import androidx.appcompat.app.AppCompatDelegate
4 | import com.kfaraj.samples.darktheme.data.Settings
5 | import com.kfaraj.samples.darktheme.data.SettingsRepository
6 |
7 | /**
8 | * Returns the night mode.
9 | */
10 | class GetNightModeUseCase(
11 | private val settingsRepository: SettingsRepository
12 | ) {
13 |
14 | operator fun invoke(): Int {
15 | val theme = settingsRepository.getTheme(
16 | Settings.THEME_SYSTEM_DEFAULT
17 | )
18 | return when (theme) {
19 | Settings.THEME_LIGHT -> AppCompatDelegate.MODE_NIGHT_NO
20 | Settings.THEME_DARK -> AppCompatDelegate.MODE_NIGHT_YES
21 | else -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
22 | }
23 | }
24 |
25 | }
26 |
--------------------------------------------------------------------------------
/darktheme/app/src/main/java/com/kfaraj/samples/darktheme/ui/MainFragment.kt:
--------------------------------------------------------------------------------
1 | package com.kfaraj.samples.darktheme.ui
2 |
3 | import android.os.Bundle
4 | import android.view.View
5 | import androidx.core.view.WindowInsetsCompat
6 | import androidx.fragment.app.Fragment
7 | import com.kfaraj.samples.darktheme.R
8 | import com.kfaraj.samples.darktheme.util.applyWindowInsetsPadding
9 |
10 | /**
11 | * Demonstrates how to implement a dark theme.
12 | */
13 | class MainFragment : Fragment(R.layout.fragment_main) {
14 |
15 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
16 | super.onViewCreated(view, savedInstanceState)
17 | view.applyWindowInsetsPadding(
18 | WindowInsetsCompat.Type.systemBars(),
19 | applyBottom = true
20 | )
21 | }
22 |
23 | }
24 |
--------------------------------------------------------------------------------
/darktheme/app/src/main/java/com/kfaraj/samples/darktheme/ui/SettingsFragment.kt:
--------------------------------------------------------------------------------
1 | package com.kfaraj.samples.darktheme.ui
2 |
3 | import android.content.SharedPreferences
4 | import android.content.SharedPreferences.OnSharedPreferenceChangeListener
5 | import android.os.Bundle
6 | import android.view.View
7 | import androidx.appcompat.app.AppCompatDelegate
8 | import androidx.core.view.WindowInsetsCompat
9 | import androidx.preference.PreferenceFragmentCompat
10 | import com.kfaraj.samples.darktheme.R
11 | import com.kfaraj.samples.darktheme.data.SettingsRepository
12 | import com.kfaraj.samples.darktheme.domain.GetNightModeUseCase
13 | import com.kfaraj.samples.darktheme.util.applyWindowInsetsPadding
14 |
15 | /**
16 | * Contains settings.
17 | */
18 | class SettingsFragment : PreferenceFragmentCompat(),
19 | OnSharedPreferenceChangeListener {
20 |
21 | private lateinit var getNightModeUseCase: GetNightModeUseCase
22 |
23 | override fun onCreate(savedInstanceState: Bundle?) {
24 | super.onCreate(savedInstanceState)
25 | val context = requireContext()
26 | val settingsRepository = SettingsRepository.getInstance(context)
27 | getNightModeUseCase = GetNightModeUseCase(settingsRepository)
28 | }
29 |
30 | override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
31 | setPreferencesFromResource(R.xml.preferences, rootKey)
32 | }
33 |
34 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
35 | super.onViewCreated(view, savedInstanceState)
36 | listView.applyWindowInsetsPadding(
37 | WindowInsetsCompat.Type.systemBars(),
38 | applyBottom = true
39 | )
40 | }
41 |
42 | override fun onStart() {
43 | super.onStart()
44 | preferenceManager.sharedPreferences?.registerOnSharedPreferenceChangeListener(this)
45 | }
46 |
47 | override fun onStop() {
48 | super.onStop()
49 | preferenceManager.sharedPreferences?.unregisterOnSharedPreferenceChangeListener(this)
50 | }
51 |
52 | override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String?) {
53 | val nightMode = getNightModeUseCase()
54 | AppCompatDelegate.setDefaultNightMode(nightMode)
55 | }
56 |
57 | }
58 |
--------------------------------------------------------------------------------
/darktheme/app/src/main/java/com/kfaraj/samples/darktheme/util/EdgeToEdge.kt:
--------------------------------------------------------------------------------
1 | package com.kfaraj.samples.darktheme.util
2 |
3 | import android.view.View
4 | import androidx.core.view.ViewCompat
5 | import androidx.core.view.updatePadding
6 |
7 | /**
8 | * Increases padding to avoid visual overlap caused by edge-to-edge.
9 | */
10 | fun View.applyWindowInsetsPadding(
11 | typeMask: Int,
12 | applyLeft: Boolean = false,
13 | applyTop: Boolean = false,
14 | applyRight: Boolean = false,
15 | applyBottom: Boolean = false
16 | ) {
17 | val paddingLeft = paddingLeft
18 | val paddingTop = paddingTop
19 | val paddingRight = paddingRight
20 | val paddingBottom = paddingBottom
21 | ViewCompat.setOnApplyWindowInsetsListener(this) { v, insets ->
22 | val typeInsets = insets.getInsets(typeMask)
23 | v.updatePadding(
24 | left = paddingLeft + if (applyLeft) typeInsets.left else 0,
25 | top = paddingTop + if (applyTop) typeInsets.top else 0,
26 | right = paddingRight + if (applyRight) typeInsets.right else 0,
27 | bottom = paddingBottom + if (applyBottom) typeInsets.bottom else 0
28 | )
29 | insets
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/darktheme/app/src/main/res/drawable/ic_add_black_24dp.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
10 |
11 |
--------------------------------------------------------------------------------
/darktheme/app/src/main/res/drawable/ic_arrow_back_24dp.xml:
--------------------------------------------------------------------------------
1 |
2 |
9 |
12 |
13 |
--------------------------------------------------------------------------------
/darktheme/app/src/main/res/drawable/ic_launcher_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
10 |
11 |
--------------------------------------------------------------------------------
/darktheme/app/src/main/res/drawable/ic_launcher_foreground.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
12 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/darktheme/app/src/main/res/drawable/ic_launcher_monochrome.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
12 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/darktheme/app/src/main/res/drawable/ic_settings_24dp.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
11 |
12 |
--------------------------------------------------------------------------------
/darktheme/app/src/main/res/layout/activity_main.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
12 |
13 |
20 |
21 |
22 |
23 |
29 |
30 |
38 |
39 |
40 |
--------------------------------------------------------------------------------
/darktheme/app/src/main/res/layout/activity_settings.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
12 |
13 |
21 |
22 |
23 |
24 |
30 |
31 |
32 |
--------------------------------------------------------------------------------
/darktheme/app/src/main/res/layout/fragment_main.xml:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
13 |
14 |
18 |
19 |
24 |
25 |
30 |
31 |
32 |
33 |
34 |
35 |
40 |
41 |
46 |
47 |
52 |
53 |
54 |
55 |
56 |
57 |
62 |
63 |
68 |
69 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
--------------------------------------------------------------------------------
/darktheme/app/src/main/res/menu/activity_main.xml:
--------------------------------------------------------------------------------
1 |
2 |
10 |
--------------------------------------------------------------------------------
/darktheme/app/src/main/res/mipmap/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/darktheme/app/src/main/res/mipmap/ic_launcher_round.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/darktheme/app/src/main/res/values-night/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/darktheme/app/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #ff00201c
4 | #ff003731
5 | #ff005048
6 | #ff006a60
7 | #ff82d5c8
8 | #ff9ef2e4
9 | #ffffffff
10 | #ff05201c
11 | #ff1c3531
12 | #ff334b47
13 | #ff4a635f
14 | #ffb1ccc6
15 | #ffcce8e2
16 | #ffffffff
17 | #ff001e31
18 | #ff153349
19 | #ff2d4961
20 | #ff456179
21 | #ffadcae6
22 | #ffcce5ff
23 | #ffffffff
24 | #ff090f0e
25 | #ff0e1513
26 | #ff161d1c
27 | #ff1a2120
28 | #ff252b2a
29 | #ff2b3230
30 | #ff303635
31 | #ff343b39
32 | #ffd5dbd9
33 | #ffdde4e1
34 | #ffe3eae7
35 | #ffe9efed
36 | #ffecf2ef
37 | #ffeff5f2
38 | #fff4fbf8
39 | #ffffffff
40 | #ff3f4947
41 | #ff6f7977
42 | #ff899390
43 | #ffbec9c6
44 | #ffdae5e1
45 |
46 |
--------------------------------------------------------------------------------
/darktheme/app/src/main/res/values/dimens.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | 16dp
4 | 8dp
5 | 16dp
6 |
7 |
--------------------------------------------------------------------------------
/darktheme/app/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | Dark theme
4 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed non risus. Suspendisse lectus tortor, dignissim sit amet, adipiscing nec, ultricies sed, dolor. Cras elementum ultrices diam. Maecenas ligula massa, varius a, semper congue, euismod non, mi. Proin porttitor, orci nec nonummy molestie, enim est eleifend mi, non fermentum diam nisl sit amet erat. Duis semper. Duis arcu massa, scelerisque vitae, consequat in, pretium a, enim. Pellentesque congue. Ut in risus volutpat libero pharetra tempor. Cras vestibulum bibendum augue. Praesent egestas leo in pede. Praesent blandit odio eu enim. Pellentesque sed dui ut augue blandit sodales. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae; Aliquam nibh. Mauris ac mauris sed pede pellentesque fermentum. Maecenas adipiscing ante non diam sodales hendrerit.
5 | Add
6 | Settings
7 | Navigate up
8 | Theme
9 |
10 | - Light
11 | - Dark
12 | - System default
13 |
14 |
15 | - light
16 | - dark
17 | - system_default
18 |
19 | system_default
20 |
21 |
--------------------------------------------------------------------------------
/darktheme/app/src/main/res/values/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
47 |
91 |
92 |
93 |
--------------------------------------------------------------------------------
/darktheme/app/src/main/res/xml/preferences.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
10 |
11 |
--------------------------------------------------------------------------------
/darktheme/screenshots/darktheme.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kfaraj/samples/0d54194506861c1ca2e26a7c6fd989a24750a431/darktheme/screenshots/darktheme.webp
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
2 | android.useAndroidX=true
3 | kotlin.code.style=official
4 |
--------------------------------------------------------------------------------
/gradle/libs.versions.toml:
--------------------------------------------------------------------------------
1 | [versions]
2 | androidx-activity = "1.10.1"
3 | androidx-appcompat = "1.7.0"
4 | androidx-coordinatorlayout = "1.3.0"
5 | androidx-core = "1.16.0"
6 | androidx-fragment = "1.8.6"
7 | androidx-lifecycle = "2.9.0"
8 | androidx-navigation = "2.9.0"
9 | androidx-paging = "3.3.6"
10 | androidx-preference = "1.2.1"
11 | androidx-recyclerview = "1.4.0"
12 | androidx-room = "2.7.1"
13 | androidx-test-core = "1.6.1"
14 | androidx-test-rules = "1.6.1"
15 | androidx-test-runner = "1.6.2"
16 | com-android-tools-build-gradle = "8.10.0"
17 | com-google-android-material = "1.12.0"
18 | com-google-dagger = "2.56.2"
19 | com-google-devtools-ksp = "2.1.21-2.0.1"
20 | io-coil = "3.2.0"
21 | io-ktor = "3.1.3"
22 | junit = "4.13.2"
23 | org-jetbrains-kotlin = "2.1.21"
24 | org-jetbrains-kotlinx-coroutines = "1.10.2"
25 | org-jetbrains-kotlinx-serialization = "1.8.1"
26 | org-mockito = "5.17.0"
27 | org-mockito-kotlin = "5.4.0"
28 | org-robolectric = "4.14.1"
29 |
30 | [libraries]
31 | androidx-activity = { module = "androidx.activity:activity", version.ref = "androidx-activity" }
32 | androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
33 | androidx-coordinatorlayout = { module = "androidx.coordinatorlayout:coordinatorlayout", version.ref = "androidx-coordinatorlayout" }
34 | androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core" }
35 | androidx-fragment-ktx = { module = "androidx.fragment:fragment-ktx", version.ref = "androidx-fragment" }
36 | androidx-lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime", version.ref = "androidx-lifecycle" }
37 | androidx-lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel", version.ref = "androidx-lifecycle" }
38 | androidx-lifecycle-viewmodel-savedstate = { module = "androidx.lifecycle:lifecycle-viewmodel-savedstate", version.ref = "androidx-lifecycle" }
39 | androidx-navigation-fragment = { module = "androidx.navigation:navigation-fragment", version.ref = "androidx-navigation" }
40 | androidx-navigation-ui = { module = "androidx.navigation:navigation-ui", version.ref = "androidx-navigation" }
41 | androidx-paging-runtime = { module = "androidx.paging:paging-runtime", version.ref = "androidx-paging" }
42 | androidx-paging-testing = { module = "androidx.paging:paging-testing", version.ref = "androidx-paging" }
43 | androidx-preference-ktx = { module = "androidx.preference:preference-ktx", version.ref = "androidx-preference" }
44 | androidx-recyclerview = { module = "androidx.recyclerview:recyclerview", version.ref = "androidx-recyclerview" }
45 | androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "androidx-room" }
46 | androidx-room-paging = { module = "androidx.room:room-paging", version.ref = "androidx-room" }
47 | androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "androidx-room" }
48 | androidx-test-core-ktx = { module = "androidx.test:core-ktx", version.ref = "androidx-test-core" }
49 | androidx-test-rules = { module = "androidx.test:rules", version.ref = "androidx-test-rules" }
50 | androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test-runner" }
51 | com-google-android-material = { module = "com.google.android.material:material", version.ref = "com-google-android-material" }
52 | com-google-dagger-hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "com-google-dagger" }
53 | com-google-dagger-hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "com-google-dagger" }
54 | io-coil = { module = "io.coil-kt.coil3:coil", version.ref = "io-coil" }
55 | io-coil-network-ktor = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "io-coil" }
56 | io-ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "io-ktor" }
57 | io-ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "io-ktor" }
58 | io-ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "io-ktor" }
59 | io-ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "io-ktor" }
60 | junit = { module = "junit:junit", version.ref = "junit" }
61 | org-jetbrains-kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "org-jetbrains-kotlinx-coroutines" }
62 | org-jetbrains-kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "org-jetbrains-kotlinx-coroutines" }
63 | org-jetbrains-kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "org-jetbrains-kotlinx-serialization" }
64 | org-mockito-core = { module = "org.mockito:mockito-core", version.ref = "org-mockito" }
65 | org-mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version.ref = "org-mockito-kotlin" }
66 | org-robolectric = { module = "org.robolectric:robolectric", version.ref = "org-robolectric" }
67 |
68 | [plugins]
69 | androidx-room = { id = "androidx.room", version.ref = "androidx-room" }
70 | com-android-application = { id = "com.android.application", version.ref = "com-android-tools-build-gradle" }
71 | com-google-dagger-hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "com-google-dagger" }
72 | com-google-devtools-ksp = { id = "com.google.devtools.ksp", version.ref = "com-google-devtools-ksp" }
73 | org-jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "org-jetbrains-kotlin" }
74 | org-jetbrains-kotlin-plugin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "org-jetbrains-kotlin" }
75 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kfaraj/samples/0d54194506861c1ca2e26a7c6fd989a24750a431/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | distributionBase=GRADLE_USER_HOME
2 | distributionPath=wrapper/dists
3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
4 | zipStoreBase=GRADLE_USER_HOME
5 | zipStorePath=wrapper/dists
6 |
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | #
4 | # Copyright 2015 the original author or authors.
5 | #
6 | # Licensed under the Apache License, Version 2.0 (the "License");
7 | # you may not use this file except in compliance with the License.
8 | # You may obtain a copy of the License at
9 | #
10 | # https://www.apache.org/licenses/LICENSE-2.0
11 | #
12 | # Unless required by applicable law or agreed to in writing, software
13 | # distributed under the License is distributed on an "AS IS" BASIS,
14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 | # See the License for the specific language governing permissions and
16 | # limitations under the License.
17 | #
18 |
19 | ##############################################################################
20 | ##
21 | ## Gradle start up script for UN*X
22 | ##
23 | ##############################################################################
24 |
25 | # Attempt to set APP_HOME
26 | # Resolve links: $0 may be a link
27 | PRG="$0"
28 | # Need this for relative symlinks.
29 | while [ -h "$PRG" ] ; do
30 | ls=`ls -ld "$PRG"`
31 | link=`expr "$ls" : '.*-> \(.*\)$'`
32 | if expr "$link" : '/.*' > /dev/null; then
33 | PRG="$link"
34 | else
35 | PRG=`dirname "$PRG"`"/$link"
36 | fi
37 | done
38 | SAVED="`pwd`"
39 | cd "`dirname \"$PRG\"`/" >/dev/null
40 | APP_HOME="`pwd -P`"
41 | cd "$SAVED" >/dev/null
42 |
43 | APP_NAME="Gradle"
44 | APP_BASE_NAME=`basename "$0"`
45 |
46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
48 |
49 | # Use the maximum available, or set MAX_FD != -1 to use that value.
50 | MAX_FD="maximum"
51 |
52 | warn () {
53 | echo "$*"
54 | }
55 |
56 | die () {
57 | echo
58 | echo "$*"
59 | echo
60 | exit 1
61 | }
62 |
63 | # OS specific support (must be 'true' or 'false').
64 | cygwin=false
65 | msys=false
66 | darwin=false
67 | nonstop=false
68 | case "`uname`" in
69 | CYGWIN* )
70 | cygwin=true
71 | ;;
72 | Darwin* )
73 | darwin=true
74 | ;;
75 | MINGW* )
76 | msys=true
77 | ;;
78 | NONSTOP* )
79 | nonstop=true
80 | ;;
81 | esac
82 |
83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
84 |
85 |
86 | # Determine the Java command to use to start the JVM.
87 | if [ -n "$JAVA_HOME" ] ; then
88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
89 | # IBM's JDK on AIX uses strange locations for the executables
90 | JAVACMD="$JAVA_HOME/jre/sh/java"
91 | else
92 | JAVACMD="$JAVA_HOME/bin/java"
93 | fi
94 | if [ ! -x "$JAVACMD" ] ; then
95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
96 |
97 | Please set the JAVA_HOME variable in your environment to match the
98 | location of your Java installation."
99 | fi
100 | else
101 | JAVACMD="java"
102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
103 |
104 | Please set the JAVA_HOME variable in your environment to match the
105 | location of your Java installation."
106 | fi
107 |
108 | # Increase the maximum file descriptors if we can.
109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
110 | MAX_FD_LIMIT=`ulimit -H -n`
111 | if [ $? -eq 0 ] ; then
112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
113 | MAX_FD="$MAX_FD_LIMIT"
114 | fi
115 | ulimit -n $MAX_FD
116 | if [ $? -ne 0 ] ; then
117 | warn "Could not set maximum file descriptor limit: $MAX_FD"
118 | fi
119 | else
120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
121 | fi
122 | fi
123 |
124 | # For Darwin, add options to specify how the application appears in the dock
125 | if $darwin; then
126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
127 | fi
128 |
129 | # For Cygwin or MSYS, switch paths to Windows format before running java
130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"`
132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
133 |
134 | JAVACMD=`cygpath --unix "$JAVACMD"`
135 |
136 | # We build the pattern for arguments to be converted via cygpath
137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
138 | SEP=""
139 | for dir in $ROOTDIRSRAW ; do
140 | ROOTDIRS="$ROOTDIRS$SEP$dir"
141 | SEP="|"
142 | done
143 | OURCYGPATTERN="(^($ROOTDIRS))"
144 | # Add a user-defined pattern to the cygpath arguments
145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then
146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
147 | fi
148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
149 | i=0
150 | for arg in "$@" ; do
151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
153 |
154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
156 | else
157 | eval `echo args$i`="\"$arg\""
158 | fi
159 | i=`expr $i + 1`
160 | done
161 | case $i in
162 | 0) set -- ;;
163 | 1) set -- "$args0" ;;
164 | 2) set -- "$args0" "$args1" ;;
165 | 3) set -- "$args0" "$args1" "$args2" ;;
166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
172 | esac
173 | fi
174 |
175 | # Escape application args
176 | save () {
177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
178 | echo " "
179 | }
180 | APP_ARGS=`save "$@"`
181 |
182 | # Collect all arguments for the java command, following the shell quoting and substitution rules
183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
184 |
185 | exec "$JAVACMD" "$@"
186 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 |
17 | @if "%DEBUG%" == "" @echo off
18 | @rem ##########################################################################
19 | @rem
20 | @rem Gradle startup script for Windows
21 | @rem
22 | @rem ##########################################################################
23 |
24 | @rem Set local scope for the variables with windows NT shell
25 | if "%OS%"=="Windows_NT" setlocal
26 |
27 | set DIRNAME=%~dp0
28 | if "%DIRNAME%" == "" set DIRNAME=.
29 | set APP_BASE_NAME=%~n0
30 | set APP_HOME=%DIRNAME%
31 |
32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
34 |
35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
37 |
38 | @rem Find java.exe
39 | if defined JAVA_HOME goto findJavaFromJavaHome
40 |
41 | set JAVA_EXE=java.exe
42 | %JAVA_EXE% -version >NUL 2>&1
43 | if "%ERRORLEVEL%" == "0" goto execute
44 |
45 | echo.
46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
47 | echo.
48 | echo Please set the JAVA_HOME variable in your environment to match the
49 | echo location of your Java installation.
50 |
51 | goto fail
52 |
53 | :findJavaFromJavaHome
54 | set JAVA_HOME=%JAVA_HOME:"=%
55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
56 |
57 | if exist "%JAVA_EXE%" goto execute
58 |
59 | echo.
60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
61 | echo.
62 | echo Please set the JAVA_HOME variable in your environment to match the
63 | echo location of your Java installation.
64 |
65 | goto fail
66 |
67 | :execute
68 | @rem Setup the command line
69 |
70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
71 |
72 |
73 | @rem Execute Gradle
74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
75 |
76 | :end
77 | @rem End local scope for the variables with windows NT shell
78 | if "%ERRORLEVEL%"=="0" goto mainEnd
79 |
80 | :fail
81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
82 | rem the _cmd.exe /c_ return code!
83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
84 | exit /b 1
85 |
86 | :mainEnd
87 | if "%OS%"=="Windows_NT" endlocal
88 |
89 | :omega
90 |
--------------------------------------------------------------------------------
/pokedex/README.md:
--------------------------------------------------------------------------------
1 | # Pokédex
2 |
3 | This sample demonstrates best practices for
4 | [Modern Android Development](https://developer.android.com/modern-android-development).
5 |
6 | ## Screenshots
7 |
8 | 
9 |
--------------------------------------------------------------------------------
/pokedex/app/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.com.android.application)
3 | alias(libs.plugins.org.jetbrains.kotlin.android)
4 | alias(libs.plugins.org.jetbrains.kotlin.plugin.serialization)
5 | alias(libs.plugins.com.google.devtools.ksp)
6 | alias(libs.plugins.com.google.dagger.hilt.android)
7 | alias(libs.plugins.androidx.room)
8 | }
9 |
10 | android {
11 | namespace = "com.kfaraj.samples.pokedex"
12 | compileSdk = 35
13 | defaultConfig {
14 | applicationId = "com.kfaraj.samples.pokedex"
15 | minSdk = 26
16 | targetSdk = 35
17 | versionCode = 1
18 | versionName = "0.1.0"
19 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
20 | vectorDrawables {
21 | useSupportLibrary = true
22 | }
23 | }
24 | signingConfigs {
25 | register("release") {
26 | val storePath = properties["signingStorePath"] as String?
27 | storeFile = if (storePath != null) file(storePath) else null
28 | storePassword = properties["signingStorePassword"] as String?
29 | keyAlias = properties["signingKeyAlias"] as String?
30 | keyPassword = properties["signingKeyPassword"] as String?
31 | }
32 | }
33 | buildTypes {
34 | named("release") {
35 | isMinifyEnabled = true
36 | isShrinkResources = true
37 | proguardFiles(
38 | getDefaultProguardFile("proguard-android-optimize.txt"),
39 | "proguard-rules.pro"
40 | )
41 | signingConfig = signingConfigs.getByName("release")
42 | }
43 | }
44 | kotlinOptions {
45 | freeCompilerArgs += listOf(
46 | "-opt-in=androidx.paging.ExperimentalPagingApi",
47 | "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi"
48 | )
49 | }
50 | testOptions {
51 | unitTests {
52 | isIncludeAndroidResources = true
53 | }
54 | managedDevices {
55 | localDevices {
56 | register("pixel9Api35") {
57 | device = "Pixel 9"
58 | apiLevel = 35
59 | systemImageSource = "aosp-atd"
60 | }
61 | }
62 | }
63 | }
64 | }
65 |
66 | kotlin {
67 | jvmToolchain(21)
68 | }
69 |
70 | room {
71 | schemaDirectory("$projectDir/schemas")
72 | }
73 |
74 | dependencies {
75 | implementation(libs.androidx.activity)
76 | implementation(libs.androidx.appcompat)
77 | implementation(libs.androidx.coordinatorlayout)
78 | implementation(libs.androidx.core.ktx)
79 | implementation(libs.androidx.fragment.ktx)
80 | implementation(libs.androidx.lifecycle.runtime)
81 | implementation(libs.androidx.lifecycle.viewmodel)
82 | implementation(libs.androidx.lifecycle.viewmodel.savedstate)
83 | implementation(libs.androidx.navigation.fragment)
84 | implementation(libs.androidx.navigation.ui)
85 | implementation(libs.androidx.paging.runtime)
86 | implementation(libs.androidx.recyclerview)
87 | implementation(libs.androidx.room.paging)
88 | implementation(libs.androidx.room.runtime)
89 | ksp(libs.androidx.room.compiler)
90 | implementation(libs.com.google.android.material)
91 | implementation(libs.com.google.dagger.hilt.android)
92 | ksp(libs.com.google.dagger.hilt.compiler)
93 | implementation(libs.io.coil)
94 | implementation(libs.io.coil.network.ktor)
95 | implementation(libs.io.ktor.client.content.negotiation)
96 | implementation(libs.io.ktor.client.core)
97 | implementation(libs.io.ktor.client.okhttp)
98 | implementation(libs.io.ktor.serialization.kotlinx.json)
99 | implementation(libs.org.jetbrains.kotlinx.coroutines.android)
100 | implementation(libs.org.jetbrains.kotlinx.serialization.json)
101 | testImplementation(libs.androidx.paging.testing)
102 | testImplementation(libs.androidx.test.core.ktx)
103 | testImplementation(libs.junit)
104 | testImplementation(libs.org.jetbrains.kotlinx.coroutines.test)
105 | testImplementation(libs.org.mockito.core)
106 | testImplementation(libs.org.mockito.kotlin)
107 | testImplementation(libs.org.robolectric)
108 | androidTestImplementation(libs.androidx.test.core.ktx)
109 | androidTestImplementation(libs.androidx.test.rules)
110 | androidTestImplementation(libs.androidx.test.runner)
111 | }
112 |
--------------------------------------------------------------------------------
/pokedex/app/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | -keepattributes SourceFile,LineNumberTable
2 | -renamesourcefileattribute SourceFile
3 |
--------------------------------------------------------------------------------
/pokedex/app/schemas/com.kfaraj.samples.pokedex.data.local.ApplicationDatabase/1.json:
--------------------------------------------------------------------------------
1 | {
2 | "formatVersion": 1,
3 | "database": {
4 | "version": 1,
5 | "identityHash": "8902f68a659fa602fc5206ccae833541",
6 | "entities": [
7 | {
8 | "tableName": "pokemons",
9 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`id`))",
10 | "fields": [
11 | {
12 | "fieldPath": "id",
13 | "columnName": "id",
14 | "affinity": "INTEGER",
15 | "notNull": true
16 | },
17 | {
18 | "fieldPath": "name",
19 | "columnName": "name",
20 | "affinity": "TEXT",
21 | "notNull": true
22 | }
23 | ],
24 | "primaryKey": {
25 | "autoGenerate": false,
26 | "columnNames": [
27 | "id"
28 | ]
29 | },
30 | "indices": [],
31 | "foreignKeys": []
32 | }
33 | ],
34 | "views": [],
35 | "setupQueries": [
36 | "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
37 | "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '8902f68a659fa602fc5206ccae833541')"
38 | ]
39 | }
40 | }
--------------------------------------------------------------------------------
/pokedex/app/src/androidTest/java/com/kfaraj/samples/pokedex/MainActivityTest.kt:
--------------------------------------------------------------------------------
1 | package com.kfaraj.samples.pokedex
2 |
3 | import androidx.test.core.app.launchActivity
4 | import org.junit.Test
5 |
6 | class MainActivityTest {
7 |
8 | @Test
9 | fun launchActivity() {
10 | launchActivity()
11 | }
12 |
13 | }
14 |
--------------------------------------------------------------------------------
/pokedex/app/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
6 |
7 |
8 |
18 |
19 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
--------------------------------------------------------------------------------
/pokedex/app/src/main/java/com/kfaraj/samples/pokedex/MainActivity.kt:
--------------------------------------------------------------------------------
1 | package com.kfaraj.samples.pokedex
2 |
3 | import android.os.Bundle
4 | import androidx.activity.enableEdgeToEdge
5 | import androidx.appcompat.app.AppCompatActivity
6 | import androidx.core.app.ActivityCompat
7 | import androidx.core.view.WindowInsetsCompat
8 | import androidx.navigation.NavHost
9 | import androidx.navigation.createGraph
10 | import androidx.navigation.ui.AppBarConfiguration
11 | import androidx.navigation.ui.setupWithNavController
12 | import com.google.android.material.appbar.AppBarLayout
13 | import com.google.android.material.appbar.MaterialToolbar
14 | import com.kfaraj.samples.pokedex.ui.PokedexRoute
15 | import com.kfaraj.samples.pokedex.ui.pokedexDestination
16 | import com.kfaraj.samples.pokedex.ui.pokemonDestination
17 | import com.kfaraj.samples.pokedex.util.applyWindowInsetsPadding
18 | import dagger.hilt.android.AndroidEntryPoint
19 |
20 | /**
21 | * Demonstrates best practices for Modern Android Development.
22 | */
23 | @AndroidEntryPoint
24 | class MainActivity : AppCompatActivity(R.layout.activity_main) {
25 |
26 | override fun onCreate(savedInstanceState: Bundle?) {
27 | enableEdgeToEdge()
28 | super.onCreate(savedInstanceState)
29 | val navHost = supportFragmentManager.findFragmentById(R.id.nav_host) as NavHost
30 | val navController = navHost.navController
31 | navController.graph = navController.createGraph(
32 | startDestination = PokedexRoute
33 | ) {
34 | pokedexDestination(resources)
35 | pokemonDestination(resources)
36 | }
37 | val appBarConfiguration = AppBarConfiguration(navController.graph)
38 | val toolbar = ActivityCompat.requireViewById(this, R.id.toolbar)
39 | toolbar.setupWithNavController(navController, appBarConfiguration)
40 | (toolbar.parent as AppBarLayout).applyWindowInsetsPadding(
41 | WindowInsetsCompat.Type.systemBars() or
42 | WindowInsetsCompat.Type.displayCutout(),
43 | applyLeft = true,
44 | applyTop = true,
45 | applyRight = true
46 | )
47 | }
48 |
49 | }
50 |
--------------------------------------------------------------------------------
/pokedex/app/src/main/java/com/kfaraj/samples/pokedex/MainApplication.kt:
--------------------------------------------------------------------------------
1 | package com.kfaraj.samples.pokedex
2 |
3 | import android.app.Application
4 | import dagger.hilt.android.HiltAndroidApp
5 |
6 | /**
7 | * Demonstrates best practices for Modern Android Development.
8 | */
9 | @HiltAndroidApp
10 | class MainApplication : Application()
11 |
--------------------------------------------------------------------------------
/pokedex/app/src/main/java/com/kfaraj/samples/pokedex/data/Pokemon.kt:
--------------------------------------------------------------------------------
1 | package com.kfaraj.samples.pokedex.data
2 |
3 | /**
4 | * Contains Pokémon data.
5 | */
6 | data class Pokemon(
7 | val id: Int,
8 | val name: String
9 | )
10 |
--------------------------------------------------------------------------------
/pokedex/app/src/main/java/com/kfaraj/samples/pokedex/data/PokemonsRemoteMediator.kt:
--------------------------------------------------------------------------------
1 | package com.kfaraj.samples.pokedex.data
2 |
3 | import androidx.paging.ExperimentalPagingApi
4 | import androidx.paging.LoadType
5 | import androidx.paging.PagingState
6 | import androidx.paging.RemoteMediator
7 | import com.kfaraj.samples.pokedex.data.local.PokemonEntity
8 | import com.kfaraj.samples.pokedex.data.local.PokemonsLocalDataSource
9 | import com.kfaraj.samples.pokedex.data.remote.NamedApiResource
10 | import com.kfaraj.samples.pokedex.data.remote.PokemonsRemoteDataSource
11 | import com.kfaraj.samples.pokedex.data.remote.id
12 | import io.ktor.client.plugins.ResponseException
13 | import java.io.IOException
14 |
15 | /**
16 | * Incrementally loads Pokémon data from a remote data source into a local data source.
17 | */
18 | @ExperimentalPagingApi
19 | class PokemonsRemoteMediator(
20 | private val pokemonsRemoteDataSource: PokemonsRemoteDataSource,
21 | private val pokemonsLocalDataSource: PokemonsLocalDataSource
22 | ) : RemoteMediator() {
23 |
24 | override suspend fun load(
25 | loadType: LoadType,
26 | state: PagingState
27 | ): MediatorResult {
28 | return try {
29 | val offset = when (loadType) {
30 | LoadType.REFRESH -> 0
31 | LoadType.PREPEND -> return MediatorResult.Success(true)
32 | LoadType.APPEND -> pokemonsLocalDataSource.getCount()
33 | }
34 | val response = pokemonsRemoteDataSource.getPokemon(state.config.pageSize, offset)
35 | val pokemons = response
36 | .results
37 | .map { result ->
38 | result.toPokemonEntity()
39 | }
40 | pokemonsLocalDataSource.upsertAll(pokemons)
41 | MediatorResult.Success(response.next == null)
42 | } catch (e: IOException) {
43 | MediatorResult.Error(e)
44 | } catch (e: ResponseException) {
45 | MediatorResult.Error(e)
46 | }
47 | }
48 |
49 | override suspend fun initialize(): InitializeAction {
50 | return if (pokemonsLocalDataSource.getCount() == 0) {
51 | InitializeAction.LAUNCH_INITIAL_REFRESH
52 | } else {
53 | InitializeAction.SKIP_INITIAL_REFRESH
54 | }
55 | }
56 |
57 | /**
58 | * Converts the model from the remote data source to the local data source.
59 | */
60 | private fun NamedApiResource.toPokemonEntity(): PokemonEntity {
61 | return PokemonEntity(
62 | id,
63 | name
64 | )
65 | }
66 |
67 | }
68 |
--------------------------------------------------------------------------------
/pokedex/app/src/main/java/com/kfaraj/samples/pokedex/data/PokemonsRepository.kt:
--------------------------------------------------------------------------------
1 | package com.kfaraj.samples.pokedex.data
2 |
3 | import androidx.paging.Pager
4 | import androidx.paging.PagingConfig
5 | import androidx.paging.PagingData
6 | import androidx.paging.map
7 | import com.kfaraj.samples.pokedex.data.local.PokemonEntity
8 | import com.kfaraj.samples.pokedex.data.local.PokemonsLocalDataSource
9 | import com.kfaraj.samples.pokedex.data.remote.PokemonsRemoteDataSource
10 | import kotlinx.coroutines.flow.Flow
11 | import kotlinx.coroutines.flow.map
12 | import javax.inject.Inject
13 | import javax.inject.Singleton
14 |
15 | /**
16 | * Exposes Pokémon data.
17 | */
18 | @Singleton
19 | class PokemonsRepository @Inject constructor(
20 | private val pokemonsRemoteDataSource: PokemonsRemoteDataSource,
21 | private val pokemonsLocalDataSource: PokemonsLocalDataSource
22 | ) {
23 |
24 | /**
25 | * Returns Pokémon data for the given [id].
26 | */
27 | suspend fun get(id: Int): Pokemon {
28 | return pokemonsLocalDataSource.get(id).toPokemon()
29 | }
30 |
31 | /**
32 | * Returns the stream of paged Pokémon data.
33 | */
34 | fun getPagingDataStream(config: PagingConfig): Flow> {
35 | return Pager(
36 | config,
37 | null,
38 | PokemonsRemoteMediator(pokemonsRemoteDataSource, pokemonsLocalDataSource)
39 | ) {
40 | pokemonsLocalDataSource.getPagingSource()
41 | }.flow
42 | .map { pagingData ->
43 | pagingData.map { pokemonEntity ->
44 | pokemonEntity.toPokemon()
45 | }
46 | }
47 | }
48 |
49 | /**
50 | * Converts the model from the local data source to the data layer.
51 | */
52 | private fun PokemonEntity.toPokemon(): Pokemon {
53 | return Pokemon(
54 | id,
55 | name
56 | )
57 | }
58 |
59 | }
60 |
--------------------------------------------------------------------------------
/pokedex/app/src/main/java/com/kfaraj/samples/pokedex/data/local/ApplicationDatabase.kt:
--------------------------------------------------------------------------------
1 | package com.kfaraj.samples.pokedex.data.local
2 |
3 | import androidx.room.Database
4 | import androidx.room.RoomDatabase
5 |
6 | /**
7 | * Exposes application data from a database.
8 | */
9 | @Database(entities = [PokemonEntity::class], version = 1)
10 | abstract class ApplicationDatabase : RoomDatabase() {
11 |
12 | /**
13 | * Returns the [PokemonDao] instance.
14 | */
15 | abstract fun getPokemonDao(): PokemonDao
16 |
17 | }
18 |
--------------------------------------------------------------------------------
/pokedex/app/src/main/java/com/kfaraj/samples/pokedex/data/local/PokemonDao.kt:
--------------------------------------------------------------------------------
1 | package com.kfaraj.samples.pokedex.data.local
2 |
3 | import androidx.paging.PagingSource
4 | import androidx.room.Dao
5 | import androidx.room.Query
6 | import androidx.room.Upsert
7 |
8 | /**
9 | * Exposes Pokémon data from a database.
10 | */
11 | @Dao
12 | interface PokemonDao {
13 |
14 | /**
15 | * Inserts or updates Pokémon entities.
16 | */
17 | @Upsert
18 | suspend fun upsertAll(pokemons: List)
19 |
20 | /**
21 | * Returns the Pokémon entity for the given [id].
22 | */
23 | @Query("SELECT * FROM pokemons WHERE id = :id")
24 | suspend fun get(id: Int): PokemonEntity
25 |
26 | /**
27 | * Returns the source of paged Pokémon entities.
28 | */
29 | @Query("SELECT * FROM pokemons")
30 | fun getPagingSource(): PagingSource
31 |
32 | /**
33 | * Returns the number of Pokémon entities.
34 | */
35 | @Query("SELECT COUNT(*) FROM pokemons")
36 | suspend fun getCount(): Int
37 |
38 | }
39 |
--------------------------------------------------------------------------------
/pokedex/app/src/main/java/com/kfaraj/samples/pokedex/data/local/PokemonEntity.kt:
--------------------------------------------------------------------------------
1 | package com.kfaraj.samples.pokedex.data.local
2 |
3 | import androidx.room.ColumnInfo
4 | import androidx.room.Entity
5 | import androidx.room.PrimaryKey
6 |
7 | /**
8 | * Contains the Pokémon entity.
9 | */
10 | @Entity(tableName = "pokemons")
11 | data class PokemonEntity(
12 | @PrimaryKey @ColumnInfo(name = "id") val id: Int,
13 | @ColumnInfo(name = "name") val name: String
14 | )
15 |
--------------------------------------------------------------------------------
/pokedex/app/src/main/java/com/kfaraj/samples/pokedex/data/local/PokemonsLocalDataSource.kt:
--------------------------------------------------------------------------------
1 | package com.kfaraj.samples.pokedex.data.local
2 |
3 | import androidx.paging.PagingSource
4 | import javax.inject.Inject
5 |
6 | /**
7 | * Exposes Pokémon data from a local data source.
8 | */
9 | class PokemonsLocalDataSource @Inject constructor(
10 | private val pokemonDao: PokemonDao
11 | ) {
12 |
13 | /**
14 | * Inserts or updates Pokémon entities.
15 | */
16 | suspend fun upsertAll(pokemons: List) {
17 | pokemonDao.upsertAll(pokemons)
18 | }
19 |
20 | /**
21 | * Returns the Pokémon entity for the given [id].
22 | */
23 | suspend fun get(id: Int): PokemonEntity {
24 | return pokemonDao.get(id)
25 | }
26 |
27 | /**
28 | * Returns the source of paged Pokémon entities.
29 | */
30 | fun getPagingSource(): PagingSource {
31 | return pokemonDao.getPagingSource()
32 | }
33 |
34 | /**
35 | * Returns the number of Pokémon entities.
36 | */
37 | suspend fun getCount(): Int {
38 | return pokemonDao.getCount()
39 | }
40 |
41 | }
42 |
--------------------------------------------------------------------------------
/pokedex/app/src/main/java/com/kfaraj/samples/pokedex/data/remote/DefaultPokeApiService.kt:
--------------------------------------------------------------------------------
1 | package com.kfaraj.samples.pokedex.data.remote
2 |
3 | import io.ktor.client.HttpClient
4 | import io.ktor.client.call.body
5 | import io.ktor.client.request.get
6 | import io.ktor.client.request.parameter
7 |
8 | internal class DefaultPokeApiService(
9 | private val httpClient: HttpClient
10 | ) : PokeApiService {
11 |
12 | override suspend fun getPokemon(
13 | limit: Int,
14 | offset: Int
15 | ): NamedApiResourceList {
16 | return httpClient.get("pokemon/") {
17 | parameter("limit", limit)
18 | parameter("offset", offset)
19 | }.body()
20 | }
21 |
22 | }
23 |
--------------------------------------------------------------------------------
/pokedex/app/src/main/java/com/kfaraj/samples/pokedex/data/remote/NamedApiResource.kt:
--------------------------------------------------------------------------------
1 | package com.kfaraj.samples.pokedex.data.remote
2 |
3 | import kotlinx.serialization.SerialName
4 | import kotlinx.serialization.Serializable
5 |
6 | /**
7 | * Contains the named API resource.
8 | */
9 | @Serializable
10 | data class NamedApiResource(
11 | @SerialName("name") val name: String,
12 | @SerialName("url") val url: String
13 | )
14 |
15 | /**
16 | * The ID of the named API resource.
17 | */
18 | val NamedApiResource.id: Int
19 | get() = url.removeSuffix("/").substringAfterLast("/").toInt()
20 |
--------------------------------------------------------------------------------
/pokedex/app/src/main/java/com/kfaraj/samples/pokedex/data/remote/NamedApiResourceList.kt:
--------------------------------------------------------------------------------
1 | package com.kfaraj.samples.pokedex.data.remote
2 |
3 | import kotlinx.serialization.SerialName
4 | import kotlinx.serialization.Serializable
5 |
6 | /**
7 | * Contains the paginated list of named API resources.
8 | */
9 | @Serializable
10 | data class NamedApiResourceList(
11 | @SerialName("count") val count: Int,
12 | @SerialName("next") val next: String?,
13 | @SerialName("previous") val previous: String?,
14 | @SerialName("results") val results: List
15 | )
16 |
--------------------------------------------------------------------------------
/pokedex/app/src/main/java/com/kfaraj/samples/pokedex/data/remote/PokeApiService.kt:
--------------------------------------------------------------------------------
1 | package com.kfaraj.samples.pokedex.data.remote
2 |
3 | /**
4 | * Exposes Pokémon data from an API service.
5 | */
6 | interface PokeApiService {
7 |
8 | /**
9 | * Returns the paginated list of Pokémon API resources.
10 | */
11 | suspend fun getPokemon(
12 | limit: Int,
13 | offset: Int
14 | ): NamedApiResourceList
15 |
16 | }
17 |
--------------------------------------------------------------------------------
/pokedex/app/src/main/java/com/kfaraj/samples/pokedex/data/remote/PokemonsRemoteDataSource.kt:
--------------------------------------------------------------------------------
1 | package com.kfaraj.samples.pokedex.data.remote
2 |
3 | import javax.inject.Inject
4 |
5 | /**
6 | * Exposes Pokémon data from a remote data source.
7 | */
8 | class PokemonsRemoteDataSource @Inject constructor(
9 | private val pokeApiService: PokeApiService
10 | ) {
11 |
12 | /**
13 | * Returns the paginated list of Pokémon API resources.
14 | */
15 | suspend fun getPokemon(
16 | limit: Int,
17 | offset: Int
18 | ): NamedApiResourceList {
19 | return pokeApiService.getPokemon(limit, offset)
20 | }
21 |
22 | }
23 |
--------------------------------------------------------------------------------
/pokedex/app/src/main/java/com/kfaraj/samples/pokedex/di/CoroutinesModule.kt:
--------------------------------------------------------------------------------
1 | package com.kfaraj.samples.pokedex.di
2 |
3 | import dagger.Module
4 | import dagger.Provides
5 | import dagger.hilt.InstallIn
6 | import dagger.hilt.components.SingletonComponent
7 | import kotlinx.coroutines.CoroutineDispatcher
8 | import kotlinx.coroutines.CoroutineScope
9 | import kotlinx.coroutines.Dispatchers
10 | import kotlinx.coroutines.SupervisorJob
11 | import javax.inject.Qualifier
12 | import javax.inject.Singleton
13 |
14 | /**
15 | * Annotates the default [CoroutineDispatcher].
16 | */
17 | @Qualifier
18 | @Retention(AnnotationRetention.BINARY)
19 | annotation class DefaultDispatcher
20 |
21 | /**
22 | * Annotates the I/O [CoroutineDispatcher].
23 | */
24 | @Qualifier
25 | @Retention(AnnotationRetention.BINARY)
26 | annotation class IoDispatcher
27 |
28 | /**
29 | * Annotates the application [CoroutineScope].
30 | */
31 | @Qualifier
32 | @Retention(AnnotationRetention.BINARY)
33 | annotation class ApplicationScope
34 |
35 | /**
36 | * Provides bindings for coroutines.
37 | */
38 | @Module
39 | @InstallIn(SingletonComponent::class)
40 | object CoroutinesModule {
41 |
42 | /**
43 | * Provides the default [CoroutineDispatcher].
44 | */
45 | @DefaultDispatcher
46 | @Provides
47 | fun provideDefaultDispatcher(): CoroutineDispatcher {
48 | return Dispatchers.Default
49 | }
50 |
51 | /**
52 | * Provides the I/O [CoroutineDispatcher].
53 | */
54 | @IoDispatcher
55 | @Provides
56 | fun provideIoDispatcher(): CoroutineDispatcher {
57 | return Dispatchers.IO
58 | }
59 |
60 | /**
61 | * Provides the application [CoroutineScope].
62 | */
63 | @ApplicationScope
64 | @Singleton
65 | @Provides
66 | fun provideApplicationScope(
67 | @DefaultDispatcher defaultDispatcher: CoroutineDispatcher
68 | ): CoroutineScope {
69 | return CoroutineScope(SupervisorJob() + defaultDispatcher)
70 | }
71 |
72 | }
73 |
--------------------------------------------------------------------------------
/pokedex/app/src/main/java/com/kfaraj/samples/pokedex/di/DatabaseModule.kt:
--------------------------------------------------------------------------------
1 | package com.kfaraj.samples.pokedex.di
2 |
3 | import android.content.Context
4 | import androidx.room.Room
5 | import com.kfaraj.samples.pokedex.data.local.ApplicationDatabase
6 | import com.kfaraj.samples.pokedex.data.local.PokemonDao
7 | import dagger.Module
8 | import dagger.Provides
9 | import dagger.hilt.InstallIn
10 | import dagger.hilt.android.qualifiers.ApplicationContext
11 | import dagger.hilt.components.SingletonComponent
12 | import javax.inject.Singleton
13 |
14 | /**
15 | * Provides bindings for the database.
16 | */
17 | @Module
18 | @InstallIn(SingletonComponent::class)
19 | object DatabaseModule {
20 |
21 | /**
22 | * Provides the [ApplicationDatabase] instance.
23 | */
24 | @Singleton
25 | @Provides
26 | fun provideApplicationDatabase(
27 | @ApplicationContext applicationContext: Context
28 | ): ApplicationDatabase {
29 | return Room.databaseBuilder(
30 | applicationContext,
31 | ApplicationDatabase::class.java,
32 | "pokedex.db"
33 | ).build()
34 | }
35 |
36 | /**
37 | * Provides the [PokemonDao] instance.
38 | */
39 | @Provides
40 | fun providePokemonDao(
41 | applicationDatabase: ApplicationDatabase
42 | ): PokemonDao {
43 | return applicationDatabase.getPokemonDao()
44 | }
45 |
46 | }
47 |
--------------------------------------------------------------------------------
/pokedex/app/src/main/java/com/kfaraj/samples/pokedex/di/NetworkModule.kt:
--------------------------------------------------------------------------------
1 | package com.kfaraj.samples.pokedex.di
2 |
3 | import com.kfaraj.samples.pokedex.data.remote.DefaultPokeApiService
4 | import com.kfaraj.samples.pokedex.data.remote.PokeApiService
5 | import dagger.Module
6 | import dagger.Provides
7 | import dagger.hilt.InstallIn
8 | import dagger.hilt.components.SingletonComponent
9 | import io.ktor.client.HttpClient
10 | import io.ktor.client.engine.okhttp.OkHttp
11 | import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
12 | import io.ktor.client.plugins.defaultRequest
13 | import io.ktor.serialization.kotlinx.json.json
14 | import kotlinx.serialization.json.Json
15 | import javax.inject.Singleton
16 |
17 | /**
18 | * Provides bindings for the network.
19 | */
20 | @Module
21 | @InstallIn(SingletonComponent::class)
22 | object NetworkModule {
23 |
24 | private val json = Json {
25 | encodeDefaults = true
26 | explicitNulls = false
27 | ignoreUnknownKeys = true
28 | }
29 |
30 | /**
31 | * Provides the [HttpClient] instance.
32 | */
33 | @Singleton
34 | @Provides
35 | fun provideHttpClient(): HttpClient {
36 | return HttpClient(OkHttp) {
37 | defaultRequest {
38 | url("https://pokeapi.co/api/v2/")
39 | }
40 | install(ContentNegotiation) {
41 | json(json)
42 | }
43 | expectSuccess = true
44 | }
45 | }
46 |
47 | /**
48 | * Provides the [PokeApiService] instance.
49 | */
50 | @Provides
51 | fun providePokeApiService(
52 | httpClient: HttpClient
53 | ): PokeApiService {
54 | return DefaultPokeApiService(
55 | httpClient
56 | )
57 | }
58 |
59 | }
60 |
--------------------------------------------------------------------------------
/pokedex/app/src/main/java/com/kfaraj/samples/pokedex/domain/FormatIdUseCase.kt:
--------------------------------------------------------------------------------
1 | package com.kfaraj.samples.pokedex.domain
2 |
3 | import java.util.Locale
4 | import javax.inject.Inject
5 |
6 | /**
7 | * Formats the ID of the Pokémon.
8 | */
9 | class FormatIdUseCase @Inject constructor() {
10 |
11 | operator fun invoke(id: Int): String {
12 | return "#%03d".format(Locale.getDefault(), id)
13 | }
14 |
15 | }
16 |
--------------------------------------------------------------------------------
/pokedex/app/src/main/java/com/kfaraj/samples/pokedex/domain/FormatNameUseCase.kt:
--------------------------------------------------------------------------------
1 | package com.kfaraj.samples.pokedex.domain
2 |
3 | import java.util.Locale
4 | import javax.inject.Inject
5 |
6 | /**
7 | * Formats the name of the Pokémon.
8 | */
9 | class FormatNameUseCase @Inject constructor() {
10 |
11 | operator fun invoke(name: String): String {
12 | return name.replaceFirstChar { it.titlecase(Locale.getDefault()) }
13 | }
14 |
15 | }
16 |
--------------------------------------------------------------------------------
/pokedex/app/src/main/java/com/kfaraj/samples/pokedex/domain/GetSpriteUseCase.kt:
--------------------------------------------------------------------------------
1 | package com.kfaraj.samples.pokedex.domain
2 |
3 | import javax.inject.Inject
4 |
5 | /**
6 | * Returns the depiction of the Pokémon in battle.
7 | */
8 | class GetSpriteUseCase @Inject constructor() {
9 |
10 | operator fun invoke(id: Int): String {
11 | return "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/$id.png"
12 | }
13 |
14 | }
15 |
--------------------------------------------------------------------------------
/pokedex/app/src/main/java/com/kfaraj/samples/pokedex/ui/PokedexFragment.kt:
--------------------------------------------------------------------------------
1 | package com.kfaraj.samples.pokedex.ui
2 |
3 | import android.os.Bundle
4 | import android.view.View
5 | import android.view.ViewGroup
6 | import androidx.core.view.ViewCompat
7 | import androidx.core.view.WindowInsetsCompat
8 | import androidx.core.view.doOnPreDraw
9 | import androidx.fragment.app.Fragment
10 | import androidx.fragment.app.viewModels
11 | import androidx.lifecycle.Lifecycle
12 | import androidx.lifecycle.lifecycleScope
13 | import androidx.lifecycle.repeatOnLifecycle
14 | import androidx.navigation.findNavController
15 | import androidx.navigation.fragment.FragmentNavigatorExtras
16 | import androidx.recyclerview.widget.RecyclerView
17 | import com.google.android.material.transition.MaterialElevationScale
18 | import com.kfaraj.samples.pokedex.R
19 | import com.kfaraj.samples.pokedex.domain.FormatIdUseCase
20 | import com.kfaraj.samples.pokedex.domain.FormatNameUseCase
21 | import com.kfaraj.samples.pokedex.util.applyWindowInsetsPadding
22 | import dagger.hilt.android.AndroidEntryPoint
23 | import kotlinx.coroutines.flow.collectLatest
24 | import kotlinx.coroutines.launch
25 | import javax.inject.Inject
26 |
27 | /**
28 | * Displays the Pokédex UI state on the screen.
29 | */
30 | @AndroidEntryPoint
31 | class PokedexFragment : Fragment(R.layout.fragment_pokedex) {
32 |
33 | @Inject
34 | lateinit var formatIdUseCase: FormatIdUseCase
35 |
36 | @Inject
37 | lateinit var formatNameUseCase: FormatNameUseCase
38 |
39 | private val viewModel by viewModels()
40 |
41 | override fun onCreate(savedInstanceState: Bundle?) {
42 | super.onCreate(savedInstanceState)
43 | exitTransition = MaterialElevationScale(false)
44 | reenterTransition = MaterialElevationScale(true)
45 | }
46 |
47 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
48 | super.onViewCreated(view, savedInstanceState)
49 | val recyclerView = ViewCompat.requireViewById(view, android.R.id.list)
50 | val adapter = PokedexItemAdapter(
51 | PokedexItemCallback(),
52 | formatIdUseCase,
53 | formatNameUseCase
54 | ) { v, item ->
55 | item?.id?.let {
56 | val extras = FragmentNavigatorExtras(v to "container")
57 | v.findNavController().navigateToPokemonDestination(it, extras)
58 | }
59 | }
60 | recyclerView.setHasFixedSize(true)
61 | recyclerView.adapter = adapter
62 | recyclerView.applyWindowInsetsPadding(
63 | WindowInsetsCompat.Type.systemBars(),
64 | applyBottom = true
65 | )
66 | (view as? ViewGroup)?.isTransitionGroup = true
67 | postponeEnterTransition()
68 | viewLifecycleOwner.lifecycleScope.launch {
69 | viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
70 | viewModel.pagingData.collectLatest { pagingData ->
71 | (view.parent as? ViewGroup)?.doOnPreDraw {
72 | startPostponedEnterTransition()
73 | }
74 | adapter.submitData(pagingData)
75 | }
76 | }
77 | }
78 | }
79 |
80 | }
81 |
--------------------------------------------------------------------------------
/pokedex/app/src/main/java/com/kfaraj/samples/pokedex/ui/PokedexItemAdapter.kt:
--------------------------------------------------------------------------------
1 | package com.kfaraj.samples.pokedex.ui
2 |
3 | import android.view.View
4 | import android.view.ViewGroup
5 | import androidx.paging.PagingDataAdapter
6 | import androidx.recyclerview.widget.DiffUtil
7 | import com.kfaraj.samples.pokedex.domain.FormatIdUseCase
8 | import com.kfaraj.samples.pokedex.domain.FormatNameUseCase
9 |
10 | /**
11 | * Displays the paged Pokédex items UI states on the screen.
12 | */
13 | class PokedexItemAdapter(
14 | diffCallback: DiffUtil.ItemCallback,
15 | private val formatIdUseCase: FormatIdUseCase,
16 | private val formatNameUseCase: FormatNameUseCase,
17 | private val onClick: (v: View, item: PokedexItemUiState?) -> Unit
18 | ) : PagingDataAdapter(diffCallback) {
19 |
20 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PokedexItemViewHolder {
21 | return PokedexItemViewHolder(
22 | parent,
23 | formatIdUseCase,
24 | formatNameUseCase
25 | ) { v, position ->
26 | val item = getItem(position)
27 | onClick(v, item)
28 | }
29 | }
30 |
31 | override fun onBindViewHolder(holder: PokedexItemViewHolder, position: Int) {
32 | val item = getItem(position)
33 | holder.bind(item)
34 | }
35 |
36 | }
37 |
--------------------------------------------------------------------------------
/pokedex/app/src/main/java/com/kfaraj/samples/pokedex/ui/PokedexItemCallback.kt:
--------------------------------------------------------------------------------
1 | package com.kfaraj.samples.pokedex.ui
2 |
3 | import androidx.recyclerview.widget.DiffUtil
4 |
5 | /**
6 | * Calculates the diff between two non-null Pokédex items UI states in a list.
7 | */
8 | class PokedexItemCallback : DiffUtil.ItemCallback() {
9 |
10 | override fun areItemsTheSame(
11 | oldItem: PokedexItemUiState,
12 | newItem: PokedexItemUiState
13 | ): Boolean {
14 | return oldItem.id == newItem.id
15 | }
16 |
17 | override fun areContentsTheSame(
18 | oldItem: PokedexItemUiState,
19 | newItem: PokedexItemUiState
20 | ): Boolean {
21 | return oldItem == newItem
22 | }
23 |
24 | }
25 |
--------------------------------------------------------------------------------
/pokedex/app/src/main/java/com/kfaraj/samples/pokedex/ui/PokedexItemUiState.kt:
--------------------------------------------------------------------------------
1 | package com.kfaraj.samples.pokedex.ui
2 |
3 | /**
4 | * Contains the Pokédex item UI state.
5 | */
6 | data class PokedexItemUiState(
7 | val id: Int,
8 | val name: String,
9 | val sprite: String
10 | )
11 |
--------------------------------------------------------------------------------
/pokedex/app/src/main/java/com/kfaraj/samples/pokedex/ui/PokedexItemViewHolder.kt:
--------------------------------------------------------------------------------
1 | package com.kfaraj.samples.pokedex.ui
2 |
3 | import android.view.LayoutInflater
4 | import android.view.View
5 | import android.view.ViewGroup
6 | import android.widget.ImageView
7 | import android.widget.TextView
8 | import androidx.core.view.ViewCompat
9 | import androidx.recyclerview.widget.RecyclerView
10 | import coil3.load
11 | import coil3.request.allowHardware
12 | import com.kfaraj.samples.pokedex.R
13 | import com.kfaraj.samples.pokedex.domain.FormatIdUseCase
14 | import com.kfaraj.samples.pokedex.domain.FormatNameUseCase
15 |
16 | /**
17 | * Displays the Pokédex item UI state on the screen.
18 | */
19 | class PokedexItemViewHolder(
20 | parent: ViewGroup,
21 | private val formatIdUseCase: FormatIdUseCase,
22 | private val formatNameUseCase: FormatNameUseCase,
23 | onClick: (v: View, position: Int) -> Unit
24 | ) : RecyclerView.ViewHolder(
25 | LayoutInflater.from(parent.context).inflate(R.layout.item_card, parent, false)
26 | ) {
27 |
28 | private val mediaView = ViewCompat.requireViewById(itemView, R.id.media)
29 | private val titleView = ViewCompat.requireViewById(itemView, R.id.title)
30 | private val bodyView = ViewCompat.requireViewById(itemView, R.id.body)
31 |
32 | init {
33 | itemView.setOnClickListener { v ->
34 | onClick(v, bindingAdapterPosition)
35 | }
36 | }
37 |
38 | /**
39 | * Binds the [item] with the view.
40 | */
41 | fun bind(item: PokedexItemUiState?) {
42 | itemView.transitionName = item?.id?.toString()
43 | mediaView.load(item?.sprite) {
44 | allowHardware(false)
45 | }
46 | titleView.text = item?.id?.let { formatIdUseCase(it) }
47 | bodyView.text = item?.name?.let { formatNameUseCase(it) }
48 | }
49 |
50 | }
51 |
--------------------------------------------------------------------------------
/pokedex/app/src/main/java/com/kfaraj/samples/pokedex/ui/PokedexNavigation.kt:
--------------------------------------------------------------------------------
1 | package com.kfaraj.samples.pokedex.ui
2 |
3 | import android.content.res.Resources
4 | import androidx.navigation.NavGraphBuilder
5 | import androidx.navigation.fragment.fragment
6 | import com.kfaraj.samples.pokedex.R
7 | import kotlinx.serialization.Serializable
8 |
9 | /**
10 | * Describes the Pokédex route.
11 | */
12 | @Serializable
13 | data object PokedexRoute
14 |
15 | /**
16 | * Adds the Pokédex destination.
17 | */
18 | fun NavGraphBuilder.pokedexDestination(
19 | resources: Resources
20 | ) {
21 | fragment {
22 | label = resources.getString(R.string.app_name)
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/pokedex/app/src/main/java/com/kfaraj/samples/pokedex/ui/PokedexViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.kfaraj.samples.pokedex.ui
2 |
3 | import androidx.lifecycle.ViewModel
4 | import androidx.lifecycle.viewModelScope
5 | import androidx.paging.PagingConfig
6 | import androidx.paging.cachedIn
7 | import androidx.paging.map
8 | import com.kfaraj.samples.pokedex.data.Pokemon
9 | import com.kfaraj.samples.pokedex.data.PokemonsRepository
10 | import com.kfaraj.samples.pokedex.domain.GetSpriteUseCase
11 | import dagger.hilt.android.lifecycle.HiltViewModel
12 | import kotlinx.coroutines.flow.map
13 | import javax.inject.Inject
14 |
15 | /**
16 | * Exposes the Pokédex UI state.
17 | */
18 | @HiltViewModel
19 | class PokedexViewModel @Inject constructor(
20 | pokemonsRepository: PokemonsRepository,
21 | getSpriteUseCase: GetSpriteUseCase
22 | ) : ViewModel() {
23 |
24 | /**
25 | * The stream of paged Pokédex items UI states.
26 | */
27 | val pagingData = pokemonsRepository.getPagingDataStream(PagingConfig(PAGE_SIZE))
28 | .map { pagingData ->
29 | pagingData.map { pokemon ->
30 | pokemon.toPokedexItemUiState(getSpriteUseCase)
31 | }
32 | }
33 | .cachedIn(viewModelScope)
34 |
35 | /**
36 | * Converts the model from the data layer to the UI layer.
37 | */
38 | private fun Pokemon.toPokedexItemUiState(
39 | getSpriteUseCase: GetSpriteUseCase
40 | ): PokedexItemUiState {
41 | return PokedexItemUiState(
42 | id,
43 | name,
44 | getSpriteUseCase(id)
45 | )
46 | }
47 |
48 | companion object {
49 | private const val PAGE_SIZE = 50
50 | }
51 |
52 | }
53 |
--------------------------------------------------------------------------------
/pokedex/app/src/main/java/com/kfaraj/samples/pokedex/ui/PokemonFragment.kt:
--------------------------------------------------------------------------------
1 | package com.kfaraj.samples.pokedex.ui
2 |
3 | import android.graphics.Color
4 | import android.os.Bundle
5 | import android.view.View
6 | import android.view.ViewGroup
7 | import android.widget.ImageView
8 | import android.widget.TextView
9 | import androidx.core.view.ViewCompat
10 | import androidx.core.view.doOnPreDraw
11 | import androidx.fragment.app.Fragment
12 | import androidx.fragment.app.viewModels
13 | import androidx.lifecycle.Lifecycle
14 | import androidx.lifecycle.lifecycleScope
15 | import androidx.lifecycle.repeatOnLifecycle
16 | import coil3.load
17 | import coil3.request.allowHardware
18 | import com.google.android.material.color.MaterialColors
19 | import com.google.android.material.transition.MaterialContainerTransform
20 | import com.kfaraj.samples.pokedex.R
21 | import com.kfaraj.samples.pokedex.domain.FormatIdUseCase
22 | import com.kfaraj.samples.pokedex.domain.FormatNameUseCase
23 | import dagger.hilt.android.AndroidEntryPoint
24 | import kotlinx.coroutines.launch
25 | import javax.inject.Inject
26 |
27 | /**
28 | * Displays the Pokémon UI state on the screen.
29 | */
30 | @AndroidEntryPoint
31 | class PokemonFragment : Fragment(R.layout.fragment_pokemon) {
32 |
33 | @Inject
34 | lateinit var formatIdUseCase: FormatIdUseCase
35 |
36 | @Inject
37 | lateinit var formatNameUseCase: FormatNameUseCase
38 |
39 | private val viewModel by viewModels()
40 |
41 | private lateinit var mediaView: ImageView
42 | private lateinit var titleView: TextView
43 | private lateinit var bodyView: TextView
44 |
45 | override fun onCreate(savedInstanceState: Bundle?) {
46 | super.onCreate(savedInstanceState)
47 | val context = requireContext()
48 | sharedElementEnterTransition = MaterialContainerTransform().apply {
49 | setAllContainerColors(
50 | MaterialColors.getColor(
51 | context,
52 | android.R.attr.colorBackground,
53 | Color.TRANSPARENT
54 | )
55 | )
56 | scrimColor = Color.TRANSPARENT
57 | }
58 | }
59 |
60 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
61 | super.onViewCreated(view, savedInstanceState)
62 | mediaView = ViewCompat.requireViewById(view, R.id.media)
63 | titleView = ViewCompat.requireViewById(view, R.id.title)
64 | bodyView = ViewCompat.requireViewById(view, R.id.body)
65 | view.transitionName = "container"
66 | postponeEnterTransition()
67 | viewLifecycleOwner.lifecycleScope.launch {
68 | viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
69 | viewModel.uiState.collect { uiState ->
70 | (view.parent as? ViewGroup)?.doOnPreDraw {
71 | startPostponedEnterTransition()
72 | }
73 | bind(uiState)
74 | }
75 | }
76 | }
77 | }
78 |
79 | /**
80 | * Binds the [uiState] with the view.
81 | */
82 | private fun bind(uiState: PokemonUiState) {
83 | mediaView.load(uiState.sprite) {
84 | allowHardware(false)
85 | }
86 | titleView.text = uiState.id?.let { formatIdUseCase(it) }
87 | bodyView.text = uiState.name?.let { formatNameUseCase(it) }
88 | }
89 |
90 | }
91 |
--------------------------------------------------------------------------------
/pokedex/app/src/main/java/com/kfaraj/samples/pokedex/ui/PokemonNavigation.kt:
--------------------------------------------------------------------------------
1 | package com.kfaraj.samples.pokedex.ui
2 |
3 | import android.content.res.Resources
4 | import androidx.navigation.NavController
5 | import androidx.navigation.NavGraphBuilder
6 | import androidx.navigation.Navigator
7 | import androidx.navigation.fragment.fragment
8 | import com.kfaraj.samples.pokedex.R
9 | import kotlinx.serialization.Serializable
10 |
11 | /**
12 | * Describes the Pokémon route.
13 | */
14 | @Serializable
15 | data class PokemonRoute(
16 | val id: Int
17 | )
18 |
19 | /**
20 | * Adds the Pokémon destination.
21 | */
22 | fun NavGraphBuilder.pokemonDestination(
23 | resources: Resources
24 | ) {
25 | fragment {
26 | label = resources.getString(R.string.empty)
27 | }
28 | }
29 |
30 | /**
31 | * Navigates to the Pokémon destination.
32 | */
33 | fun NavController.navigateToPokemonDestination(
34 | id: Int,
35 | navigatorExtras: Navigator.Extras
36 | ) {
37 | val route = PokemonRoute(id)
38 | navigate(route, null, navigatorExtras)
39 | }
40 |
--------------------------------------------------------------------------------
/pokedex/app/src/main/java/com/kfaraj/samples/pokedex/ui/PokemonUiState.kt:
--------------------------------------------------------------------------------
1 | package com.kfaraj.samples.pokedex.ui
2 |
3 | /**
4 | * Contains the Pokémon UI state.
5 | */
6 | data class PokemonUiState(
7 | val id: Int? = null,
8 | val name: String? = null,
9 | val sprite: String? = null
10 | )
11 |
--------------------------------------------------------------------------------
/pokedex/app/src/main/java/com/kfaraj/samples/pokedex/ui/PokemonViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.kfaraj.samples.pokedex.ui
2 |
3 | import androidx.lifecycle.SavedStateHandle
4 | import androidx.lifecycle.ViewModel
5 | import androidx.lifecycle.viewModelScope
6 | import androidx.navigation.toRoute
7 | import com.kfaraj.samples.pokedex.data.Pokemon
8 | import com.kfaraj.samples.pokedex.data.PokemonsRepository
9 | import com.kfaraj.samples.pokedex.domain.GetSpriteUseCase
10 | import dagger.hilt.android.lifecycle.HiltViewModel
11 | import kotlinx.coroutines.flow.MutableStateFlow
12 | import kotlinx.coroutines.flow.asStateFlow
13 | import kotlinx.coroutines.launch
14 | import javax.inject.Inject
15 |
16 | /**
17 | * Exposes the Pokémon UI state.
18 | */
19 | @HiltViewModel
20 | class PokemonViewModel @Inject constructor(
21 | savedStateHandle: SavedStateHandle,
22 | pokemonsRepository: PokemonsRepository,
23 | getSpriteUseCase: GetSpriteUseCase
24 | ) : ViewModel() {
25 |
26 | private val _uiState = MutableStateFlow(PokemonUiState())
27 |
28 | /**
29 | * The stream of Pokémon UI state.
30 | */
31 | val uiState = _uiState.asStateFlow()
32 |
33 | init {
34 | viewModelScope.launch {
35 | val id = savedStateHandle.toRoute().id
36 | val pokemon = pokemonsRepository.get(id)
37 | _uiState.value = pokemon.toPokemonUiState(getSpriteUseCase)
38 | }
39 | }
40 |
41 | /**
42 | * Converts the model from the data layer to the UI layer.
43 | */
44 | private fun Pokemon.toPokemonUiState(
45 | getSpriteUseCase: GetSpriteUseCase
46 | ): PokemonUiState {
47 | return PokemonUiState(
48 | id,
49 | name,
50 | getSpriteUseCase(id)
51 | )
52 | }
53 |
54 | }
55 |
--------------------------------------------------------------------------------
/pokedex/app/src/main/java/com/kfaraj/samples/pokedex/util/EdgeToEdge.kt:
--------------------------------------------------------------------------------
1 | package com.kfaraj.samples.pokedex.util
2 |
3 | import android.view.View
4 | import androidx.core.view.ViewCompat
5 | import androidx.core.view.updatePadding
6 |
7 | /**
8 | * Increases padding to avoid visual overlap caused by edge-to-edge.
9 | */
10 | fun View.applyWindowInsetsPadding(
11 | typeMask: Int,
12 | applyLeft: Boolean = false,
13 | applyTop: Boolean = false,
14 | applyRight: Boolean = false,
15 | applyBottom: Boolean = false
16 | ) {
17 | val paddingLeft = paddingLeft
18 | val paddingTop = paddingTop
19 | val paddingRight = paddingRight
20 | val paddingBottom = paddingBottom
21 | ViewCompat.setOnApplyWindowInsetsListener(this) { v, insets ->
22 | val typeInsets = insets.getInsets(typeMask)
23 | v.updatePadding(
24 | left = paddingLeft + if (applyLeft) typeInsets.left else 0,
25 | top = paddingTop + if (applyTop) typeInsets.top else 0,
26 | right = paddingRight + if (applyRight) typeInsets.right else 0,
27 | bottom = paddingBottom + if (applyBottom) typeInsets.bottom else 0
28 | )
29 | insets
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/pokedex/app/src/main/res/drawable/ic_launcher_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
10 |
11 |
--------------------------------------------------------------------------------
/pokedex/app/src/main/res/drawable/ic_launcher_foreground.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
12 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/pokedex/app/src/main/res/drawable/ic_launcher_monochrome.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
12 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/pokedex/app/src/main/res/layout/activity_main.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
12 |
13 |
18 |
19 |
20 |
21 |
28 |
29 |
30 |
--------------------------------------------------------------------------------
/pokedex/app/src/main/res/layout/fragment_pokedex.xml:
--------------------------------------------------------------------------------
1 |
2 |
11 |
--------------------------------------------------------------------------------
/pokedex/app/src/main/res/layout/fragment_pokemon.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
15 |
16 |
21 |
22 |
27 |
28 |
34 |
35 |
36 |
37 |
38 |
--------------------------------------------------------------------------------
/pokedex/app/src/main/res/layout/item_card.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
13 |
14 |
21 |
22 |
27 |
28 |
33 |
34 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
--------------------------------------------------------------------------------
/pokedex/app/src/main/res/mipmap/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/pokedex/app/src/main/res/mipmap/ic_launcher_round.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/pokedex/app/src/main/res/values-night/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/pokedex/app/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #ff00201c
4 | #ff003731
5 | #ff005048
6 | #ff006a60
7 | #ff82d5c8
8 | #ff9ef2e4
9 | #ffffffff
10 | #ff05201c
11 | #ff1c3531
12 | #ff334b47
13 | #ff4a635f
14 | #ffb1ccc6
15 | #ffcce8e2
16 | #ffffffff
17 | #ff001e31
18 | #ff153349
19 | #ff2d4961
20 | #ff456179
21 | #ffadcae6
22 | #ffcce5ff
23 | #ffffffff
24 | #ff090f0e
25 | #ff0e1513
26 | #ff161d1c
27 | #ff1a2120
28 | #ff252b2a
29 | #ff2b3230
30 | #ff303635
31 | #ff343b39
32 | #ffd5dbd9
33 | #ffdde4e1
34 | #ffe3eae7
35 | #ffe9efed
36 | #ffecf2ef
37 | #ffeff5f2
38 | #fff4fbf8
39 | #ffffffff
40 | #ff3f4947
41 | #ff6f7977
42 | #ff899390
43 | #ffbec9c6
44 | #ffdae5e1
45 |
46 |
--------------------------------------------------------------------------------
/pokedex/app/src/main/res/values/dimens.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | 4dp
4 | 16dp
5 |
6 |
--------------------------------------------------------------------------------
/pokedex/app/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | Pokédex
4 |
5 |
6 |
--------------------------------------------------------------------------------
/pokedex/app/src/main/res/values/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
47 |
91 |
92 |
93 |
--------------------------------------------------------------------------------
/pokedex/app/src/test/java/com/kfaraj/samples/pokedex/MainActivityTest.kt:
--------------------------------------------------------------------------------
1 | package com.kfaraj.samples.pokedex
2 |
3 | import androidx.test.core.app.launchActivity
4 | import org.junit.Test
5 | import org.junit.runner.RunWith
6 | import org.robolectric.RobolectricTestRunner
7 |
8 | @RunWith(RobolectricTestRunner::class)
9 | class MainActivityTest {
10 |
11 | @Test
12 | fun launchActivity() {
13 | launchActivity()
14 | }
15 |
16 | }
17 |
--------------------------------------------------------------------------------
/pokedex/app/src/test/java/com/kfaraj/samples/pokedex/data/PokemonsRemoteMediatorTest.kt:
--------------------------------------------------------------------------------
1 | package com.kfaraj.samples.pokedex.data
2 |
3 | import androidx.paging.LoadType
4 | import androidx.paging.PagingConfig
5 | import androidx.paging.PagingState
6 | import androidx.paging.RemoteMediator.InitializeAction
7 | import androidx.paging.RemoteMediator.MediatorResult
8 | import com.kfaraj.samples.pokedex.data.local.PokemonEntity
9 | import com.kfaraj.samples.pokedex.data.local.PokemonsLocalDataSource
10 | import com.kfaraj.samples.pokedex.data.remote.NamedApiResource
11 | import com.kfaraj.samples.pokedex.data.remote.NamedApiResourceList
12 | import com.kfaraj.samples.pokedex.data.remote.PokemonsRemoteDataSource
13 | import kotlinx.coroutines.test.runTest
14 | import org.junit.Assert.assertEquals
15 | import org.junit.Assert.assertTrue
16 | import org.junit.Test
17 | import org.mockito.kotlin.mock
18 | import org.mockito.kotlin.verify
19 | import org.mockito.kotlin.whenever
20 |
21 | class PokemonsRemoteMediatorTest {
22 |
23 | @Test
24 | fun load_refresh() = runTest {
25 | val response = NamedApiResourceList(1, null, null, listOf(BULBASAUR_API_RESOURCE))
26 | val pokemonsRemoteDataSource = mock().apply {
27 | whenever(getPokemon(1, 0)).thenReturn(response)
28 | }
29 | val pokemonsLocalDataSource = mock().apply {
30 | whenever(getCount()).thenReturn(0)
31 | }
32 | val pokemonsRemoteMediator = PokemonsRemoteMediator(
33 | pokemonsRemoteDataSource,
34 | pokemonsLocalDataSource
35 | )
36 | val state = PagingState(emptyList(), null, PagingConfig(1), 0)
37 | val result = pokemonsRemoteMediator.load(LoadType.REFRESH, state)
38 | verify(pokemonsLocalDataSource).upsertAll(listOf(BULBASAUR_ENTITY))
39 | assertTrue(result is MediatorResult.Success)
40 | assertTrue((result as MediatorResult.Success).endOfPaginationReached)
41 | }
42 |
43 | @Test
44 | fun load_append() = runTest {
45 | val response = NamedApiResourceList(1, null, "/", emptyList())
46 | val pokemonsRemoteDataSource = mock().apply {
47 | whenever(getPokemon(1, 1)).thenReturn(response)
48 | }
49 | val pokemonsLocalDataSource = mock().apply {
50 | whenever(getCount()).thenReturn(1)
51 | }
52 | val pokemonsRemoteMediator = PokemonsRemoteMediator(
53 | pokemonsRemoteDataSource,
54 | pokemonsLocalDataSource
55 | )
56 | val state = PagingState(emptyList(), null, PagingConfig(1), 0)
57 | val result = pokemonsRemoteMediator.load(LoadType.APPEND, state)
58 | verify(pokemonsLocalDataSource).upsertAll(emptyList())
59 | assertTrue(result is MediatorResult.Success)
60 | assertTrue((result as MediatorResult.Success).endOfPaginationReached)
61 | }
62 |
63 | @Test
64 | fun initialize_launchInitialRefresh() = runTest {
65 | val pokemonsRemoteDataSource = mock()
66 | val pokemonsLocalDataSource = mock().apply {
67 | whenever(getCount()).thenReturn(0)
68 | }
69 | val pokemonsRemoteMediator = PokemonsRemoteMediator(
70 | pokemonsRemoteDataSource,
71 | pokemonsLocalDataSource
72 | )
73 | val result = pokemonsRemoteMediator.initialize()
74 | assertEquals(InitializeAction.LAUNCH_INITIAL_REFRESH, result)
75 | }
76 |
77 | @Test
78 | fun initialize_skipInitialRefresh() = runTest {
79 | val pokemonsRemoteDataSource = mock()
80 | val pokemonsLocalDataSource = mock().apply {
81 | whenever(getCount()).thenReturn(1)
82 | }
83 | val pokemonsRemoteMediator = PokemonsRemoteMediator(
84 | pokemonsRemoteDataSource,
85 | pokemonsLocalDataSource
86 | )
87 | val result = pokemonsRemoteMediator.initialize()
88 | assertEquals(InitializeAction.SKIP_INITIAL_REFRESH, result)
89 | }
90 |
91 | companion object {
92 | private val BULBASAUR_API_RESOURCE = NamedApiResource("bulbasaur", "/1/")
93 | private val BULBASAUR_ENTITY = PokemonEntity(1, "bulbasaur")
94 | }
95 |
96 | }
97 |
--------------------------------------------------------------------------------
/pokedex/app/src/test/java/com/kfaraj/samples/pokedex/data/PokemonsRepositoryTest.kt:
--------------------------------------------------------------------------------
1 | package com.kfaraj.samples.pokedex.data
2 |
3 | import androidx.paging.PagingConfig
4 | import androidx.paging.testing.asPagingSourceFactory
5 | import androidx.paging.testing.asSnapshot
6 | import com.kfaraj.samples.pokedex.data.local.PokemonEntity
7 | import com.kfaraj.samples.pokedex.data.local.PokemonsLocalDataSource
8 | import com.kfaraj.samples.pokedex.data.remote.NamedApiResourceList
9 | import com.kfaraj.samples.pokedex.data.remote.PokemonsRemoteDataSource
10 | import com.kfaraj.samples.pokedex.testutils.MainDispatcherRule
11 | import kotlinx.coroutines.test.runTest
12 | import org.junit.Assert.assertEquals
13 | import org.junit.Rule
14 | import org.junit.Test
15 | import org.mockito.kotlin.mock
16 | import org.mockito.kotlin.whenever
17 |
18 | class PokemonsRepositoryTest {
19 |
20 | @get:Rule
21 | val mainDispatcherRule = MainDispatcherRule()
22 |
23 | @Test
24 | fun get() = runTest {
25 | val pokemonsRemoteDataSource = mock()
26 | val pokemonsLocalDataSource = mock().apply {
27 | whenever(get(1)).thenReturn(BULBASAUR_ENTITY)
28 | }
29 | val pokemonsRepository = PokemonsRepository(
30 | pokemonsRemoteDataSource,
31 | pokemonsLocalDataSource
32 | )
33 | val result = pokemonsRepository.get(1)
34 | assertEquals(BULBASAUR, result)
35 | }
36 |
37 | @Test
38 | fun getPagingDataStream() = runTest {
39 | val response = NamedApiResourceList(1, null, "/", emptyList())
40 | val pokemonsRemoteDataSource = mock().apply {
41 | whenever(getPokemon(1, 1)).thenReturn(response)
42 | }
43 | val pagingSourceFactory = listOf(BULBASAUR_ENTITY).asPagingSourceFactory()
44 | val pagingSource = pagingSourceFactory()
45 | val pokemonsLocalDataSource = mock().apply {
46 | whenever(getPagingSource()).thenReturn(pagingSource)
47 | whenever(getCount()).thenReturn(1)
48 | }
49 | val pokemonsRepository = PokemonsRepository(
50 | pokemonsRemoteDataSource,
51 | pokemonsLocalDataSource
52 | )
53 | val result = pokemonsRepository.getPagingDataStream(PagingConfig(1)).asSnapshot()
54 | assertEquals(listOf(BULBASAUR), result)
55 | }
56 |
57 | companion object {
58 | private val BULBASAUR_ENTITY = PokemonEntity(1, "bulbasaur")
59 | private val BULBASAUR = Pokemon(1, "bulbasaur")
60 | }
61 |
62 | }
63 |
--------------------------------------------------------------------------------
/pokedex/app/src/test/java/com/kfaraj/samples/pokedex/data/local/PokemonsLocalDataSourceTest.kt:
--------------------------------------------------------------------------------
1 | package com.kfaraj.samples.pokedex.data.local
2 |
3 | import androidx.paging.testing.asPagingSourceFactory
4 | import kotlinx.coroutines.test.runTest
5 | import org.junit.Assert.assertEquals
6 | import org.junit.Test
7 | import org.mockito.kotlin.mock
8 | import org.mockito.kotlin.verify
9 | import org.mockito.kotlin.whenever
10 |
11 | class PokemonsLocalDataSourceTest {
12 |
13 | @Test
14 | fun upsertAll() = runTest {
15 | val pokemons = listOf(BULBASAUR_ENTITY)
16 | val pokemonDao = mock()
17 | val pokemonsLocalDataSource = PokemonsLocalDataSource(
18 | pokemonDao
19 | )
20 | pokemonsLocalDataSource.upsertAll(pokemons)
21 | verify(pokemonDao).upsertAll(pokemons)
22 | }
23 |
24 | @Test
25 | fun get() = runTest {
26 | val pokemonDao = mock().apply {
27 | whenever(get(1)).thenReturn(BULBASAUR_ENTITY)
28 | }
29 | val pokemonsLocalDataSource = PokemonsLocalDataSource(
30 | pokemonDao
31 | )
32 | val result = pokemonsLocalDataSource.get(1)
33 | assertEquals(BULBASAUR_ENTITY, result)
34 | }
35 |
36 | @Test
37 | fun getPagingSource() {
38 | val pagingSourceFactory = listOf(BULBASAUR_ENTITY).asPagingSourceFactory()
39 | val pagingSource = pagingSourceFactory()
40 | val pokemonDao = mock().apply {
41 | whenever(getPagingSource()).thenReturn(pagingSource)
42 | }
43 | val pokemonsLocalDataSource = PokemonsLocalDataSource(
44 | pokemonDao
45 | )
46 | val result = pokemonsLocalDataSource.getPagingSource()
47 | assertEquals(pagingSource, result)
48 | }
49 |
50 | @Test
51 | fun getCount() = runTest {
52 | val pokemonDao = mock().apply {
53 | whenever(getCount()).thenReturn(1)
54 | }
55 | val pokemonsLocalDataSource = PokemonsLocalDataSource(
56 | pokemonDao
57 | )
58 | val result = pokemonsLocalDataSource.getCount()
59 | assertEquals(1, result)
60 | }
61 |
62 | companion object {
63 | private val BULBASAUR_ENTITY = PokemonEntity(1, "bulbasaur")
64 | }
65 |
66 | }
67 |
--------------------------------------------------------------------------------
/pokedex/app/src/test/java/com/kfaraj/samples/pokedex/data/remote/PokemonsRemoteDataSourceTest.kt:
--------------------------------------------------------------------------------
1 | package com.kfaraj.samples.pokedex.data.remote
2 |
3 | import kotlinx.coroutines.test.runTest
4 | import org.junit.Assert.assertEquals
5 | import org.junit.Test
6 | import org.mockito.kotlin.mock
7 | import org.mockito.kotlin.whenever
8 |
9 | class PokemonsRemoteDataSourceTest {
10 |
11 | @Test
12 | fun getPokemon() = runTest {
13 | val response = NamedApiResourceList(1, null, null, listOf(BULBASAUR_API_RESOURCE))
14 | val pokeApiService = mock().apply {
15 | whenever(getPokemon(1, 0)).thenReturn(response)
16 | }
17 | val pokemonsRemoteDataSource = PokemonsRemoteDataSource(
18 | pokeApiService
19 | )
20 | val result = pokemonsRemoteDataSource.getPokemon(1, 0)
21 | assertEquals(response, result)
22 | }
23 |
24 | companion object {
25 | private val BULBASAUR_API_RESOURCE = NamedApiResource("bulbasaur", "/1/")
26 | }
27 |
28 | }
29 |
--------------------------------------------------------------------------------
/pokedex/app/src/test/java/com/kfaraj/samples/pokedex/domain/FormatIdUseCaseTest.kt:
--------------------------------------------------------------------------------
1 | package com.kfaraj.samples.pokedex.domain
2 |
3 | import org.junit.Assert.assertEquals
4 | import org.junit.Test
5 |
6 | class FormatIdUseCaseTest {
7 |
8 | @Test
9 | fun formatIdUseCase() {
10 | val formatIdUseCase = FormatIdUseCase()
11 | val result = formatIdUseCase(1)
12 | assertEquals("#001", result)
13 | }
14 |
15 | }
16 |
--------------------------------------------------------------------------------
/pokedex/app/src/test/java/com/kfaraj/samples/pokedex/domain/FormatNameUseCaseTest.kt:
--------------------------------------------------------------------------------
1 | package com.kfaraj.samples.pokedex.domain
2 |
3 | import org.junit.Assert.assertEquals
4 | import org.junit.Test
5 |
6 | class FormatNameUseCaseTest {
7 |
8 | @Test
9 | fun formatNameUseCase() {
10 | val formatNameUseCase = FormatNameUseCase()
11 | val result = formatNameUseCase("bulbasaur")
12 | assertEquals("Bulbasaur", result)
13 | }
14 |
15 | }
16 |
--------------------------------------------------------------------------------
/pokedex/app/src/test/java/com/kfaraj/samples/pokedex/domain/GetSpriteUseCaseTest.kt:
--------------------------------------------------------------------------------
1 | package com.kfaraj.samples.pokedex.domain
2 |
3 | import org.junit.Assert.assertEquals
4 | import org.junit.Test
5 |
6 | class GetSpriteUseCaseTest {
7 |
8 | @Test
9 | fun getSpriteUseCase() {
10 | val getSpriteUseCase = GetSpriteUseCase()
11 | val result = getSpriteUseCase(1)
12 | assertEquals(
13 | "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/1.png",
14 | result
15 | )
16 | }
17 |
18 | }
19 |
--------------------------------------------------------------------------------
/pokedex/app/src/test/java/com/kfaraj/samples/pokedex/testutils/MainDispatcherRule.kt:
--------------------------------------------------------------------------------
1 | package com.kfaraj.samples.pokedex.testutils
2 |
3 | import kotlinx.coroutines.CoroutineDispatcher
4 | import kotlinx.coroutines.Dispatchers
5 | import kotlinx.coroutines.test.TestDispatcher
6 | import kotlinx.coroutines.test.UnconfinedTestDispatcher
7 | import kotlinx.coroutines.test.resetMain
8 | import kotlinx.coroutines.test.setMain
9 | import org.junit.rules.TestWatcher
10 | import org.junit.runner.Description
11 |
12 | /**
13 | * Replaces the main [CoroutineDispatcher].
14 | */
15 | class MainDispatcherRule(
16 | private val testDispatcher: TestDispatcher = UnconfinedTestDispatcher()
17 | ) : TestWatcher() {
18 |
19 | override fun starting(description: Description) {
20 | super.starting(description)
21 | Dispatchers.setMain(testDispatcher)
22 | }
23 |
24 | override fun finished(description: Description) {
25 | super.finished(description)
26 | Dispatchers.resetMain()
27 | }
28 |
29 | }
30 |
--------------------------------------------------------------------------------
/pokedex/app/src/test/java/com/kfaraj/samples/pokedex/ui/PokedexViewModelTest.kt:
--------------------------------------------------------------------------------
1 | package com.kfaraj.samples.pokedex.ui
2 |
3 | import androidx.paging.LoadState
4 | import androidx.paging.LoadStates
5 | import androidx.paging.PagingData
6 | import androidx.paging.testing.asSnapshot
7 | import com.kfaraj.samples.pokedex.data.Pokemon
8 | import com.kfaraj.samples.pokedex.data.PokemonsRepository
9 | import com.kfaraj.samples.pokedex.domain.GetSpriteUseCase
10 | import com.kfaraj.samples.pokedex.testutils.MainDispatcherRule
11 | import kotlinx.coroutines.flow.flowOf
12 | import kotlinx.coroutines.test.runTest
13 | import org.junit.Assert.assertEquals
14 | import org.junit.Rule
15 | import org.junit.Test
16 | import org.mockito.kotlin.any
17 | import org.mockito.kotlin.mock
18 | import org.mockito.kotlin.whenever
19 |
20 | class PokedexViewModelTest {
21 |
22 | @get:Rule
23 | val mainDispatcherRule = MainDispatcherRule()
24 |
25 | @Test
26 | fun pagingData() = runTest {
27 | val pagingData = flowOf(
28 | PagingData.from(
29 | listOf(BULBASAUR),
30 | LoadStates(
31 | LoadState.NotLoading(true),
32 | LoadState.NotLoading(true),
33 | LoadState.NotLoading(true)
34 | )
35 | )
36 | )
37 | val pokemonsRepository = mock().apply {
38 | whenever(getPagingDataStream(any())).thenReturn(pagingData)
39 | }
40 | val getSpriteUseCase = mock().apply {
41 | whenever(invoke(1)).thenReturn("/1.png")
42 | }
43 | val viewModel = PokedexViewModel(
44 | pokemonsRepository,
45 | getSpriteUseCase
46 | )
47 | val result = viewModel.pagingData.asSnapshot()
48 | assertEquals(listOf(BULBASAUR_UI_STATE), result)
49 | }
50 |
51 | companion object {
52 | private val BULBASAUR = Pokemon(1, "bulbasaur")
53 | private val BULBASAUR_UI_STATE = PokedexItemUiState(1, "bulbasaur", "/1.png")
54 | }
55 |
56 | }
57 |
--------------------------------------------------------------------------------
/pokedex/app/src/test/java/com/kfaraj/samples/pokedex/ui/PokemonViewModelTest.kt:
--------------------------------------------------------------------------------
1 | package com.kfaraj.samples.pokedex.ui
2 |
3 | import androidx.lifecycle.SavedStateHandle
4 | import com.kfaraj.samples.pokedex.data.Pokemon
5 | import com.kfaraj.samples.pokedex.data.PokemonsRepository
6 | import com.kfaraj.samples.pokedex.domain.GetSpriteUseCase
7 | import com.kfaraj.samples.pokedex.testutils.MainDispatcherRule
8 | import kotlinx.coroutines.test.runTest
9 | import org.junit.Assert.assertEquals
10 | import org.junit.Rule
11 | import org.junit.Test
12 | import org.junit.runner.RunWith
13 | import org.mockito.kotlin.mock
14 | import org.mockito.kotlin.whenever
15 | import org.robolectric.RobolectricTestRunner
16 |
17 | @RunWith(RobolectricTestRunner::class)
18 | class PokemonViewModelTest {
19 |
20 | @get:Rule
21 | val mainDispatcherRule = MainDispatcherRule()
22 |
23 | @Test
24 | fun uiState() = runTest {
25 | val savedStateHandle = SavedStateHandle(mapOf("id" to 1))
26 | val pokemonsRepository = mock().apply {
27 | whenever(get(1)).thenReturn(BULBASAUR)
28 | }
29 | val getSpriteUseCase = mock().apply {
30 | whenever(invoke(1)).thenReturn("/1.png")
31 | }
32 | val viewModel = PokemonViewModel(
33 | savedStateHandle,
34 | pokemonsRepository,
35 | getSpriteUseCase
36 | )
37 | val result = viewModel.uiState.value
38 | assertEquals(BULBASAUR_UI_STATE, result)
39 | }
40 |
41 | companion object {
42 | private val BULBASAUR = Pokemon(1, "bulbasaur")
43 | private val BULBASAUR_UI_STATE = PokemonUiState(1, "bulbasaur", "/1.png")
44 | }
45 |
46 | }
47 |
--------------------------------------------------------------------------------
/pokedex/screenshots/pokedex.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kfaraj/samples/0d54194506861c1ca2e26a7c6fd989a24750a431/pokedex/screenshots/pokedex.webp
--------------------------------------------------------------------------------
/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | pluginManagement {
2 | repositories {
3 | google {
4 | content {
5 | includeGroupByRegex("com\\.android.*")
6 | includeGroupByRegex("com\\.google.*")
7 | includeGroupByRegex("androidx.*")
8 | }
9 | }
10 | mavenCentral()
11 | gradlePluginPortal()
12 | }
13 | }
14 |
15 | dependencyResolutionManagement {
16 | repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS
17 | repositories {
18 | google()
19 | mavenCentral()
20 | }
21 | }
22 |
23 | rootProject.name = "samples"
24 | include(":darktheme:app")
25 | include(":pokedex:app")
26 |
--------------------------------------------------------------------------------