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) }