diff --git a/app/shared/build.gradle.kts b/app/shared/build.gradle.kts index 41eb8693..ba6f90d8 100644 --- a/app/shared/build.gradle.kts +++ b/app/shared/build.gradle.kts @@ -61,6 +61,7 @@ kotlin { implementation(libs.haze) implementation(libs.haze.materials) + implementation(libs.kache) api(libs.ktor) api(libs.ktor.content.negotiation) diff --git a/app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/ui/screen/initial/favorite/component/SeriesCard.kt b/app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/ui/screen/initial/favorite/component/SeriesCard.kt index 2254fcd2..d185a55f 100644 --- a/app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/ui/screen/initial/favorite/component/SeriesCard.kt +++ b/app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/ui/screen/initial/favorite/component/SeriesCard.kt @@ -22,6 +22,8 @@ import dev.datlag.burningseries.model.BSUtil import dev.datlag.burningseries.shared.common.bottomShadowBrush import dev.datlag.burningseries.shared.ui.custom.Cover import dev.datlag.burningseries.shared.ui.theme.SchemeTheme +import dev.datlag.burningseries.shared.ui.theme.onPrimary +import dev.datlag.burningseries.shared.ui.theme.primary import dev.datlag.burningseries.shared.ui.theme.rememberSchemeThemeDominantColorState @Composable @@ -32,7 +34,7 @@ fun SeriesCard( ) { SchemeTheme( key = series.hrefPrimary - ) { + ) { updater -> Card( modifier = modifier, onClick = { @@ -42,15 +44,11 @@ fun SeriesCard( Box( modifier = Modifier.fillMaxSize() ) { - val scope = rememberCoroutineScope() val colorState = rememberSchemeThemeDominantColorState( key = series.hrefPrimary, applyMinContrast = true, minContrastBackgroundColor = MaterialTheme.colorScheme.surfaceVariant ) - val animatedColor by animateColorAsState( - targetValue = colorState.color - ) Cover( key = series.coverHref, @@ -59,11 +57,7 @@ fun SeriesCard( modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop, onSuccess = { state -> - SchemeTheme.update( - key = series.hrefPrimary, - input = state.painter, - scope = scope - ) + updater?.update(state.painter) } ) @@ -71,7 +65,7 @@ fun SeriesCard( modifier = Modifier .align(Alignment.BottomStart) .fillMaxWidth() - .bottomShadowBrush(animatedColor) + .bottomShadowBrush(colorState.primary) .padding(16.dp) .padding(top = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically) @@ -83,13 +77,13 @@ fun SeriesCard( fontWeight = FontWeight.Bold, overflow = TextOverflow.Ellipsis, modifier = Modifier.fillMaxWidth(), - color = colorState.onColor + color = colorState.onPrimary ) series.subTitle?.let { Text( text = it, modifier = Modifier.fillMaxWidth(), - color = colorState.onColor, + color = colorState.onPrimary, maxLines = 2 ) } diff --git a/app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/ui/screen/initial/series/SeriesScreen.kt b/app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/ui/screen/initial/series/SeriesScreen.kt index 634662d7..39b74e38 100644 --- a/app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/ui/screen/initial/series/SeriesScreen.kt +++ b/app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/ui/screen/initial/series/SeriesScreen.kt @@ -180,7 +180,7 @@ private fun DefaultScreen(component: SeriesComponent, loadingEpisode: String?) { .clip(MaterialTheme.shapes.medium) .align(Alignment.CenterVertically) ) { - val scope = rememberCoroutineScope() + val updater = SchemeTheme.create(commonHref) Cover( modifier = Modifier.fillMaxSize(), @@ -190,7 +190,7 @@ private fun DefaultScreen(component: SeriesComponent, loadingEpisode: String?) { stringResource(SharedRes.strings.loading_intent_series) }, onSuccess = { success -> - SchemeTheme.update(commonHref, success.painter, scope) + updater?.update(success.painter) } ) IconButton( diff --git a/app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/ui/theme/DynamicMaterialTheme.kt b/app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/ui/theme/DynamicMaterialTheme.kt new file mode 100644 index 00000000..c19aad03 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/ui/theme/DynamicMaterialTheme.kt @@ -0,0 +1,86 @@ +package dev.datlag.burningseries.shared.ui.theme + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import com.materialkolor.Contrast +import com.materialkolor.PaletteStyle +import com.materialkolor.rememberDynamicColorScheme +import dev.datlag.burningseries.shared.LocalDarkMode + +@Composable +fun DynamicMaterialTheme( + seedColor: Color?, + animate: Boolean = false, + animationSpec: AnimationSpec = spring(stiffness = Spring.StiffnessLow), + content: @Composable () -> Unit +) { + val dynamicColorScheme = if (seedColor != null) { + rememberDynamicColorScheme( + seedColor = seedColor, + isDark = LocalDarkMode.current, + style = PaletteStyle.TonalSpot, + contrastLevel = Contrast.Default.value, + isExtendedFidelity = false + ) + } else { + MaterialTheme.colorScheme + } + val animatedColorScheme = if (!animate) { + dynamicColorScheme + } else { + dynamicColorScheme.copy( + primary = dynamicColorScheme.primary.animate(animationSpec), + primaryContainer = dynamicColorScheme.primaryContainer.animate(animationSpec), + secondary = dynamicColorScheme.secondary.animate(animationSpec), + secondaryContainer = dynamicColorScheme.secondaryContainer.animate(animationSpec), + tertiary = dynamicColorScheme.tertiary.animate(animationSpec), + tertiaryContainer = dynamicColorScheme.tertiaryContainer.animate(animationSpec), + background = dynamicColorScheme.background.animate(animationSpec), + surface = dynamicColorScheme.surface.animate(animationSpec), + surfaceTint = dynamicColorScheme.surfaceTint.animate(animationSpec), + surfaceBright = dynamicColorScheme.surfaceBright.animate(animationSpec), + surfaceDim = dynamicColorScheme.surfaceDim.animate(animationSpec), + surfaceContainer = dynamicColorScheme.surfaceContainer.animate(animationSpec), + surfaceContainerHigh = dynamicColorScheme.surfaceContainerHigh.animate(animationSpec), + surfaceContainerHighest = dynamicColorScheme.surfaceContainerHighest.animate(animationSpec), + surfaceContainerLow = dynamicColorScheme.surfaceContainerLow.animate(animationSpec), + surfaceContainerLowest = dynamicColorScheme.surfaceContainerLowest.animate(animationSpec), + surfaceVariant = dynamicColorScheme.surfaceVariant.animate(animationSpec), + error = dynamicColorScheme.error.animate(animationSpec), + errorContainer = dynamicColorScheme.errorContainer.animate(animationSpec), + onPrimary = dynamicColorScheme.onPrimary.animate(animationSpec), + onPrimaryContainer = dynamicColorScheme.onPrimaryContainer.animate(animationSpec), + onSecondary = dynamicColorScheme.onSecondary.animate(animationSpec), + onSecondaryContainer = dynamicColorScheme.onSecondaryContainer.animate(animationSpec), + onTertiary = dynamicColorScheme.onTertiary.animate(animationSpec), + onTertiaryContainer = dynamicColorScheme.onTertiaryContainer.animate(animationSpec), + onBackground = dynamicColorScheme.onBackground.animate(animationSpec), + onSurface = dynamicColorScheme.onSurface.animate(animationSpec), + onSurfaceVariant = dynamicColorScheme.onSurfaceVariant.animate(animationSpec), + onError = dynamicColorScheme.onError.animate(animationSpec), + onErrorContainer = dynamicColorScheme.onErrorContainer.animate(animationSpec), + inversePrimary = dynamicColorScheme.inversePrimary.animate(animationSpec), + inverseSurface = dynamicColorScheme.inverseSurface.animate(animationSpec), + inverseOnSurface = dynamicColorScheme.inverseOnSurface.animate(animationSpec), + outline = dynamicColorScheme.outline.animate(animationSpec), + outlineVariant = dynamicColorScheme.outlineVariant.animate(animationSpec), + scrim = dynamicColorScheme.scrim.animate(animationSpec), + ) + } + + MaterialTheme( + colorScheme = animatedColorScheme + ) { + content() + } +} + +@Composable +private fun Color.animate(animationSpec: AnimationSpec): Color { + return animateColorAsState(this, animationSpec).value +} \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/ui/theme/SchemeTheme.kt b/app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/ui/theme/SchemeTheme.kt index 013fc4c1..7c950b69 100644 --- a/app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/ui/theme/SchemeTheme.kt +++ b/app/shared/src/commonMain/kotlin/dev/datlag/burningseries/shared/ui/theme/SchemeTheme.kt @@ -5,7 +5,9 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.compositeOver import androidx.compose.ui.graphics.luminance @@ -14,6 +16,10 @@ import com.kmpalette.DominantColorState import com.kmpalette.palette.graphics.Palette import com.kmpalette.rememberPainterDominantColorState import com.materialkolor.DynamicMaterialTheme +import com.mayakapps.kache.InMemoryKache +import com.mayakapps.kache.KacheStrategy +import dev.datlag.burningseries.model.common.scopeCatching +import dev.datlag.burningseries.model.common.suspendCatching import dev.datlag.burningseries.shared.LocalDarkMode import dev.datlag.burningseries.shared.common.ioDispatcher import dev.datlag.burningseries.shared.common.launchIO @@ -23,70 +29,104 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.* import kotlin.coroutines.CoroutineContext +val DominantColorState?.primary + @Composable + get() = this?.color ?: MaterialTheme.colorScheme.primary + +val DominantColorState?.onPrimary + @Composable + get() = this?.onColor ?: MaterialTheme.colorScheme.onPrimary + +val Color.plainOnColor: Color + get() = if (this.luminance() > 0.5F) { + Color.Black + } else { + Color.White + } + data object SchemeTheme { internal val commonSchemeKey = MutableStateFlow(null) - internal val colorState = MutableStateFlow>>(emptyMap()) - internal val itemScheme = MutableStateFlow>(emptyMap()) + private val kache = InMemoryKache>( + maxSize = 25L * 1024 * 1024 + ) { + strategy = KacheStrategy.LRU + } - internal var _state: DominantColorState? = null - internal val state: DominantColorState - get() = _state!! + internal fun get(key: Any) = scopeCatching { + kache.getIfAvailable(key) + }.getOrNull() - fun setCommon(key: Any?) { - commonSchemeKey.update { key } - } + internal suspend fun getOrPut(key: Any, fallback: DominantColorState) = suspendCatching { + kache.getIfAvailable(key) + }.getOrNull() ?: suspendCatching { + kache.put(key, fallback) + }.getOrNull() ?: suspendCatching { + kache.getOrPut(key) { fallback } + }.getOrNull() @Composable - fun update(key: Any?, input: Painter?) { - if (_state == null || input == null) { - return + fun create( + key: Any?, + defaultColor: Color? = null, + defaultOnColor: Color? = null, + ): Updater? { + if (key == null) { + return null } - LaunchedEffect(key, input) { - suspendUpdate(key, input) + val onColor = defaultOnColor ?: remember(defaultColor) { + defaultColor?.plainOnColor + } + val state = rememberSchemeThemeDominantColorState( + key = key, + defaultColor = defaultColor ?: MaterialTheme.colorScheme.primary, + defaultOnColor = onColor ?: MaterialTheme.colorScheme.onPrimary, + ) + val scope = rememberCoroutineScope() + return remember(state, scope) { + state?.let { Updater.State(scope, it) } } } - fun update(key: Any?, input: Painter?, scope: CoroutineScope) { - scope.launchIO { - suspendUpdate(key, input) - } + fun setCommon(key: Any?) { + commonSchemeKey.update { key } } - suspend fun suspendUpdate(key: Any?, input: Painter?) { - if (_state == null || key == null || input == null) { - return - } + sealed interface Updater { + fun update(input: Painter?) - withIOContext { - val useState = (colorState.firstOrNull() ?: colorState.value)[key] ?: state - useState.updateFrom(input) + data class State( + private val scope: CoroutineScope, + private val state: DominantColorState + ) : Updater { + override fun update(input: Painter?) { + if (input == null) { + return + } - itemScheme.getAndUpdate { - it.toMutableMap().apply { - put(key, useState.color) + scope.launchIO { + state.updateFrom(input) } } } - } -} - -@Composable -fun rememberSchemeThemeDominantColor( - key: Any? -): Color? { - if (SchemeTheme._state == null) { - SchemeTheme._state = rememberPainterDominantColorState( - coroutineContext = ioDispatcher() - ) - } - val color by remember(key) { - SchemeTheme.itemScheme.map { it[key] } - }.collectAsStateWithLifecycle(SchemeTheme.itemScheme.value[key]) + data class Default( + private val key: Any, + private val scope: CoroutineScope, + ) : Updater { + override fun update(input: Painter?) { + if (input == null) { + return + } - return color + scope.launchIO { + val state = get(key) ?: return@launchIO + state.updateFrom(input) + } + } + } + } } @Composable @@ -94,29 +134,35 @@ fun rememberSchemeThemeDominantColorState( key: Any?, defaultColor: Color = MaterialTheme.colorScheme.primary, defaultOnColor: Color = MaterialTheme.colorScheme.onPrimary, - coroutineContext: CoroutineContext = ioDispatcher(), isSwatchValid: (Palette.Swatch) -> Boolean = { true }, builder: Palette.Builder.() -> Unit = {}, -): DominantColorState { - val state by remember(key) { - SchemeTheme.colorState.map { it[key] } - }.collectAsStateWithLifecycle(SchemeTheme.colorState.value[key]) +): DominantColorState? { + if (key == null) { + return null + } + + val existingState = remember(key) { + SchemeTheme.get(key) + } ?: SchemeTheme.get(key) + + if (existingState != null) { + return existingState + } - return state ?: rememberPainterDominantColorState( + val fallbackState = rememberPainterDominantColorState( defaultColor = defaultColor, defaultOnColor = defaultOnColor, - coroutineContext = coroutineContext, builder = builder, - isSwatchValid = isSwatchValid - ).also { - if (key != null) { - SchemeTheme.colorState.update { map -> - map.toMutableMap().apply { - put(key, it) - } - } + isSwatchValid = isSwatchValid, + coroutineContext = ioDispatcher() + ) + val state by produceState?>(null, key) { + value = withIOContext { + SchemeTheme.getOrPut(key, fallbackState) ?: fallbackState } } + + return remember(state) { state ?: fallbackState } } @Composable @@ -126,14 +172,12 @@ fun rememberSchemeThemeDominantColorState( defaultOnColor: Color = MaterialTheme.colorScheme.onPrimary, clearFilter: Boolean = false, applyMinContrast: Boolean = false, - minContrastBackgroundColor: Color = Color.Transparent, - coroutineContext: CoroutineContext = ioDispatcher() -): DominantColorState { + minContrastBackgroundColor: Color = Color.Transparent +): DominantColorState? { return rememberSchemeThemeDominantColorState( key = key, defaultColor = defaultColor, defaultOnColor = defaultOnColor, - coroutineContext = coroutineContext, builder = { if (clearFilter) { clearFilters() @@ -152,21 +196,43 @@ fun rememberSchemeThemeDominantColorState( } @Composable -fun SchemeTheme(key: Any?, content: @Composable () -> Unit) { +fun SchemeTheme( + key: Any?, + animate: Boolean = true, + defaultColor: Color? = null, + defaultOnColor: Color? = null, + content: @Composable (SchemeTheme.Updater?) -> Unit +) { + val onColor = defaultOnColor ?: remember(defaultColor) { + defaultColor?.plainOnColor + } + val state = rememberSchemeThemeDominantColorState( + key = key, + defaultColor = defaultColor ?: MaterialTheme.colorScheme.primary, + defaultOnColor = onColor ?: MaterialTheme.colorScheme.onPrimary, + ) + val updater = SchemeTheme.create(key) + DynamicMaterialTheme( - seedColor = rememberSchemeThemeDominantColor(key) ?: MaterialTheme.colorScheme.primary, - useDarkTheme = LocalDarkMode.current, - animate = true + seedColor = state?.color, + animate = animate ) { - content() + content(updater) } } @Composable -fun CommonSchemeTheme(content: @Composable () -> Unit) { +fun CommonSchemeTheme( + animate: Boolean = true, + content: @Composable (SchemeTheme.Updater?) -> Unit +) { val key by SchemeTheme.commonSchemeKey.collectAsStateWithLifecycle() - SchemeTheme(key, content) + SchemeTheme( + key = key, + animate = animate, + content = content + ) } @Composable diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 423a0cf7..fb39c570 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -27,6 +27,7 @@ flowredux = "1.2.1" grpc = "1.59.0" haze = "0.6.2" jsunpacker = "1.0.2" +kache = "2.1.0" kcef = "2024.01.07.1" kmpalette = "3.1.0" kodein = "7.21.2" @@ -97,6 +98,7 @@ grpc = { group = "io.grpc", name = "grpc-protobuf", version.ref = "grpc" } haze = { group = "dev.chrisbanes.haze", name = "haze", version.ref = "haze" } haze-materials = { group = "dev.chrisbanes.haze", name = "haze-materials", version.ref = "haze" } jsunpacker = { group = "dev.datlag.jsunpacker", name = "jsunpacker", version.ref = "jsunpacker" } +kache = { group = "com.mayakapps.kache", name = "kache", version.ref = "kache" } kmpalette = { group = "com.kmpalette", name = "kmpalette-core", version.ref = "kmpalette" } kodein = { group = "org.kodein.di", name = "kodein-di", version.ref = "kodein" } kodein-compose = { group = "org.kodein.di", name = "kodein-di-framework-compose", version.ref = "kodein" }