mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-17 07:21:58 +00:00
feat: add support for custom avatar url
This commit is contained in:
parent
81babba3ed
commit
2fa918fe44
7 changed files with 134 additions and 35 deletions
|
|
@ -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 "%1$s" will be permanently deleted.</string>
|
||||
<string name="profile_delete_title">Delete Profile</string>
|
||||
<string name="profile_edit_add_title">Add Profile</string>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue