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,