Snap for 13363539 from 74b961ed0bc81a43bd67d7b6dae669958cab9b03 to androidx-xr-arcore-release
Change-Id: I5f686692b0e932725f5e8537ecbf21ebade854c4
diff --git a/app/build.gradle b/app/build.gradle
index 5f49e03..f17205a 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -1,3 +1,5 @@
+import androidx.build.KotlinTarget
+
plugins {
id("AndroidXPlugin")
id("AndroidXComposePlugin")
@@ -14,17 +16,12 @@
compileSdk = 35
defaultConfig {
- // The schemas directory contains a schema file for each version of the Room database.
- // This is required to enable Room auto migrations.
+ // Propagate the proguard rules to be used by the including app module
+ consumerProguardFiles("proguard-rules.pro")
+ // We don't export schemas, but Room's Gradle plugin doesn't know that. We use the project
+ // directory as a placeholder.
room {
- schemaDirectory("$projectDir/schemas")
- }
- }
-
- buildTypes {
- release {
- minifyEnabled false
- proguardFiles("proguard-rules.pro")
+ schemaDirectory("$projectDir")
}
}
@@ -33,6 +30,25 @@
}
}
+/**
+ * Exclusion groups for JetBrains Compose. When a dependency depends on JetBrains Compose, prebuilts
+ * for all other platforms will be pulled in. We also want to ensure we're using *Jetpack* Compose
+ * and not mix JetBrains and Jetpack Compose.
+ *
+ * IMPORTANT: When adding a dependency here, please make sure that it is otherwise present on the
+ * classpath.
+ */
+def jetbrainsComposeExcludeGroups = [
+ 'org.jetbrains.androidx.lifecycle',
+ 'org.jetbrains.compose.animation',
+ 'org.jetbrains.compose.annotation-internal',
+ 'org.jetbrains.compose.collection-internal',
+ 'org.jetbrains.compose.foundation',
+ 'org.jetbrains.compose.runtime',
+ 'org.jetbrains.compose.ui',
+ 'org.jetbrains.skiko'
+]
+
dependencies {
// Compose
implementation(project(":compose:runtime:runtime"))
@@ -43,17 +59,30 @@
implementation(project(":compose:material3:material3"))
implementation(project(":compose:foundation:foundation"))
implementation(project(":compose:foundation:foundation-layout"))
+ implementation(project(":compose:integration-tests:hero:hero-common:hero-common-implementation"))
implementation("androidx.activity:activity-compose:1.9.2")
// AndroidX
implementation(libs.androidx.core)
implementation(libs.testRunner)
- implementation("androidx.navigation:navigation-compose:2.8.2")
+ implementation("androidx.navigation:navigation-compose:2.8.5")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.4")
implementation("androidx.palette:palette:1.0.0")
// Image Loading
implementation("com.github.bumptech.glide:compose:1.0.0-beta01")
+ implementation("com.github.bumptech.glide:okhttp3-integration:4.16.0")
+ ksp("com.github.bumptech.glide:ksp:4.16.0")
+ implementation("io.coil-kt.coil3:coil-compose-android:3.1.0") {
+ jetbrainsComposeExcludeGroups.each { group ->
+ exclude group: group
+ }
+ }
+ implementation("io.coil-kt.coil3:coil-network-okhttp:3.1.0") {
+ jetbrainsComposeExcludeGroups.each { group ->
+ exclude group: group
+ }
+ }
// Kotlinx
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.7")
@@ -63,9 +92,10 @@
implementation(libs.kotlinCoroutinesAndroid)
// Database
- implementation("androidx.room:room-runtime:2.6.1")
- implementation("androidx.room:room-ktx:2.6.1")
- ksp("androidx.room:room-compiler:2.6.1")
+ // TODO: Update to Room 2.7.0 stable once released (b/407725691)
+ implementation("androidx.room:room-runtime:2.7.0-rc03")
+ implementation("androidx.room:room-ktx:2.7.0-rc03")
+ ksp("androidx.room:room-compiler:2.7.0-rc03")
// Network
implementation("com.squareup.retrofit2:retrofit:2.11.0")
@@ -74,3 +104,8 @@
implementation("com.squareup.okhttp3:mockwebserver:4.12.0")
implementation("com.squareup.okhttp3:okhttp-tls:4.12.0")
}
+
+androidx {
+ //TODO(b/402389694): Target Kotlin 2.
+ kotlinTarget = KotlinTarget.KOTLIN_1_9
+}
\ No newline at end of file
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
index df35c17..0cf1f04 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -41,3 +41,21 @@
public static **[] values();
public static ** valueOf(java.lang.String);
}
+
+# Serializer for classes with named companion objects are retrieved using `getDeclaredClasses`.
+# If you have any, replace classes with those containing named companion objects.
+-keepattributes InnerClasses # Needed for `getDeclaredClasses`.
+
+-if @kotlinx.serialization.Serializable class com.skydoves.pokedex.compose.core.model.Pokemon
+{
+ static **$* *;
+}
+-keepnames class <1>$$serializer { # -keepnames suffices; class is kept when serializer() is kept.
+ static <1>$$serializer INSTANCE;
+}
+
+# Keep both serializer and serializable classes to save the attribute InnerClasses
+-keepclasseswithmembers, allowshrinking, allowobfuscation, allowaccessmodification class com.skydoves.pokedex.compose.core.model.Pokemon
+{
+ *;
+}
diff --git a/app/schemas/com.skydoves.pokedex.compose.core.database.PokedexDatabase/2.json b/app/schemas/com.skydoves.pokedex.compose.core.database.PokedexDatabase/2.json
deleted file mode 100644
index b8235b6..0000000
--- a/app/schemas/com.skydoves.pokedex.compose.core.database.PokedexDatabase/2.json
+++ /dev/null
@@ -1,126 +0,0 @@
-{
- "formatVersion": 1,
- "database": {
- "version": 2,
- "identityHash": "3e4fc349c7e47ef58902f587531caab0",
- "entities": [
- {
- "tableName": "PokemonEntity",
- "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`page` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, PRIMARY KEY(`name`))",
- "fields": [
- {
- "fieldPath": "page",
- "columnName": "page",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "name",
- "columnName": "name",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "url",
- "columnName": "url",
- "affinity": "TEXT",
- "notNull": true
- }
- ],
- "primaryKey": {
- "autoGenerate": false,
- "columnNames": [
- "name"
- ]
- },
- "indices": [],
- "foreignKeys": []
- },
- {
- "tableName": "PokemonInfoEntity",
- "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `height` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `experience` INTEGER NOT NULL, `types` TEXT NOT NULL, `hp` INTEGER NOT NULL, `attack` INTEGER NOT NULL, `defense` INTEGER NOT NULL, `speed` INTEGER NOT NULL, `exp` INTEGER NOT NULL, PRIMARY KEY(`id`))",
- "fields": [
- {
- "fieldPath": "id",
- "columnName": "id",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "name",
- "columnName": "name",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "height",
- "columnName": "height",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "weight",
- "columnName": "weight",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "experience",
- "columnName": "experience",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "types",
- "columnName": "types",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "hp",
- "columnName": "hp",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "attack",
- "columnName": "attack",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "defense",
- "columnName": "defense",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "speed",
- "columnName": "speed",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "exp",
- "columnName": "exp",
- "affinity": "INTEGER",
- "notNull": true
- }
- ],
- "primaryKey": {
- "autoGenerate": false,
- "columnNames": [
- "id"
- ]
- },
- "indices": [],
- "foreignKeys": []
- }
- ],
- "views": [],
- "setupQueries": [
- "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
- "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3e4fc349c7e47ef58902f587531caab0')"
- ]
- }
-}
\ No newline at end of file
diff --git a/app/schemas/com.skydoves.pokedex.compose.core.database.PokedexDatabase/3.json b/app/schemas/com.skydoves.pokedex.compose.core.database.PokedexDatabase/3.json
deleted file mode 100644
index ca4d915..0000000
--- a/app/schemas/com.skydoves.pokedex.compose.core.database.PokedexDatabase/3.json
+++ /dev/null
@@ -1,108 +0,0 @@
-{
- "formatVersion": 1,
- "database": {
- "version": 3,
- "identityHash": "2ce2c0e046fdc408aab83eb7e475bf26",
- "entities": [
- {
- "tableName": "PokemonEntity",
- "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`page` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, PRIMARY KEY(`name`))",
- "fields": [
- {
- "fieldPath": "page",
- "columnName": "page",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "name",
- "columnName": "name",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "url",
- "columnName": "url",
- "affinity": "TEXT",
- "notNull": true
- }
- ],
- "primaryKey": {
- "autoGenerate": false,
- "columnNames": [
- "name"
- ]
- },
- "indices": [],
- "foreignKeys": []
- },
- {
- "tableName": "PokemonInfoEntity",
- "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `height` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `experience` INTEGER NOT NULL, `types` TEXT NOT NULL, `exp` INTEGER NOT NULL, `stats` TEXT NOT NULL, PRIMARY KEY(`id`))",
- "fields": [
- {
- "fieldPath": "id",
- "columnName": "id",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "name",
- "columnName": "name",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "height",
- "columnName": "height",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "weight",
- "columnName": "weight",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "experience",
- "columnName": "experience",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "types",
- "columnName": "types",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "exp",
- "columnName": "exp",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "stats",
- "columnName": "stats",
- "affinity": "TEXT",
- "notNull": true
- }
- ],
- "primaryKey": {
- "autoGenerate": false,
- "columnNames": [
- "id"
- ]
- },
- "indices": [],
- "foreignKeys": []
- }
- ],
- "views": [],
- "setupQueries": [
- "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
- "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '2ce2c0e046fdc408aab83eb7e475bf26')"
- ]
- }
-}
\ No newline at end of file
diff --git a/app/schemas/com.skydoves.pokedex.compose.core.database.PokedexDatabase/4.json b/app/schemas/com.skydoves.pokedex.compose.core.database.PokedexDatabase/4.json
deleted file mode 100644
index d24909b..0000000
--- a/app/schemas/com.skydoves.pokedex.compose.core.database.PokedexDatabase/4.json
+++ /dev/null
@@ -1,108 +0,0 @@
-{
- "formatVersion": 1,
- "database": {
- "version": 4,
- "identityHash": "2ce2c0e046fdc408aab83eb7e475bf26",
- "entities": [
- {
- "tableName": "PokemonEntity",
- "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`page` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, PRIMARY KEY(`name`))",
- "fields": [
- {
- "fieldPath": "page",
- "columnName": "page",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "name",
- "columnName": "name",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "url",
- "columnName": "url",
- "affinity": "TEXT",
- "notNull": true
- }
- ],
- "primaryKey": {
- "autoGenerate": false,
- "columnNames": [
- "name"
- ]
- },
- "indices": [],
- "foreignKeys": []
- },
- {
- "tableName": "PokemonInfoEntity",
- "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `height` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `experience` INTEGER NOT NULL, `types` TEXT NOT NULL, `exp` INTEGER NOT NULL, `stats` TEXT NOT NULL, PRIMARY KEY(`id`))",
- "fields": [
- {
- "fieldPath": "id",
- "columnName": "id",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "name",
- "columnName": "name",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "height",
- "columnName": "height",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "weight",
- "columnName": "weight",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "experience",
- "columnName": "experience",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "types",
- "columnName": "types",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "exp",
- "columnName": "exp",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "stats",
- "columnName": "stats",
- "affinity": "TEXT",
- "notNull": true
- }
- ],
- "primaryKey": {
- "autoGenerate": false,
- "columnNames": [
- "id"
- ]
- },
- "indices": [],
- "foreignKeys": []
- }
- ],
- "views": [],
- "setupQueries": [
- "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
- "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '2ce2c0e046fdc408aab83eb7e475bf26')"
- ]
- }
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/skydoves/pokedex/compose/core/PokedexFeatureFlags.kt b/app/src/main/kotlin/com/skydoves/pokedex/compose/core/PokedexFeatureFlags.kt
new file mode 100644
index 0000000..a05a3c7
--- /dev/null
+++ b/app/src/main/kotlin/com/skydoves/pokedex/compose/core/PokedexFeatureFlags.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.skydoves.pokedex.compose.core
+
+/** Contains feature flags for the Pokedex hero benchmark target */
+object PokedexFeatureFlags {
+ /**
+ * Whether to configure and use Coil for image loading of all images, or Glide if false. Please
+ * note that Glide will always be configured.
+ */
+ var UseCoil = true
+}
diff --git a/app/src/main/kotlin/com/skydoves/pokedex/compose/core/data/repository/home/HomeRepositoryImpl.kt b/app/src/main/kotlin/com/skydoves/pokedex/compose/core/data/repository/home/HomeRepositoryImpl.kt
index 16a4355..b347870 100644
--- a/app/src/main/kotlin/com/skydoves/pokedex/compose/core/data/repository/home/HomeRepositoryImpl.kt
+++ b/app/src/main/kotlin/com/skydoves/pokedex/compose/core/data/repository/home/HomeRepositoryImpl.kt
@@ -18,8 +18,8 @@
import androidx.annotation.WorkerThread
import com.skydoves.pokedex.compose.core.database.PokemonDao
-import com.skydoves.pokedex.compose.core.database.entitiy.mapper.asDomain
-import com.skydoves.pokedex.compose.core.database.entitiy.mapper.asEntity
+import com.skydoves.pokedex.compose.core.database.entitiy.mapper.asDatabaseEntity
+import com.skydoves.pokedex.compose.core.database.entitiy.mapper.asPresentationModel
import com.skydoves.pokedex.compose.core.network.Dispatcher
import com.skydoves.pokedex.compose.core.network.PokedexAppDispatchers
import com.skydoves.pokedex.compose.core.network.service.PokedexClient
@@ -28,11 +28,13 @@
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onStart
+import okhttp3.HttpUrl
class HomeRepositoryImpl(
private val pokedexClient: PokedexClient,
private val pokemonDao: PokemonDao,
@Dispatcher(PokedexAppDispatchers.IO) private val ioDispatcher: CoroutineDispatcher,
+ private val apiUrl: HttpUrl
) : HomeRepository {
@WorkerThread
@@ -43,20 +45,18 @@
onError: (String?) -> Unit,
) =
flow {
- var pokemons = pokemonDao.getPokemonList(page).asDomain()
- if (pokemons.isEmpty()) {
- val response = pokedexClient.fetchPokemonList(page = page)
- response
- .onSuccess { data ->
- pokemons = data.results
- pokemons.forEach { pokemon -> pokemon.page = page }
- pokemonDao.insertPokemonList(pokemons.asEntity())
- emit(pokemonDao.getAllPokemonList(page).asDomain())
- }
- .onFailure { throwable -> onError(throwable.message) }
- } else {
- emit(pokemonDao.getAllPokemonList(page).asDomain())
- }
+ // Start out by fetching cached data
+ emit(pokemonDao.getPokemonList().asPresentationModel(apiUrl, page))
+ // Afterwards, we'll make a request to the API to still get new data
+ val networkPokemonResponse = pokedexClient.fetchPokemonList(page = page)
+ networkPokemonResponse
+ .onSuccess { data ->
+ val networkFetchedPokemons = data.results
+ pokemonDao.insertPokemonList(networkFetchedPokemons.asDatabaseEntity())
+ // We re-query the database to account for concurrent modifications
+ emit(pokemonDao.getAllPokemonList().asPresentationModel(apiUrl, page))
+ }
+ .onFailure { throwable -> onError(throwable.message) }
}
.onStart { onStart() }
.onCompletion { onComplete() }
diff --git a/app/src/main/kotlin/com/skydoves/pokedex/compose/core/database/PokedexDatabase.kt b/app/src/main/kotlin/com/skydoves/pokedex/compose/core/database/PokedexDatabase.kt
index 5f168d8..bef6a0b 100644
--- a/app/src/main/kotlin/com/skydoves/pokedex/compose/core/database/PokedexDatabase.kt
+++ b/app/src/main/kotlin/com/skydoves/pokedex/compose/core/database/PokedexDatabase.kt
@@ -24,8 +24,8 @@
@Database(
entities = [PokemonEntity::class, PokemonInfoEntity::class],
- version = 4,
- exportSchema = true,
+ version = 1,
+ exportSchema = false /* We don't require schema versioning in Hero Benchmarks */
)
@TypeConverters(value = [TypeResponseConverter::class, StatsResponseConverter::class])
abstract class PokedexDatabase : RoomDatabase() {
diff --git a/app/src/main/kotlin/com/skydoves/pokedex/compose/core/database/PokemonDao.kt b/app/src/main/kotlin/com/skydoves/pokedex/compose/core/database/PokemonDao.kt
index d93be5a..b0d5fdd 100644
--- a/app/src/main/kotlin/com/skydoves/pokedex/compose/core/database/PokemonDao.kt
+++ b/app/src/main/kotlin/com/skydoves/pokedex/compose/core/database/PokemonDao.kt
@@ -28,9 +28,9 @@
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertPokemonList(pokemonList: List<PokemonEntity>)
- @Query("SELECT * FROM PokemonEntity WHERE page = :page_")
- suspend fun getPokemonList(page_: Int): List<PokemonEntity>
+ @Query("SELECT * FROM PokemonEntity") suspend fun getPokemonList(): List<PokemonEntity>
- @Query("SELECT * FROM PokemonEntity WHERE page <= :page_")
- suspend fun getAllPokemonList(page_: Int): List<PokemonEntity>
+ @Query("SELECT * FROM PokemonEntity") suspend fun getAllPokemonList(): List<PokemonEntity>
+
+ @Query("DELETE FROM PokemonEntity") suspend fun deleteAll()
}
diff --git a/app/src/main/kotlin/com/skydoves/pokedex/compose/core/database/di/DatabaseModule.kt b/app/src/main/kotlin/com/skydoves/pokedex/compose/core/database/di/DatabaseModule.kt
index b525c9a..d99e519 100644
--- a/app/src/main/kotlin/com/skydoves/pokedex/compose/core/database/di/DatabaseModule.kt
+++ b/app/src/main/kotlin/com/skydoves/pokedex/compose/core/database/di/DatabaseModule.kt
@@ -25,7 +25,7 @@
import com.skydoves.pokedex.compose.core.database.TypeResponseConverter
import kotlinx.serialization.json.Json
-class DatabaseModule(private val context: Context, private val json: Json) {
+class DatabaseModule(private val context: () -> Context, private val json: Json) {
val typeResponseConverter: TypeResponseConverter by lazy { TypeResponseConverter(json) }
val statsResponseConverter: StatsResponseConverter by lazy { StatsResponseConverter(json) }
@@ -34,7 +34,7 @@
// fallbackToDestructiveMigration requires a parameter in Room 2.7 that's not available in
// 2.6. Forward-compatibility checks like androidx_max_dep_versions will fail without this.
@Suppress("DEPRECATION")
- Room.databaseBuilder(context, PokedexDatabase::class.java, "Pokedex.db")
+ Room.databaseBuilder(context(), PokedexDatabase::class.java, "Pokedex.db")
.fallbackToDestructiveMigration()
.addTypeConverter(typeResponseConverter)
.addTypeConverter(statsResponseConverter)
diff --git a/app/src/main/kotlin/com/skydoves/pokedex/compose/core/database/entitiy/PokemonEntity.kt b/app/src/main/kotlin/com/skydoves/pokedex/compose/core/database/entitiy/PokemonEntity.kt
index 7d2fa9a..6dbb314 100644
--- a/app/src/main/kotlin/com/skydoves/pokedex/compose/core/database/entitiy/PokemonEntity.kt
+++ b/app/src/main/kotlin/com/skydoves/pokedex/compose/core/database/entitiy/PokemonEntity.kt
@@ -19,9 +19,4 @@
import androidx.room.Entity
import androidx.room.PrimaryKey
-@Entity
-data class PokemonEntity(
- var page: Int = 0,
- @PrimaryKey val name: String,
- val url: String,
-)
+@Entity data class PokemonEntity(@PrimaryKey val name: String)
diff --git a/app/src/main/kotlin/com/skydoves/pokedex/compose/core/database/entitiy/mapper/PokemonEntityMapper.kt b/app/src/main/kotlin/com/skydoves/pokedex/compose/core/database/entitiy/mapper/PokemonEntityMapper.kt
index 4edbb1b..c61d20e 100644
--- a/app/src/main/kotlin/com/skydoves/pokedex/compose/core/database/entitiy/mapper/PokemonEntityMapper.kt
+++ b/app/src/main/kotlin/com/skydoves/pokedex/compose/core/database/entitiy/mapper/PokemonEntityMapper.kt
@@ -18,34 +18,25 @@
import com.skydoves.pokedex.compose.core.database.entitiy.PokemonEntity
import com.skydoves.pokedex.compose.core.model.Pokemon
+import com.skydoves.pokedex.compose.core.model.PokemonNetworkModel
+import okhttp3.HttpUrl
-object PokemonEntityMapper : EntityMapper<List<Pokemon>, List<PokemonEntity>> {
+fun List<PokemonNetworkModel>.asDatabaseEntity(): List<PokemonEntity> = map { pokemon ->
+ PokemonEntity(name = pokemon.name)
+}
- override fun asEntity(domain: List<Pokemon>): List<PokemonEntity> {
- return domain.map { pokemon ->
- PokemonEntity(
- page = pokemon.page,
- name = pokemon.name,
- url = pokemon.url,
- )
- }
+fun List<PokemonEntity>.asPresentationModel(apiUrl: HttpUrl, page: Int = 0): List<Pokemon> =
+ map { entity ->
+ Pokemon(
+ name = entity.name.replaceFirstChar { it.uppercase() },
+ imageUrl =
+ apiUrl
+ .newBuilder()
+ .addPathSegment("pokemon")
+ .addPathSegment(entity.name)
+ .addPathSegment("image")
+ .build()
+ .toString(),
+ page = page
+ )
}
-
- override fun asDomain(entity: List<PokemonEntity>): List<Pokemon> {
- return entity.map { pokemonEntity ->
- Pokemon(
- page = pokemonEntity.page,
- nameField = pokemonEntity.name,
- url = pokemonEntity.url,
- )
- }
- }
-}
-
-fun List<Pokemon>.asEntity(): List<PokemonEntity> {
- return PokemonEntityMapper.asEntity(this)
-}
-
-fun List<PokemonEntity>?.asDomain(): List<Pokemon> {
- return PokemonEntityMapper.asDomain(this.orEmpty())
-}
diff --git a/app/src/main/kotlin/com/skydoves/pokedex/compose/core/di/RepositoryModule.kt b/app/src/main/kotlin/com/skydoves/pokedex/compose/core/di/RepositoryModule.kt
index 1f7153a..b8790af 100644
--- a/app/src/main/kotlin/com/skydoves/pokedex/compose/core/di/RepositoryModule.kt
+++ b/app/src/main/kotlin/com/skydoves/pokedex/compose/core/di/RepositoryModule.kt
@@ -24,18 +24,20 @@
import com.skydoves.pokedex.compose.core.database.PokemonInfoDao
import com.skydoves.pokedex.compose.core.network.service.PokedexClient
import kotlinx.coroutines.CoroutineDispatcher
+import okhttp3.HttpUrl
class RepositoryModule(
private val pokedexClient: PokedexClient,
private val pokemonDao: PokemonDao,
private val pokemonInfoDao: PokemonInfoDao,
- private val ioDispatcher: CoroutineDispatcher
+ private val ioDispatcher: CoroutineDispatcher,
+ private val apiUrl: HttpUrl
) {
val detailsRepository: DetailsRepository by lazy {
DetailsRepositoryImpl(pokedexClient, pokemonInfoDao, ioDispatcher)
}
val homeRepository: HomeRepository by lazy {
- HomeRepositoryImpl(pokedexClient, pokemonDao, ioDispatcher)
+ HomeRepositoryImpl(pokedexClient, pokemonDao, ioDispatcher, apiUrl)
}
}
diff --git a/app/src/main/kotlin/com/skydoves/pokedex/compose/core/model/Pokemon.kt b/app/src/main/kotlin/com/skydoves/pokedex/compose/core/model/Pokemon.kt
index b9ac4d2..941674c 100644
--- a/app/src/main/kotlin/com/skydoves/pokedex/compose/core/model/Pokemon.kt
+++ b/app/src/main/kotlin/com/skydoves/pokedex/compose/core/model/Pokemon.kt
@@ -20,22 +20,12 @@
import android.os.Parcel
import android.os.Parcelable
import androidx.compose.runtime.Immutable
-import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
-import okhttp3.HttpUrl
@SuppressLint("BanParcelableUsage") // TODO(b/374318532): Migrate to VersionedParcelable
@Immutable
@Serializable
-data class Pokemon(
- var page: Int = 0,
- @SerialName(value = "name") val nameField: String,
- @SerialName(value = "url") val url: String,
- val imageUrl: String = imageUrlFromPokemonInfoUrl(url)
-) : Parcelable {
-
- val name: String
- get() = nameField.replaceFirstChar { it.uppercase() }
+data class Pokemon(var page: Int = 0, val name: String, val imageUrl: String) : Parcelable {
constructor(
parcel: Parcel
@@ -43,8 +33,8 @@
override fun writeToParcel(parcel: Parcel, flags: Int) {
parcel.writeInt(page)
- parcel.writeString(nameField)
- parcel.writeString(url)
+ parcel.writeString(name)
+ parcel.writeString(imageUrl)
}
override fun describeContents() = 0
@@ -55,52 +45,3 @@
override fun newArray(size: Int): Array<Pokemon?> = arrayOfNulls(size)
}
}
-
-val FakePokemonNames =
- listOf(
- "Jason",
- "Jack",
- "Anna",
- "Bubir",
- "Xanto",
- "Vistesia",
- "Ulint-y",
- "Lapesareba",
- "Nemo",
- "Masurap"
- )
-val FakeRandomizedNames by lazy {
- FakePokemonNames.flatMap { name ->
- val nameChars = name.toCharArray()
- val shuffledChars = nameChars.apply { shuffle() }
- listOf(
- shuffledChars.joinToString(""),
- shuffledChars.apply { reverse() }.joinToString(""),
- nameChars.apply { reverse() }.joinToString("")
- )
- }
-}
-
-/**
- * Create a list of [Pokemon] with fake names and corresponding URLs.
- *
- * @param apiBaseUrl The base URL of the API to fetch data from
- */
-fun fakePokemons(apiBaseUrl: HttpUrl): List<Pokemon> =
- FakeRandomizedNames.mapIndexed { index, name ->
- Pokemon(nameField = name, url = "${apiBaseUrl}pokemon/${index}/")
- }
-
-/**
- * Derive the URL for a Pokemon's image from the Pokemon's URL
- *
- * For example, `https://localhost:3000/api/v2/pokemon/0` would become:
- * `https://localhost:3000/api/v2/pokemon/0/image`
- *
- * @param pokemonInfoUrl The URL of the pokemon details
- * @return A string with the URL for the image
- */
-private fun imageUrlFromPokemonInfoUrl(pokemonInfoUrl: String): String {
- val separator = if (pokemonInfoUrl.endsWith("/")) "" else "/"
- return "${pokemonInfoUrl}${separator}image"
-}
diff --git a/app/src/main/kotlin/com/skydoves/pokedex/compose/core/model/PokemonInfo.kt b/app/src/main/kotlin/com/skydoves/pokedex/compose/core/model/PokemonInfo.kt
index d2b4622..8909227 100644
--- a/app/src/main/kotlin/com/skydoves/pokedex/compose/core/model/PokemonInfo.kt
+++ b/app/src/main/kotlin/com/skydoves/pokedex/compose/core/model/PokemonInfo.kt
@@ -109,12 +109,22 @@
var FakePokemonStats = listOf("hp", "attack", "speed", "defense")
-fun fakePokemonStats(random: Random = Random) =
- PokemonInfo.StatsResponse(
- baseStat = random.nextInt(),
+fun fakePokemonStats(random: Random = Random): PokemonInfo.StatsResponse {
+ val stat = PokemonInfo.Stat(FakePokemonStats.random())
+ val statMax =
+ when (stat.name) {
+ "hp" -> PokemonInfo.MAX_HP
+ "attack" -> PokemonInfo.MAX_ATTACK
+ "speed" -> PokemonInfo.MAX_SPEED
+ "defense" -> PokemonInfo.MAX_DEFENSE
+ else -> 100
+ }
+ return PokemonInfo.StatsResponse(
+ baseStat = random.nextInt(until = statMax),
effort = random.nextInt(),
- stat = PokemonInfo.Stat(FakePokemonStats.random())
+ stat = stat
)
+}
var FakePokemonTypes =
listOf(
diff --git a/app/src/main/kotlin/com/skydoves/pokedex/compose/core/model/PokemonNetworkModel.kt b/app/src/main/kotlin/com/skydoves/pokedex/compose/core/model/PokemonNetworkModel.kt
new file mode 100644
index 0000000..912f930
--- /dev/null
+++ b/app/src/main/kotlin/com/skydoves/pokedex/compose/core/model/PokemonNetworkModel.kt
@@ -0,0 +1,49 @@
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.skydoves.pokedex.compose.core.model
+
+import androidx.compose.runtime.Immutable
+import kotlinx.serialization.Serializable
+
+@Immutable @Serializable class PokemonNetworkModel(val name: String)
+
+fun fakePokemonNetworkModels() = FakeRandomizedNames.map { name -> PokemonNetworkModel(name) }
+
+val FakePokemonNames =
+ listOf(
+ "Jason",
+ "Jack",
+ "Anna",
+ "Bubir",
+ "Xanto",
+ "Vistesia",
+ "Ulint-y",
+ "Lapesareba",
+ "Nemo",
+ "Masurap"
+ )
+val FakeRandomizedNames by lazy {
+ FakePokemonNames.flatMap { name ->
+ val nameChars = name.toCharArray()
+ val shuffledChars = nameChars.apply { shuffle() }
+ listOf(
+ shuffledChars.joinToString(""),
+ shuffledChars.apply { reverse() }.joinToString(""),
+ nameChars.apply { reverse() }.joinToString("")
+ )
+ }
+}
diff --git a/app/src/main/kotlin/com/skydoves/pokedex/compose/core/network/di/ModuleLocator.kt b/app/src/main/kotlin/com/skydoves/pokedex/compose/core/network/di/ModuleLocator.kt
new file mode 100644
index 0000000..bdd7e89
--- /dev/null
+++ b/app/src/main/kotlin/com/skydoves/pokedex/compose/core/network/di/ModuleLocator.kt
@@ -0,0 +1,58 @@
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.skydoves.pokedex.compose.core.network.di
+
+import android.content.Context
+import com.skydoves.pokedex.compose.core.database.di.DatabaseModule
+import com.skydoves.pokedex.compose.core.di.RepositoryModule
+
+object ModuleLocator {
+
+ private var context: (() -> Context)? = null
+
+ fun attach(context: () -> Context) {
+ this.context = context
+ }
+
+ fun detach() {
+ context = null
+ }
+
+ val serializationModule by lazy { SerializationModule() }
+ val dispatchersModule by lazy { DispatchersModule() }
+ val networkModule by lazy {
+ NetworkModule(
+ json = serializationModule.json,
+ networkCoroutineContext = dispatchersModule.io
+ )
+ }
+ val databaseModule by lazy {
+ DatabaseModule(
+ context = requireNotNull(context) { "Please attach the context using attach" },
+ json = serializationModule.json
+ )
+ }
+ val repositoryModule by lazy {
+ RepositoryModule(
+ networkModule.pokedexClient,
+ databaseModule.pokemonDao,
+ databaseModule.pokemonInfoDao,
+ dispatchersModule.io,
+ networkModule.baseUrl
+ )
+ }
+}
diff --git a/app/src/main/kotlin/com/skydoves/pokedex/compose/core/network/di/NetworkModule.kt b/app/src/main/kotlin/com/skydoves/pokedex/compose/core/network/di/NetworkModule.kt
index 83fa810..0172c24 100644
--- a/app/src/main/kotlin/com/skydoves/pokedex/compose/core/network/di/NetworkModule.kt
+++ b/app/src/main/kotlin/com/skydoves/pokedex/compose/core/network/di/NetworkModule.kt
@@ -32,7 +32,6 @@
package com.skydoves.pokedex.compose.core.network.di
-import com.skydoves.pokedex.compose.BuildConfig
import com.skydoves.pokedex.compose.core.network.service.PokedexClient
import com.skydoves.pokedex.compose.core.network.service.PokedexService
import com.skydoves.pokedex.compose.core.network.service.pokedexMockWebServer
@@ -81,10 +80,10 @@
runBlocking(networkCoroutineContext) { mockServer.url("/api/v2/") }
}
- val okHttpClient: OkHttpClient by lazy {
- OkHttpClient.Builder()
+ fun okHttpClientFactory(): OkHttpClient {
+ return OkHttpClient.Builder()
.apply {
- if (BuildConfig.DEBUG) {
+ if (true) {
this.addNetworkInterceptor(
HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BODY }
)
@@ -97,6 +96,8 @@
.build()
}
+ val okHttpClient: OkHttpClient by lazy { okHttpClientFactory() }
+
val retrofit: Retrofit by lazy {
Retrofit.Builder()
.client(okHttpClient)
diff --git a/app/src/main/kotlin/com/skydoves/pokedex/compose/core/network/model/PokemonResponse.kt b/app/src/main/kotlin/com/skydoves/pokedex/compose/core/network/model/PokemonResponse.kt
index 2a34bc9..d87b7b7 100644
--- a/app/src/main/kotlin/com/skydoves/pokedex/compose/core/network/model/PokemonResponse.kt
+++ b/app/src/main/kotlin/com/skydoves/pokedex/compose/core/network/model/PokemonResponse.kt
@@ -16,26 +16,24 @@
package com.skydoves.pokedex.compose.core.network.model
-import com.skydoves.pokedex.compose.core.model.Pokemon
-import com.skydoves.pokedex.compose.core.model.fakePokemons
+import com.skydoves.pokedex.compose.core.model.PokemonNetworkModel
+import com.skydoves.pokedex.compose.core.model.fakePokemonNetworkModels
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
-import okhttp3.HttpUrl
@Serializable
data class PokemonResponse(
@SerialName(value = "count") val count: Int,
@SerialName(value = "next") val next: String?,
@SerialName(value = "previous") val previous: String?,
- @SerialName(value = "results") val results: List<Pokemon>,
+ @SerialName(value = "results") val results: List<PokemonNetworkModel>,
)
/**
- * Create a [PokemonResponse] with a list of [pokemons], [fakePokemons] by default.
+ * Create a [PokemonResponse] with a list of [pokemons].
*
- * @param pokemonInfoEndpointUrl The URL of the pokemon info endpoint to derive the image URL from
+ * @param pokemons The pokemons to be contained in the response, a list of generated items with fake
+ * data by default.
*/
-fun fakePokemonResponse(
- pokemonInfoEndpointUrl: HttpUrl,
- pokemons: List<Pokemon> = fakePokemons(pokemonInfoEndpointUrl)
-) = PokemonResponse(count = pokemons.size, previous = null, next = null, results = pokemons)
+fun fakePokemonResponse(pokemons: List<PokemonNetworkModel> = fakePokemonNetworkModels()) =
+ PokemonResponse(count = pokemons.size, previous = null, next = null, results = pokemons)
diff --git a/app/src/main/kotlin/com/skydoves/pokedex/compose/core/network/service/PokedexMockWebServer.kt b/app/src/main/kotlin/com/skydoves/pokedex/compose/core/network/service/PokedexMockWebServer.kt
index 5f515de..c3f8c1d 100644
--- a/app/src/main/kotlin/com/skydoves/pokedex/compose/core/network/service/PokedexMockWebServer.kt
+++ b/app/src/main/kotlin/com/skydoves/pokedex/compose/core/network/service/PokedexMockWebServer.kt
@@ -16,6 +16,8 @@
package com.skydoves.pokedex.compose.core.network.service
+import android.graphics.Bitmap
+import androidx.compose.integration.hero.common.implementation.GradientBitmap
import com.skydoves.pokedex.compose.core.model.FakeRandomizedNames
import com.skydoves.pokedex.compose.core.model.fakePokemonInfo
import com.skydoves.pokedex.compose.core.network.model.fakePokemonResponse
@@ -25,49 +27,64 @@
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
-import org.intellij.lang.annotations.Language
+import okio.Buffer
/**
* A [okhttp3.mockwebserver.MockWebServer] with a [Dispatcher] that sends responses with fake data
* for our API.
*/
fun pokedexMockWebServer(json: Json) =
- MockWebServer().apply {
- val pokemonEndpointRegex = Regex(PokemonEndpointPattern)
- val pokemonInfoEndpointRegex = Regex(PokemonInfoEndpointPattern)
- dispatcher =
- object : Dispatcher() {
- override fun dispatch(request: RecordedRequest): MockResponse {
- val requestPath = request.path
- if (requestPath == null) return MockResponse().setResponseCode(404)
- return when {
- pokemonEndpointRegex.matches(requestPath) -> {
- val mockWebServerUrl = this@apply.url("/api/v2/")
- val responseData = fakePokemonResponse(mockWebServerUrl)
- MockResponse()
- .setResponseCode(200)
- .setBody(json.encodeToString(responseData))
- }
- pokemonInfoEndpointRegex.matches(requestPath) -> {
- val requestUrl = request.requestUrl
- if (requestUrl == null) return MockResponse().setResponseCode(404)
- val pokemonName = requestUrl.pathSegments.last()
- val fakePokemonInfo =
- json.encodeToString(
- fakePokemonInfo(
- id = FakeRandomizedNames.indexOf(pokemonName),
- name = pokemonName
- )
- )
- return MockResponse().setResponseCode(200).setBody(fakePokemonInfo)
- }
- else -> MockResponse().setResponseCode(404)
- }
+ MockWebServer().apply { dispatcher = PokedexMockDispatcher(json) }
+
+/** This [Dispatcher] provides fake responses for our API. */
+private class PokedexMockDispatcher(private val json: Json) : Dispatcher() {
+ private val pokemonEndpointRegex = Regex("/api/v2/pokemon(\\?(?<query>(.*)))")
+ private val pokemonInfoEndpointRegex = Regex("/api/v2/pokemon/(?<name>\\w*)(/?)")
+ private val pokemonImageEndpointRegex = Regex("/api/v2/pokemon/(?<name>.*)/image(/?)")
+
+ override fun dispatch(request: RecordedRequest): MockResponse {
+ val requestPath = request.path
+ if (requestPath == null) return MockResponse().setResponseCode(404)
+ val response =
+ try {
+ when {
+ pokemonEndpointRegex.matches(requestPath) -> pokemonHandler()
+ pokemonInfoEndpointRegex.matches(requestPath) -> pokemonInfoHandler(request)
+ pokemonImageEndpointRegex.matches(requestPath) -> pokemonImageHandler(request)
+ else -> MockResponse().setResponseCode(404)
}
+ } catch (exception: Exception) {
+ exception.printStackTrace()
+ MockResponse()
+ .setResponseCode(500)
+ .setBody(exception.message ?: "Unknown Error Occurred")
}
+ return response
}
-@Language("RegExp") private const val PokemonEndpointPattern = "/api/v2/pokemon(\\?(?<query>(.*)))"
+ private fun pokemonHandler(): MockResponse {
+ return MockResponse()
+ .setResponseCode(200)
+ .setBody(json.encodeToString(fakePokemonResponse()))
+ }
-@Language("RegExp")
-private const val PokemonInfoEndpointPattern = "/api/v2/pokemon/(?<name>\\w*)(/?)"
+ private fun pokemonInfoHandler(request: RecordedRequest): MockResponse {
+ val requestUrl = request.requestUrl
+ if (requestUrl == null) return MockResponse().setResponseCode(404)
+ val pokemonName = requestUrl.pathSegments.last()
+ val fakePokemonInfo =
+ json.encodeToString(
+ fakePokemonInfo(id = FakeRandomizedNames.indexOf(pokemonName), name = pokemonName)
+ )
+ return MockResponse().setResponseCode(200).setBody(fakePokemonInfo)
+ }
+
+ private fun pokemonImageHandler(request: RecordedRequest): MockResponse {
+ val pathSegments = request.requestUrl!!.pathSegments
+ val pokemonName = pathSegments[pathSegments.size - 2]
+ val image = GradientBitmap(width = 500, height = 500, seed = pokemonName.hashCode())
+ val buffer = Buffer()
+ image.compress(Bitmap.CompressFormat.PNG, 100, buffer.outputStream())
+ return MockResponse().setResponseCode(200).setBody(buffer)
+ }
+}
diff --git a/app/src/main/kotlin/com/skydoves/pokedex/compose/core/preview/PreviewUtils.kt b/app/src/main/kotlin/com/skydoves/pokedex/compose/core/preview/PreviewUtils.kt
index f622d05..058a1e4 100644
--- a/app/src/main/kotlin/com/skydoves/pokedex/compose/core/preview/PreviewUtils.kt
+++ b/app/src/main/kotlin/com/skydoves/pokedex/compose/core/preview/PreviewUtils.kt
@@ -25,11 +25,18 @@
fun mockPokemon() =
Pokemon(
page = 0,
- nameField = "bulbasaur",
- url = "https://pokeapi.co/api/v2/pokemon/1/",
+ name = "bulbasaur",
+ imageUrl = "https://pokeapi.co/api/v2/pokemon/1/",
)
- fun mockPokemonList() = List(10) { Pokemon(page = 0, nameField = "bulbasaur$it", url = "") }
+ fun mockPokemonList() =
+ List(10) {
+ Pokemon(
+ page = 0,
+ name = "bulbasaur$it",
+ imageUrl = "https://pokeapi.co/api/v2/pokemon/1/"
+ )
+ }
fun mockPokemonInfo() =
PokemonInfo(
diff --git a/app/src/main/kotlin/com/skydoves/pokedex/compose/core/viewmodel/PokedexViewModelFactory.kt b/app/src/main/kotlin/com/skydoves/pokedex/compose/core/viewmodel/PokedexViewModelFactory.kt
index 0848a0b..9765c77 100644
--- a/app/src/main/kotlin/com/skydoves/pokedex/compose/core/viewmodel/PokedexViewModelFactory.kt
+++ b/app/src/main/kotlin/com/skydoves/pokedex/compose/core/viewmodel/PokedexViewModelFactory.kt
@@ -21,32 +21,14 @@
import androidx.lifecycle.createSavedStateHandle
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
-import com.skydoves.pokedex.compose.core.database.di.DatabaseModule
import com.skydoves.pokedex.compose.core.di.RepositoryModule
-import com.skydoves.pokedex.compose.core.network.di.DispatchersModule
-import com.skydoves.pokedex.compose.core.network.di.NetworkModule
-import com.skydoves.pokedex.compose.core.network.di.SerializationModule
+import com.skydoves.pokedex.compose.core.network.di.ModuleLocator
import com.skydoves.pokedex.compose.feature.details.DetailsViewModel
import com.skydoves.pokedex.compose.feature.home.HomeViewModel
val LocalPokedexViewModelFactory = compositionLocalWithComputedDefaultOf {
- val serializationModule = SerializationModule()
- val dispatchersModule = DispatchersModule()
- val networkModule =
- NetworkModule(
- json = serializationModule.json,
- networkCoroutineContext = dispatchersModule.io
- )
- val databaseModule =
- DatabaseModule(context = LocalContext.currentValue, json = serializationModule.json)
- val repositoryModule =
- RepositoryModule(
- networkModule.pokedexClient,
- databaseModule.pokemonDao,
- databaseModule.pokemonInfoDao,
- dispatchersModule.io
- )
- PokedexViewModelFactory(repositoryModule)
+ ModuleLocator.attach(context = { LocalContext.currentValue })
+ PokedexViewModelFactory(ModuleLocator.repositoryModule)
}
fun PokedexViewModelFactory(repositoryModule: RepositoryModule) = viewModelFactory {
diff --git a/app/src/main/kotlin/com/skydoves/pokedex/compose/core/viewmodel/ViewModelStateFlow.kt b/app/src/main/kotlin/com/skydoves/pokedex/compose/core/viewmodel/ViewModelStateFlow.kt
index 0ba4a27..de57265 100644
--- a/app/src/main/kotlin/com/skydoves/pokedex/compose/core/viewmodel/ViewModelStateFlow.kt
+++ b/app/src/main/kotlin/com/skydoves/pokedex/compose/core/viewmodel/ViewModelStateFlow.kt
@@ -17,6 +17,7 @@
package com.skydoves.pokedex.compose.core.viewmodel
import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -30,6 +31,7 @@
* once Kotlin 2.0 stable version is released and the new Compose compiler is compatible with Kotlin
* 2.0.
*/
+@OptIn(ExperimentalForInheritanceCoroutinesApi::class) // TODO: Remove this class b/400932000
class ViewModelStateFlow<T>(private val key: ViewModelKey, value: T) : MutableStateFlow<T> {
private val mutableStateFlow: MutableStateFlow<Map<ViewModelKey, T>> =
diff --git a/app/src/main/kotlin/com/skydoves/pokedex/compose/feature/details/DetailsViewModel.kt b/app/src/main/kotlin/com/skydoves/pokedex/compose/feature/details/DetailsViewModel.kt
index f12fbe0..2b0fd6b 100644
--- a/app/src/main/kotlin/com/skydoves/pokedex/compose/feature/details/DetailsViewModel.kt
+++ b/app/src/main/kotlin/com/skydoves/pokedex/compose/feature/details/DetailsViewModel.kt
@@ -46,7 +46,7 @@
.filterNotNull()
.flatMapLatest { pokemon ->
detailsRepository.fetchPokemonInfo(
- name = pokemon.nameField.replaceFirstChar { it.lowercase() },
+ name = pokemon.name.replaceFirstChar { it.lowercase() },
onComplete = { uiState.tryEmit(key, DetailsUiState.Idle) },
onError = { uiState.tryEmit(key, DetailsUiState.Error(it)) },
)
diff --git a/app/src/main/kotlin/com/skydoves/pokedex/compose/feature/details/PokedexDetails.kt b/app/src/main/kotlin/com/skydoves/pokedex/compose/feature/details/PokedexDetails.kt
index ef9182b..784070e 100644
--- a/app/src/main/kotlin/com/skydoves/pokedex/compose/feature/details/PokedexDetails.kt
+++ b/app/src/main/kotlin/com/skydoves/pokedex/compose/feature/details/PokedexDetails.kt
@@ -22,6 +22,7 @@
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope
+import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -47,6 +48,7 @@
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
@@ -60,11 +62,15 @@
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.palette.graphics.Palette
+import coil3.compose.AsyncImage
+import coil3.request.ImageRequest
+import coil3.request.crossfade
import com.bumptech.glide.integration.compose.CrossFade
import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi
import com.bumptech.glide.integration.compose.GlideImage
import com.bumptech.glide.integration.compose.placeholder
import com.skydoves.pokedex.compose.R
+import com.skydoves.pokedex.compose.core.PokedexFeatureFlags
import com.skydoves.pokedex.compose.core.data.repository.details.FakeDetailsRepository
import com.skydoves.pokedex.compose.core.designsystem.component.PokedexCircularProgress
import com.skydoves.pokedex.compose.core.designsystem.component.PokedexText
@@ -110,7 +116,6 @@
}
}
-@OptIn(ExperimentalGlideComposeApi::class)
@Composable
private fun DetailsHeader(
sharedTransitionScope: SharedTransitionScope,
@@ -166,7 +171,8 @@
fontSize = 18.sp,
)
- GlideImage(
+ PokemonHeaderImage(
+ pokemon,
modifier =
Modifier.align(Alignment.BottomCenter)
.padding(bottom = 20.dp)
@@ -180,12 +186,7 @@
),
animatedVisibilityScope = animatedVisibilityScope,
boundsTransform = boundsTransform,
- ),
- model = pokemon?.imageUrl,
- contentScale = ContentScale.Inside,
- transition = CrossFade,
- contentDescription = pokemon?.name,
- loading = placeholder(painterResource(id = R.drawable.pokemon_preview)),
+ )
)
}
@@ -212,6 +213,33 @@
)
}
+@OptIn(ExperimentalGlideComposeApi::class)
+@Composable
+private fun PokemonHeaderImage(pokemon: Pokemon?, modifier: Modifier) {
+ if (PokedexFeatureFlags.UseCoil) {
+ AsyncImage(
+ modifier = modifier,
+ model =
+ ImageRequest.Builder(LocalContext.current)
+ .data(pokemon?.imageUrl)
+ .crossfade(PokemonHeaderImageCrossfadeDurationMillis)
+ .build(),
+ contentDescription = pokemon?.name,
+ contentScale = ContentScale.Inside,
+ placeholder = painterResource(id = R.drawable.pokemon_preview),
+ )
+ } else {
+ GlideImage(
+ modifier = modifier,
+ model = pokemon?.imageUrl,
+ contentScale = ContentScale.Inside,
+ transition = CrossFade(tween(PokemonHeaderImageCrossfadeDurationMillis)),
+ contentDescription = pokemon?.name,
+ loading = placeholder(painterResource(id = R.drawable.pokemon_preview)),
+ )
+ }
+}
+
@Composable
private fun DetailsInfo(pokemonInfo: PokemonInfo) {
Row(
@@ -311,3 +339,5 @@
)
}
}
+
+private const val PokemonHeaderImageCrossfadeDurationMillis = 250
diff --git a/app/src/main/kotlin/com/skydoves/pokedex/compose/feature/home/PokedexHome.kt b/app/src/main/kotlin/com/skydoves/pokedex/compose/feature/home/PokedexHome.kt
index 6a1c102..4cb1a37 100644
--- a/app/src/main/kotlin/com/skydoves/pokedex/compose/feature/home/PokedexHome.kt
+++ b/app/src/main/kotlin/com/skydoves/pokedex/compose/feature/home/PokedexHome.kt
@@ -23,6 +23,7 @@
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope
+import androidx.compose.animation.core.tween
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -33,19 +34,23 @@
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
-import androidx.compose.foundation.lazy.grid.itemsIndexed
+import androidx.compose.foundation.lazy.grid.items
+import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardColors
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
@@ -57,11 +62,15 @@
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.palette.graphics.Palette
+import coil3.compose.AsyncImage
+import coil3.request.ImageRequest
+import coil3.request.crossfade
import com.bumptech.glide.integration.compose.CrossFade
import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi
import com.bumptech.glide.integration.compose.GlideImage
import com.bumptech.glide.integration.compose.placeholder
import com.skydoves.pokedex.compose.R
+import com.skydoves.pokedex.compose.core.PokedexFeatureFlags
import com.skydoves.pokedex.compose.core.data.repository.home.FakeHomeRepository
import com.skydoves.pokedex.compose.core.designsystem.component.PokedexAppBar
import com.skydoves.pokedex.compose.core.designsystem.component.PokedexCircularProgress
@@ -108,18 +117,23 @@
fetchNextPokemonList: () -> Unit,
) {
Box(modifier = Modifier.fillMaxSize()) {
- val threadHold = 8
+ val gridState = rememberLazyGridState()
+ LaunchedEffect(gridState) {
+ val paginationThreshold = pokemonList.size - PaginationBufferSize
+ snapshotFlow { gridState.firstVisibleItemIndex >= paginationThreshold }
+ .collect {
+ if (uiState != HomeUiState.Loading) {
+ fetchNextPokemonList()
+ }
+ }
+ }
LazyVerticalGrid(
+ state = gridState,
modifier = Modifier.testTag("PokedexList"),
columns = GridCells.Fixed(2),
contentPadding = PaddingValues(6.dp),
) {
- itemsIndexed(items = pokemonList, key = { _, pokemon -> pokemon.name }) { index, pokemon
- ->
- if ((index + threadHold) >= pokemonList.size && uiState != HomeUiState.Loading) {
- fetchNextPokemonList()
- }
-
+ items(items = pokemonList, key = { pokemon -> pokemon.name }) { pokemon ->
PokemonCard(
animatedVisibilityScope = animatedVisibilityScope,
sharedTransitionScope = sharedTransitionScope,
@@ -160,7 +174,7 @@
),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
) {
- GlideImage(
+ PokemonCardImage(
modifier =
Modifier.align(Alignment.CenterHorizontally)
.padding(top = 20.dp)
@@ -175,16 +189,7 @@
animatedVisibilityScope = animatedVisibilityScope,
boundsTransform = boundsTransform,
),
- contentDescription = pokemon.name,
- model = pokemon.imageUrl,
- contentScale = ContentScale.Inside,
- transition = CrossFade,
- loading =
- placeholder(
- painterResource(
- id = R.drawable.pokemon_preview,
- )
- ),
+ pokemon = pokemon
)
Text(
@@ -211,6 +216,33 @@
}
}
+@Composable
+@OptIn(ExperimentalGlideComposeApi::class)
+private fun PokemonCardImage(pokemon: Pokemon, modifier: Modifier = Modifier) {
+ if (PokedexFeatureFlags.UseCoil) {
+ AsyncImage(
+ modifier = modifier,
+ contentDescription = pokemon.name,
+ model =
+ ImageRequest.Builder(LocalContext.current)
+ .data(pokemon.imageUrl)
+ .crossfade(PokemonCardImageCrossfadeDurationMillis)
+ .build(),
+ contentScale = ContentScale.Inside,
+ placeholder = painterResource(id = R.drawable.pokemon_preview),
+ )
+ } else {
+ GlideImage(
+ modifier = modifier,
+ contentDescription = pokemon.name,
+ model = pokemon.imageUrl,
+ contentScale = ContentScale.Inside,
+ transition = CrossFade(tween(PokemonCardImageCrossfadeDurationMillis)),
+ loading = placeholder(painterResource(id = R.drawable.pokemon_preview)),
+ )
+ }
+}
+
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
@@ -244,3 +276,6 @@
)
}
}
+
+private const val PaginationBufferSize = 8
+private const val PokemonCardImageCrossfadeDurationMillis = 250
diff --git a/app/src/main/kotlin/com/skydoves/pokedex/compose/ui/PokedexGlideAppModule.kt b/app/src/main/kotlin/com/skydoves/pokedex/compose/ui/PokedexGlideAppModule.kt
new file mode 100644
index 0000000..12a9330
--- /dev/null
+++ b/app/src/main/kotlin/com/skydoves/pokedex/compose/ui/PokedexGlideAppModule.kt
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.skydoves.pokedex.compose.ui
+
+import android.content.Context
+import com.bumptech.glide.Glide
+import com.bumptech.glide.Registry
+import com.bumptech.glide.annotation.GlideModule
+import com.bumptech.glide.integration.okhttp3.OkHttpUrlLoader
+import com.bumptech.glide.load.model.GlideUrl
+import com.bumptech.glide.module.AppGlideModule
+import com.skydoves.pokedex.compose.core.network.di.ModuleLocator
+import java.io.InputStream
+
+@GlideModule
+class PokedexGlideAppModule : AppGlideModule() {
+ override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
+ val okHttpClient = ModuleLocator.networkModule.okHttpClientFactory()
+ val loaderFactory = OkHttpUrlLoader.Factory(okHttpClient)
+ registry.replace<GlideUrl, InputStream>(
+ /* modelClass = */ GlideUrl::class.java,
+ /* dataClass = */ InputStream::class.java,
+ /* factory = */ loaderFactory
+ )
+ }
+}
diff --git a/app/src/main/kotlin/com/skydoves/pokedex/compose/ui/PokedexMain.kt b/app/src/main/kotlin/com/skydoves/pokedex/compose/ui/PokedexMain.kt
index 9cfb72c..bc9c595 100644
--- a/app/src/main/kotlin/com/skydoves/pokedex/compose/ui/PokedexMain.kt
+++ b/app/src/main/kotlin/com/skydoves/pokedex/compose/ui/PokedexMain.kt
@@ -16,6 +16,7 @@
package com.skydoves.pokedex.compose.ui
+import android.os.Trace
import androidx.activity.ComponentActivity
import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.Composable
@@ -25,11 +26,16 @@
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.navigation.compose.rememberNavController
+import coil3.ImageLoader
+import coil3.compose.setSingletonImageLoaderFactory
+import coil3.network.okhttp.OkHttpNetworkFetcherFactory
+import com.skydoves.pokedex.compose.core.PokedexFeatureFlags
import com.skydoves.pokedex.compose.core.designsystem.theme.PokedexTheme
import com.skydoves.pokedex.compose.core.navigation.AppComposeNavigator
import com.skydoves.pokedex.compose.core.navigation.LocalComposeNavigator
import com.skydoves.pokedex.compose.core.navigation.PokedexComposeNavigator
import com.skydoves.pokedex.compose.core.navigation.PokedexScreen
+import com.skydoves.pokedex.compose.core.network.di.ModuleLocator
import com.skydoves.pokedex.compose.navigation.PokedexNavHost
@Composable
@@ -41,7 +47,13 @@
val context = LocalContext.current
DisposableEffect(context) {
(context as? ComponentActivity)?.enableEdgeToEdge()
- onDispose {}
+ Trace.beginSection("ModuleLocator.attach")
+ ModuleLocator.attach(context = { context })
+ Trace.endSection()
+ onDispose { ModuleLocator.detach() }
+ }
+ if (PokedexFeatureFlags.UseCoil) {
+ ConfigureCoil()
}
val navHostController = rememberNavController()
LaunchedEffect(Unit) { composeNavigator.handleNavigationCommands(navHostController) }
@@ -49,3 +61,18 @@
}
}
}
+
+@Composable
+private fun ConfigureCoil() {
+ setSingletonImageLoaderFactory { context ->
+ ImageLoader.Builder(context)
+ .components {
+ add(
+ OkHttpNetworkFetcherFactory(
+ callFactory = ModuleLocator.networkModule.okHttpClient.newBuilder().build()
+ )
+ )
+ }
+ .build()
+ }
+}