Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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<String, Color>() }
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 }) {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -317,6 +341,7 @@ private fun ProfileAvatarCard(
isEditMode: Boolean,
animDelay: Int,
onHoverChange: (Boolean) -> Unit,
onDominantColor: (Color) -> Unit,
onClick: () -> Unit,
) {
val avatarColor = remember(profile.avatarColorHex) {
Expand All @@ -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<Painter?>(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) }
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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),
)
}
}
Loading