Revert^2 "Use gradient bitmaps for poxedex-compose images"
52ebb56f3feaa51576b1a3c15cf03d299fc4d23f
Change-Id: I5ba3562398311cd13a6d13265ea32e2a53d5bab1
diff --git a/app/build.gradle b/app/build.gradle
index 5f49e03..eac44e2 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -43,6 +43,7 @@
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
@@ -54,6 +55,8 @@
// 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")
// Kotlinx
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.7")
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/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/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/service/PokedexMockWebServer.kt b/app/src/main/kotlin/com/skydoves/pokedex/compose/core/network/service/PokedexMockWebServer.kt
index 37ec71a..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,48 +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 responseData = fakePokemonResponse()
- 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/viewmodel/PokedexViewModelFactory.kt b/app/src/main/kotlin/com/skydoves/pokedex/compose/core/viewmodel/PokedexViewModelFactory.kt
index 6da9c40..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,33 +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,
- networkModule.baseUrl
- )
- 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/feature/home/PokedexHome.kt b/app/src/main/kotlin/com/skydoves/pokedex/compose/feature/home/PokedexHome.kt
index 6a1c102..adf454e 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
@@ -33,16 +33,19 @@
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
@@ -108,18 +111,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,
@@ -179,12 +187,7 @@
model = pokemon.imageUrl,
contentScale = ContentScale.Inside,
transition = CrossFade,
- loading =
- placeholder(
- painterResource(
- id = R.drawable.pokemon_preview,
- )
- ),
+ loading = placeholder(painterResource(id = R.drawable.pokemon_preview)),
)
Text(
@@ -244,3 +247,5 @@
)
}
}
+
+private const val PaginationBufferSize = 8
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..4613103 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
@@ -30,6 +31,7 @@
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 +43,10 @@
val context = LocalContext.current
DisposableEffect(context) {
(context as? ComponentActivity)?.enableEdgeToEdge()
- onDispose {}
+ Trace.beginSection("ModuleLocator.attach")
+ ModuleLocator.attach(context = { context })
+ Trace.endSection()
+ onDispose { ModuleLocator.detach() }
}
val navHostController = rememberNavController()
LaunchedEffect(Unit) { composeNavigator.handleNavigationCommands(navHostController) }