diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileModels.kt index ac0a9ca4f..73b839869 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileModels.kt @@ -97,3 +97,20 @@ fun profileAvatarImageUrl(profile: NuvioProfile, avatar: AvatarCatalogItem?): St ?.storagePath ?.takeIf { it.isNotBlank() } ?.let(::avatarStorageUrl) + +/** + * Resolves the colour that drives the animated profile hover background. + * + * Built-in avatars (and any profile without a valid custom image URL) keep using their curated + * [NuvioProfile.avatarColorHex]. Profiles using a custom URL image use [extractedColor] once it has + * been extracted from the loaded image, falling back to [NuvioProfile.avatarColorHex] until then. + */ +fun resolveProfileHoverColor(profile: NuvioProfile?, extractedColor: Color?): Color { + if (profile == null) return Color(0xFF1E88E5) + val hasCustomImageUrl = normalizedAvatarUrl(profile.avatarUrl) != null + return if (hasCustomImageUrl && extractedColor != null) { + extractedColor + } else { + parseHexColor(profile.avatarColorHex) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSelectionScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSelectionScreen.kt index 0f2c3e2ad..adc1ab798 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSelectionScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSelectionScreen.kt @@ -42,6 +42,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState @@ -52,6 +53,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -61,6 +63,7 @@ import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.nuvio.app.isDesktop import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage +import com.kmpalette.extensions.painter.rememberPainterDominantColorState import com.nuvio.app.core.auth.AuthRepository import com.nuvio.app.core.auth.AuthState import com.nuvio.app.core.ui.ProfileMeshBackground @@ -107,15 +110,26 @@ fun ProfileSelectionScreen( val statusBarTop = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() val profiles = profileState.profiles - val backgroundProfileColor = remember(profileState.activeProfile, profiles, hoveredProfileIndex) { - val hoveredProfile = if (isDesktop) { - profiles.firstOrNull { it.profileIndex == hoveredProfileIndex } - } else { - null - } - val sourceProfile = hoveredProfile ?: profileState.activeProfile ?: profiles.firstOrNull() - sourceProfile?.avatarColorHex?.let(::parseHexColor) ?: Color(0xFF1E88E5) + // Dominant colours extracted from custom URL avatar images, keyed by image URL so repeated + // hovering reads the cache instead of re-extracting the palette. + val extractedAvatarColors = remember { mutableStateMapOf() } + val hoveredProfile = if (isDesktop) { + profiles.firstOrNull { it.profileIndex == hoveredProfileIndex } + } else { + null } + val backgroundSourceProfile = hoveredProfile ?: profileState.activeProfile ?: profiles.firstOrNull() + val backgroundExtractedColor = if (isDesktop) { + backgroundSourceProfile + ?.let { normalizedAvatarUrl(it.avatarUrl) } + ?.let { url -> extractedAvatarColors[url] } + } else { + null + } + val backgroundProfileColor = resolveProfileHoverColor( + profile = backgroundSourceProfile, + extractedColor = backgroundExtractedColor, + ) LaunchedEffect(profiles) { if (hoveredProfileIndex != null && profiles.none { it.profileIndex == hoveredProfileIndex }) { @@ -193,6 +207,11 @@ fun ProfileSelectionScreen( isEditMode = isEditMode, animDelay = currentIndex * 80, onHoverChange = { isHovered -> updateHoveredProfile(profile, isHovered) }, + onDominantColor = { color -> + normalizedAvatarUrl(profile.avatarUrl)?.let { url -> + extractedAvatarColors[url] = color + } + }, onClick = { if (isEditMode) { onEditProfile(profile) @@ -234,6 +253,11 @@ fun ProfileSelectionScreen( isEditMode = isEditMode, animDelay = currentIndex * 80, onHoverChange = { isHovered -> updateHoveredProfile(profile, isHovered) }, + onDominantColor = { color -> + normalizedAvatarUrl(profile.avatarUrl)?.let { url -> + extractedAvatarColors[url] = color + } + }, onClick = { if (isEditMode) { onEditProfile(profile) @@ -317,6 +341,7 @@ private fun ProfileAvatarCard( isEditMode: Boolean, animDelay: Int, onHoverChange: (Boolean) -> Unit, + onDominantColor: (Color) -> Unit, onClick: () -> Unit, ) { val avatarColor = remember(profile.avatarColorHex) { @@ -330,6 +355,27 @@ private fun ProfileAvatarCard( profileAvatarImageUrl(profile, avatarItem) } + val customImageUrl = remember(profile.avatarUrl) { normalizedAvatarUrl(profile.avatarUrl) } + val shouldExtractDominantColor = isDesktop && customImageUrl != null + val currentOnDominantColor = rememberUpdatedState(onDominantColor) + val dominantColorState = rememberPainterDominantColorState( + defaultColor = avatarColor, + defaultOnColor = avatarColor, + ) + var loadedAvatarPainter by remember(customImageUrl) { mutableStateOf(null) } + var hasExtractedDominantColor by remember(customImageUrl) { mutableStateOf(false) } + + LaunchedEffect(shouldExtractDominantColor, loadedAvatarPainter) { + val painter = loadedAvatarPainter + if (shouldExtractDominantColor && !hasExtractedDominantColor && painter != null) { + runCatching { dominantColorState.updateFrom(painter) } + .onSuccess { + hasExtractedDominantColor = true + currentOnDominantColor.value(dominantColorState.color) + } + } + } + val animAlpha = remember { Animatable(0f) } val animScale = remember { Animatable(0.85f) } val animOffset = remember { Animatable(30f) } @@ -423,6 +469,11 @@ private fun ProfileAvatarCard( contentDescription = avatarItem?.displayName ?: profile.name, modifier = Modifier.size(100.dp).clip(CircleShape), contentScale = ContentScale.Crop, + onSuccess = if (shouldExtractDominantColor) { + { state -> loadedAvatarPainter = state.painter } + } else { + null + }, ) } else if (profile.name.isNotBlank()) { Text( diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/profiles/ProfileHoverColorTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/profiles/ProfileHoverColorTest.kt new file mode 100644 index 000000000..8d89e81d0 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/profiles/ProfileHoverColorTest.kt @@ -0,0 +1,71 @@ +package com.nuvio.app.features.profiles + +import androidx.compose.ui.graphics.Color +import kotlin.test.Test +import kotlin.test.assertEquals + +class ProfileHoverColorTest { + + private val extracted = Color(0xFF123456) + + @Test + fun `built-in avatar ignores extracted colour and uses avatarColorHex`() { + val profile = NuvioProfile( + name = "Built-in", + avatarColorHex = "#E53935", + avatarId = "avatar-1", + avatarUrl = null, + ) + assertEquals( + parseHexColor("#E53935"), + resolveProfileHoverColor(profile, extractedColor = extracted), + ) + } + + @Test + fun `custom url avatar uses extracted colour when available`() { + val profile = NuvioProfile( + name = "Custom", + avatarColorHex = "#1E88E5", + avatarUrl = "https://example.test/avatar.png", + ) + assertEquals( + extracted, + resolveProfileHoverColor(profile, extractedColor = extracted), + ) + } + + @Test + fun `custom url avatar falls back to avatarColorHex before extraction`() { + val profile = NuvioProfile( + name = "Custom", + avatarColorHex = "#43A047", + avatarUrl = "https://example.test/avatar.png", + ) + assertEquals( + parseHexColor("#43A047"), + resolveProfileHoverColor(profile, extractedColor = null), + ) + } + + @Test + fun `invalid url is treated as non-custom and keeps avatarColorHex`() { + val profile = NuvioProfile( + name = "Broken", + avatarColorHex = "#FB8C00", + avatarUrl = "not a url", + ) + assertEquals( + parseHexColor("#FB8C00"), + resolveProfileHoverColor(profile, extractedColor = extracted), + ) + } + + @Test + fun `null profile uses the default colour`() { + assertEquals( + Color(0xFF1E88E5), + resolveProfileHoverColor(profile = null, extractedColor = extracted), + ) + } +}