From 2fa918fe4492d7dec7f8fbe6be406aecc7bd42c4 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Thu, 7 May 2026 13:14:19 +0530 Subject: [PATCH] feat: add support for custom avatar url --- .../composeResources/values/strings.xml | 5 ++ .../commonMain/kotlin/com/nuvio/app/App.kt | 8 +- .../features/profiles/ProfileEditScreen.kt | 89 ++++++++++++++++--- .../app/features/profiles/ProfileModels.kt | 19 ++++ .../features/profiles/ProfileRepository.kt | 9 +- .../profiles/ProfileSelectionScreen.kt | 15 ++-- .../features/profiles/ProfileSwitcherTab.kt | 24 +++-- 7 files changed, 134 insertions(+), 35 deletions(-) diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 81460967..e8b01632 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -1007,9 +1007,14 @@ Locked. Try again in %1$ds Avatar options will appear here when the catalog loads. Avatar: %1$s + Enter a valid http:// or https:// image URL. Choose an avatar Choose an avatar below. Create Profile + Custom avatar URL selected. + Custom avatar URL + Paste an image link, or leave this empty to use the built-in avatar catalog. + https://example.com/avatar.png All data for "%1$s" will be permanently deleted. Delete Profile Add Profile diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 987a0643..3eebbdac 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -135,7 +135,7 @@ import com.nuvio.app.features.profiles.ProfileEditScreen import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.profiles.ProfileSelectionScreen import com.nuvio.app.features.profiles.ProfileSwitcherTab -import com.nuvio.app.features.profiles.avatarStorageUrl +import com.nuvio.app.features.profiles.profileAvatarImageUrl import com.nuvio.app.features.search.SearchScreen import com.nuvio.app.features.settings.SettingsScreen import com.nuvio.app.features.settings.HomescreenSettingsScreen @@ -331,6 +331,7 @@ fun App() { profileState.activeProfile?.name, profileState.activeProfile?.avatarColorHex, profileState.activeProfile?.avatarId, + profileState.activeProfile?.avatarUrl, profileAvatars, ) { val activeProfile = profileState.activeProfile @@ -340,10 +341,7 @@ fun App() { NativeTabBridge.publishProfileTabIcon( name = activeProfile?.name, avatarColorHex = activeProfile?.avatarColorHex, - avatarImageUrl = avatarItem - ?.storagePath - ?.takeIf { it.isNotBlank() } - ?.let(::avatarStorageUrl), + avatarImageUrl = activeProfile?.let { profileAvatarImageUrl(it, avatarItem) }, avatarBackgroundColorHex = avatarItem?.bgColor, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileEditScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileEditScreen.kt index 83d26dc6..5f00697d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileEditScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileEditScreen.kt @@ -78,6 +78,7 @@ fun ProfileEditScreen( var name by rememberSaveable { mutableStateOf(currentProfile?.name ?: "") } var selectedAvatarId by rememberSaveable { mutableStateOf(currentProfile?.avatarId) } + var avatarUrl by rememberSaveable { mutableStateOf(currentProfile?.avatarUrl.orEmpty()) } var usesPrimaryAddons by rememberSaveable { mutableStateOf(currentProfile?.usesPrimaryAddons ?: false) } var isSaving by remember { mutableStateOf(false) } var showDeleteConfirm by remember { mutableStateOf(false) } @@ -90,17 +91,20 @@ fun ProfileEditScreen( AvatarRepository.fetchAvatars() AvatarRepository.refreshAvatars() } - LaunchedEffect(isNew, avatars, selectedAvatarId) { - if (isNew && selectedAvatarId == null && avatars.isNotEmpty()) { + LaunchedEffect(isNew, avatars, selectedAvatarId, avatarUrl) { + if (isNew && avatarUrl.isBlank() && selectedAvatarId == null && avatars.isNotEmpty()) { selectedAvatarId = avatars.first().id } } + val customAvatarUrl = remember(avatarUrl) { normalizedAvatarUrl(avatarUrl) } + val avatarUrlIsInvalid = avatarUrl.isNotBlank() && customAvatarUrl == null val selectedAvatarItem = remember(selectedAvatarId, avatars) { selectedAvatarId?.let { id -> avatars.find { it.id == id } } } - val previewAccent = remember(selectedAvatarItem, fallbackColorHex) { - parseHexColor(selectedAvatarItem?.bgColor ?: fallbackColorHex) + val visibleAvatarItem = if (customAvatarUrl == null) selectedAvatarItem else null + val previewAccent = remember(visibleAvatarItem, fallbackColorHex) { + parseHexColor(visibleAvatarItem?.bgColor ?: fallbackColorHex) } NuvioScreen(modifier = modifier) { @@ -123,12 +127,47 @@ fun ProfileEditScreen( usesPrimaryAddons = usesPrimaryAddons, onNameChange = { name = it }, onUsesPrimaryAddonsChange = { usesPrimaryAddons = it }, - selectedAvatar = selectedAvatarItem, + selectedAvatar = visibleAvatarItem, + customAvatarUrl = customAvatarUrl, accentColor = previewAccent, hasAvatarChoices = avatars.isNotEmpty(), ) } + item { + NuvioSurfaceCard { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text( + text = stringResource(Res.string.profile_custom_avatar_url), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = stringResource(Res.string.profile_custom_avatar_url_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + NuvioInputField( + value = avatarUrl, + onValueChange = { value -> + avatarUrl = value + if (value.isNotBlank()) { + selectedAvatarId = null + } + }, + placeholder = stringResource(Res.string.profile_custom_avatar_url_placeholder), + ) + if (avatarUrlIsInvalid) { + Text( + text = stringResource(Res.string.profile_avatar_url_invalid), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + } + } + item { NuvioSurfaceCard { Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { @@ -165,8 +204,11 @@ fun ProfileEditScreen( AvatarChoiceItem( avatar = avatar, size = avatarSize, - isSelected = avatar.id == selectedAvatarId, - onClick = { selectedAvatarId = avatar.id }, + isSelected = customAvatarUrl == null && avatar.id == selectedAvatarId, + onClick = { + avatarUrl = "" + selectedAvatarId = avatar.id + }, ) } } @@ -220,16 +262,17 @@ fun ProfileEditScreen( } else { stringResource(Res.string.collections_editor_save_changes) }, - enabled = name.isNotBlank() && !isSaving, + enabled = name.isNotBlank() && !avatarUrlIsInvalid && !isSaving, onClick = { isSaving = true scope.launch { - val avatarColorHex = selectedAvatarItem?.bgColor ?: fallbackColorHex + val avatarColorHex = visibleAvatarItem?.bgColor ?: fallbackColorHex if (isNew) { ProfileRepository.createProfile( name = name, avatarColorHex = avatarColorHex, - avatarId = selectedAvatarId, + avatarId = if (customAvatarUrl == null) selectedAvatarId else null, + avatarUrl = customAvatarUrl, usesPrimaryAddons = usesPrimaryAddons, ) } else { @@ -237,7 +280,8 @@ fun ProfileEditScreen( profileIndex = currentProfile!!.profileIndex, name = name, avatarColorHex = avatarColorHex, - avatarId = selectedAvatarId, + avatarId = if (customAvatarUrl == null) selectedAvatarId else null, + avatarUrl = customAvatarUrl, usesPrimaryAddons = usesPrimaryAddons, ) } @@ -330,6 +374,7 @@ private fun ProfileIdentityCard( onNameChange: (String) -> Unit, onUsesPrimaryAddonsChange: (Boolean) -> Unit, selectedAvatar: AvatarCatalogItem?, + customAvatarUrl: String?, accentColor: Color, hasAvatarChoices: Boolean, ) { @@ -345,16 +390,31 @@ private fun ProfileIdentityCard( .size(88.dp) .clip(CircleShape) .background( - if (selectedAvatar != null) accentColor else accentColor.copy(alpha = 0.18f), + if (selectedAvatar != null || customAvatarUrl != null) { + accentColor + } else { + accentColor.copy(alpha = 0.18f) + }, ) .border( width = 2.dp, - color = if (selectedAvatar == null) accentColor.copy(alpha = 0.35f) else Color.Transparent, + color = if (selectedAvatar == null && customAvatarUrl == null) { + accentColor.copy(alpha = 0.35f) + } else { + Color.Transparent + }, shape = CircleShape, ), contentAlignment = Alignment.Center, ) { - if (selectedAvatar != null) { + if (customAvatarUrl != null) { + AsyncImage( + model = customAvatarUrl, + contentDescription = name, + modifier = Modifier.size(88.dp).clip(CircleShape), + contentScale = ContentScale.Crop, + ) + } else if (selectedAvatar != null) { AsyncImage( model = avatarStorageUrl(selectedAvatar.storagePath), contentDescription = selectedAvatar.displayName, @@ -410,6 +470,7 @@ private fun ProfileIdentityCard( ) Text( text = when { + customAvatarUrl != null -> stringResource(Res.string.profile_custom_avatar_selected) selectedAvatar != null -> stringResource( Res.string.profile_avatar_selected, selectedAvatar.displayName, 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 3e91429f..f36aee81 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 @@ -12,6 +12,7 @@ data class NuvioProfile( val name: String = "", @SerialName("avatar_color_hex") val avatarColorHex: String = "#1E88E5", @SerialName("avatar_id") val avatarId: String? = null, + @SerialName("avatar_url") val avatarUrl: String? = null, @SerialName("uses_primary_addons") val usesPrimaryAddons: Boolean = false, @SerialName("uses_primary_plugins") val usesPrimaryPlugins: Boolean = false, @SerialName("pin_enabled") val pinEnabled: Boolean = false, @@ -28,6 +29,7 @@ data class ProfilePushPayload( @SerialName("uses_primary_addons") val usesPrimaryAddons: Boolean = false, @SerialName("uses_primary_plugins") val usesPrimaryPlugins: Boolean = false, @SerialName("avatar_id") val avatarId: String? = null, + @SerialName("avatar_url") val avatarUrl: String? = null, ) @Serializable @@ -74,3 +76,20 @@ val PROFILE_COLORS = listOf( fun avatarStorageUrl(storagePath: String): String = "${com.nuvio.app.core.network.SupabaseConfig.URL}/storage/v1/object/public/avatars/$storagePath" + +fun normalizedAvatarUrl(url: String?): String? = + url?.trim()?.takeIf { it.isValidAvatarUrl() } + +fun String.isValidAvatarUrl(): Boolean { + val value = trim() + return value.length <= 2048 && + !value.any { it.isWhitespace() } && + (value.startsWith("https://") || value.startsWith("http://")) +} + +fun profileAvatarImageUrl(profile: NuvioProfile, avatar: AvatarCatalogItem?): String? = + normalizedAvatarUrl(profile.avatarUrl) + ?: avatar + ?.storagePath + ?.takeIf { it.isNotBlank() } + ?.let(::avatarStorageUrl) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt index 01904938..0cb6cc27 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt @@ -179,6 +179,7 @@ object ProfileRepository { name: String, avatarColorHex: String, avatarId: String? = null, + avatarUrl: String? = null, usesPrimaryAddons: Boolean = false, ) { val existing = _state.value.profiles @@ -192,6 +193,7 @@ object ProfileRepository { usesPrimaryAddons = profile.usesPrimaryAddons, usesPrimaryPlugins = profile.usesPrimaryPlugins, avatarId = profile.avatarId, + avatarUrl = profile.avatarUrl, ) } + ProfilePushPayload( profileIndex = nextIndex, @@ -199,6 +201,7 @@ object ProfileRepository { avatarColorHex = avatarColorHex, usesPrimaryAddons = usesPrimaryAddons, avatarId = avatarId, + avatarUrl = avatarUrl, ) pushProfiles(allPayloads) @@ -209,6 +212,7 @@ object ProfileRepository { name: String, avatarColorHex: String, avatarId: String? = null, + avatarUrl: String? = null, usesPrimaryAddons: Boolean = false, ) { val allPayloads = _state.value.profiles.map { profile -> @@ -218,7 +222,8 @@ object ProfileRepository { name = name, avatarColorHex = avatarColorHex, usesPrimaryAddons = usesPrimaryAddons, - avatarId = avatarId ?: profile.avatarId, + avatarId = avatarId, + avatarUrl = avatarUrl, ) } else { ProfilePushPayload( @@ -228,6 +233,7 @@ object ProfileRepository { usesPrimaryAddons = profile.usesPrimaryAddons, usesPrimaryPlugins = profile.usesPrimaryPlugins, avatarId = profile.avatarId, + avatarUrl = profile.avatarUrl, ) } } @@ -357,6 +363,7 @@ object ProfileRepository { name = p.name, avatarColorHex = p.avatarColorHex, avatarId = p.avatarId, + avatarUrl = p.avatarUrl, usesPrimaryAddons = p.usesPrimaryAddons, usesPrimaryPlugins = p.usesPrimaryPlugins, ) 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 3d487c02..195ba674 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 @@ -304,6 +304,9 @@ private fun ProfileAvatarCard( val avatarItem = remember(profile.avatarId, avatars) { profile.avatarId?.let { id -> avatars.find { it.id == id } } } + val avatarImageUrl = remember(profile.avatarUrl, avatarItem) { + profileAvatarImageUrl(profile, avatarItem) + } val animAlpha = remember { Animatable(0f) } val animScale = remember { Animatable(0.85f) } @@ -342,8 +345,8 @@ private fun ProfileAvatarCard( modifier = Modifier.size(110.dp), contentAlignment = Alignment.Center, ) { - if (avatarItem != null) { - val bgColor = avatarItem.bgColor?.let { parseHexColor(it) } ?: avatarColor + if (avatarImageUrl != null) { + val bgColor = avatarItem?.bgColor?.let { parseHexColor(it) } ?: avatarColor Box( modifier = Modifier .size(110.dp) @@ -364,15 +367,15 @@ private fun ProfileAvatarCard( }, ) .then( - if (avatarItem == null) Modifier.border(2.dp, avatarColor.copy(alpha = 0.4f), CircleShape) + if (avatarImageUrl == null) Modifier.border(2.dp, avatarColor.copy(alpha = 0.4f), CircleShape) else Modifier, ), contentAlignment = Alignment.Center, ) { - if (avatarItem != null) { + if (avatarImageUrl != null) { AsyncImage( - model = avatarStorageUrl(avatarItem.storagePath), - contentDescription = avatarItem.displayName, + model = avatarImageUrl, + contentDescription = avatarItem?.displayName ?: profile.name, modifier = Modifier.size(100.dp).clip(CircleShape), contentScale = ContentScale.Crop, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt index d6a77b3f..cecd6273 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt @@ -341,6 +341,9 @@ private fun PopupProfileBubble( val avatarItem = remember(profile.avatarId, avatars) { profile.avatarId?.let { id -> avatars.find { it.id == id } } } + val avatarImageUrl = remember(profile.avatarUrl, avatarItem) { + profileAvatarImageUrl(profile, avatarItem) + } // Per-item entrance animation val itemAlpha = remember { Animatable(0f) } @@ -393,8 +396,8 @@ private fun PopupProfileBubble( .size(48.dp) .clip(CircleShape) .background( - if (avatarItem != null) { - avatarItem.bgColor?.let { parseHexColor(it) } ?: avatarColor + if (avatarImageUrl != null) { + avatarItem?.bgColor?.let { parseHexColor(it) } ?: avatarColor } else { avatarColor.copy(alpha = 0.15f) }, @@ -411,7 +414,7 @@ private fun PopupProfileBubble( avatarColor.copy(alpha = 0.6f), CircleShape, ) - avatarItem == null -> Modifier.border( + avatarImageUrl == null -> Modifier.border( 1.5.dp, avatarColor.copy(alpha = 0.3f), CircleShape, @@ -421,9 +424,9 @@ private fun PopupProfileBubble( ), contentAlignment = Alignment.Center, ) { - if (avatarItem != null) { + if (avatarImageUrl != null) { AsyncImage( - model = avatarStorageUrl(avatarItem.storagePath), + model = avatarImageUrl, contentDescription = profile.name, modifier = Modifier.size(48.dp).clip(CircleShape), contentScale = ContentScale.Crop, @@ -700,6 +703,9 @@ fun ActiveProfileMiniAvatar( val avatarItem = remember(profile.avatarId, avatars) { profile.avatarId?.let { id -> avatars.find { it.id == id } } } + val avatarImageUrl = remember(profile.avatarUrl, avatarItem) { + profileAvatarImageUrl(profile, avatarItem) + } val borderColor = if (selected) { MaterialTheme.colorScheme.primary @@ -712,8 +718,8 @@ fun ActiveProfileMiniAvatar( .size(size.dp) .clip(CircleShape) .background( - if (avatarItem != null) { - avatarItem.bgColor?.let { parseHexColor(it) } ?: avatarColor + if (avatarImageUrl != null) { + avatarItem?.bgColor?.let { parseHexColor(it) } ?: avatarColor } else { avatarColor.copy(alpha = 0.15f) }, @@ -721,9 +727,9 @@ fun ActiveProfileMiniAvatar( .border(1.5.dp, borderColor, CircleShape), contentAlignment = Alignment.Center, ) { - if (avatarItem != null) { + if (avatarImageUrl != null) { AsyncImage( - model = avatarStorageUrl(avatarItem.storagePath), + model = avatarImageUrl, contentDescription = profile.name, modifier = Modifier.size(size.dp).clip(CircleShape), contentScale = ContentScale.Crop,