feat: add support for custom avatar url

This commit is contained in:
tapframe 2026-05-07 13:14:19 +05:30
parent 81babba3ed
commit 2fa918fe44
7 changed files with 134 additions and 35 deletions

View file

@ -1007,9 +1007,14 @@
<string name="pin_locked_try_again">Locked. Try again in %1$ds</string>
<string name="profile_avatar_options_pending">Avatar options will appear here when the catalog loads.</string>
<string name="profile_avatar_selected">Avatar: %1$s</string>
<string name="profile_avatar_url_invalid">Enter a valid http:// or https:// image URL.</string>
<string name="profile_choose_avatar">Choose an avatar</string>
<string name="profile_choose_avatar_below">Choose an avatar below.</string>
<string name="profile_create_profile">Create Profile</string>
<string name="profile_custom_avatar_selected">Custom avatar URL selected.</string>
<string name="profile_custom_avatar_url">Custom avatar URL</string>
<string name="profile_custom_avatar_url_description">Paste an image link, or leave this empty to use the built-in avatar catalog.</string>
<string name="profile_custom_avatar_url_placeholder">https://example.com/avatar.png</string>
<string name="profile_delete_confirm_message">All data for &quot;%1$s&quot; will be permanently deleted.</string>
<string name="profile_delete_title">Delete Profile</string>
<string name="profile_edit_add_title">Add Profile</string>

View file

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

View file

@ -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,

View file

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

View file

@ -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,
)

View file

@ -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,
)

View file

@ -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,