feat: implement profile switcher tab with animated popup and inline PIN entry for profile selection

This commit is contained in:
tapframe 2026-03-30 14:42:22 +05:30
parent b11988ecbb
commit 0b01e7fb30
3 changed files with 722 additions and 7 deletions

View file

@ -21,7 +21,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Home
import androidx.compose.material.icons.rounded.Search
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material.icons.rounded.VideoLibrary
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.LaunchedEffect
@ -83,6 +82,7 @@ import com.nuvio.app.features.profiles.NuvioProfile
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.search.SearchScreen
import com.nuvio.app.features.settings.SettingsScreen
import com.nuvio.app.features.settings.HomescreenSettingsScreen
@ -291,6 +291,7 @@ private fun MainAppContent(
WatchedRepository.uiState
}.collectAsStateWithLifecycle()
var initialHomeReady by rememberSaveable { mutableStateOf(false) }
var profileSwitchLoading by remember { mutableStateOf(false) }
val onPlay: (String, String, String, String, String, String?, String?, String?, Int?, Int?, String?, String?, String?, Long?) -> Unit =
{ type, videoId, parentMetaId, parentMetaType, title, logo, poster, background, seasonNumber, episodeNumber, episodeTitle, episodeThumbnail, pauseDescription, resumePositionMs ->
@ -416,8 +417,19 @@ private fun MainAppContent(
NavigationBarItem(
selected = selectedTab == AppScreenTab.Settings,
onClick = { selectedTab = AppScreenTab.Settings },
icon = { Icon(Icons.Rounded.Settings, contentDescription = null) },
label = { Text("Settings") },
icon = {
ProfileSwitcherTab(
selected = selectedTab == AppScreenTab.Settings,
onClick = { selectedTab = AppScreenTab.Settings },
onProfileSelected = { profile ->
profileSwitchLoading = true
selectedTab = AppScreenTab.Home
ProfileRepository.selectProfile(profile.profileIndex)
com.nuvio.app.core.sync.SyncManager.pullAllForProfile(profile.profileIndex)
},
)
},
label = { Text("Profile") },
colors = navigationItemColors,
)
}
@ -683,9 +695,22 @@ private fun MainAppContent(
},
)
if (!initialHomeReady) {
androidx.compose.animation.AnimatedVisibility(
visible = !initialHomeReady || profileSwitchLoading,
enter = fadeIn(),
exit = fadeOut(androidx.compose.animation.core.tween(400)),
) {
AppLaunchOverlay(modifier = Modifier.fillMaxSize())
}
// Auto-dismiss profile switch overlay
if (profileSwitchLoading) {
LaunchedEffect(Unit) {
// Brief loading screen while home refreshes for the new profile
kotlinx.coroutines.delay(1200)
profileSwitchLoading = false
}
}
}
}

View file

@ -1,5 +1,13 @@
package com.nuvio.app.features.profiles
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@ -24,6 +32,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@ -32,6 +41,9 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@ -49,6 +61,7 @@ fun PinEntryDialog(
var error by remember { mutableStateOf<String?>(null) }
var isVerifying by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val haptic = LocalHapticFeedback.current
BasicAlertDialog(onDismissRequest = onDismiss) {
Surface(
@ -83,13 +96,17 @@ fun PinEntryDialog(
}
}
error?.let { errorText ->
Spacer(modifier = Modifier.height(12.dp))
AnimatedVisibility(
visible = error != null,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut(),
) {
Text(
text = errorText,
text = error.orEmpty(),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 12.dp),
)
}
@ -100,11 +117,13 @@ fun PinEntryDialog(
if (pin.length < 4 && !isVerifying) {
error = null
pin += digit
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
if (pin.length == 4) {
isVerifying = true
scope.launch {
val result = onVerify(pin)
if (!result.unlocked) {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
error = if (result.retryAfterSeconds > 0) {
"Locked. Try again in ${result.retryAfterSeconds}s"
} else {
@ -121,6 +140,7 @@ fun PinEntryDialog(
if (pin.isNotEmpty() && !isVerifying) {
pin = pin.dropLast(1)
error = null
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
}
},
)
@ -150,8 +170,25 @@ private fun PinDot(filled: Boolean, hasError: Boolean) {
filled -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.outline
}
val dotScale = remember { Animatable(1f) }
LaunchedEffect(filled) {
if (filled) {
dotScale.snapTo(1.5f)
dotScale.animateTo(
1f,
spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessHigh,
),
)
}
}
Box(
modifier = Modifier
.graphicsLayer {
scaleX = dotScale.value
scaleY = dotScale.value
}
.size(16.dp)
.clip(CircleShape)
.then(

View file

@ -0,0 +1,653 @@
package com.nuvio.app.features.profiles
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.Backspace
import androidx.compose.material.icons.rounded.Lock
import androidx.compose.material.icons.rounded.Person
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Composable
fun ProfileSwitcherTab(
selected: Boolean,
onClick: () -> Unit,
onProfileSelected: (NuvioProfile) -> Unit,
modifier: Modifier = Modifier,
) {
val profileState by ProfileRepository.state.collectAsStateWithLifecycle()
val activeProfile = profileState.activeProfile
val profiles = profileState.profiles
val avatars by AvatarRepository.avatars.collectAsStateWithLifecycle()
val haptic = LocalHapticFeedback.current
val scope = rememberCoroutineScope()
val density = LocalDensity.current
var showPopup by remember { mutableStateOf(false) }
// Keep popup composed while exit animation plays
var popupVisible by remember { mutableStateOf(false) }
var pinProfile by remember { mutableStateOf<NuvioProfile?>(null) }
// Popup entrance/exit animation
val popupAlpha = remember { Animatable(0f) }
val popupScale = remember { Animatable(0.5f) }
val popupTranslateY = remember { Animatable(40f) }
LaunchedEffect(showPopup) {
if (showPopup) {
popupVisible = true
launch { popupAlpha.animateTo(1f, tween(220, easing = FastOutSlowInEasing)) }
launch {
popupScale.animateTo(
1f,
spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMedium,
),
)
}
launch {
popupTranslateY.animateTo(
0f,
spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMedium,
),
)
}
} else {
// Animate out
launch { popupAlpha.animateTo(0f, tween(180, easing = FastOutSlowInEasing)) }
launch { popupScale.animateTo(0.85f, tween(200, easing = FastOutSlowInEasing)) }
launch {
popupTranslateY.animateTo(30f, tween(200, easing = FastOutSlowInEasing))
// Remove from composition after animation completes
popupVisible = false
pinProfile = null
}
}
}
Box(
modifier = modifier
.pointerInput(profiles) {
detectTapGestures(
onTap = { onClick() },
onLongPress = {
if (profiles.size > 1) {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
showPopup = true
}
},
)
},
contentAlignment = Alignment.Center,
) {
// The avatar icon for the bottom navigation
ActiveProfileMiniAvatar(
profile = activeProfile,
avatars = avatars,
selected = selected,
size = 28,
)
// Floating profile popup (stays composed during exit animation)
if (popupVisible && profiles.size > 1) {
Popup(
alignment = Alignment.BottomCenter,
offset = IntOffset(0, with(density) { -64.dp.roundToPx() }),
properties = PopupProperties(focusable = true),
onDismissRequest = { showPopup = false },
) {
Box(
modifier = Modifier
.imePadding()
.graphicsLayer {
alpha = popupAlpha.value
scaleX = popupScale.value
scaleY = popupScale.value
translationY = popupTranslateY.value
}
.shadow(16.dp, RoundedCornerShape(28.dp))
.background(
MaterialTheme.colorScheme.surfaceContainerHigh,
RoundedCornerShape(28.dp),
)
.padding(16.dp),
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
// Profile avatars row
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.Top,
) {
profiles.forEachIndexed { index, profile ->
val isActive =
profile.profileIndex == activeProfile?.profileIndex
val isPinTarget =
pinProfile?.profileIndex == profile.profileIndex
PopupProfileBubble(
profile = profile,
avatars = avatars,
isActive = isActive,
isSelected = isPinTarget,
delayMs = index * 50,
onClick = {
if (profile.pinEnabled) {
pinProfile = profile
} else {
onProfileSelected(profile)
showPopup = false
}
},
)
}
}
// Inline PIN entry for locked profiles
AnimatedVisibility(
visible = pinProfile != null,
enter = expandVertically(
animationSpec = spring(
dampingRatio = Spring.DampingRatioLowBouncy,
stiffness = Spring.StiffnessMediumLow,
),
) + fadeIn(tween(200)),
exit = shrinkVertically(tween(150)) + fadeOut(tween(100)),
) {
pinProfile?.let { profile ->
InlinePinEntry(
profileName = profile.name,
onVerified = {
onProfileSelected(profile)
showPopup = false
},
onCancel = { pinProfile = null },
verifyPin = { pin ->
ProfileRepository.verifyPin(profile.profileIndex, pin)
},
)
}
}
}
}
}
}
}
}
@Composable
private fun PopupProfileBubble(
profile: NuvioProfile,
avatars: List<AvatarCatalogItem>,
isActive: Boolean,
isSelected: Boolean,
delayMs: Int,
onClick: () -> Unit,
) {
val avatarColor = remember(profile.avatarColorHex) { parseHexColor(profile.avatarColorHex) }
val avatarItem = remember(profile.avatarId, avatars) {
profile.avatarId?.let { id -> avatars.find { it.id == id } }
}
// Per-item entrance animation
val itemAlpha = remember { Animatable(0f) }
val itemScale = remember { Animatable(0.4f) }
LaunchedEffect(Unit) {
delay(delayMs.toLong())
launch { itemAlpha.animateTo(1f, tween(200, easing = FastOutSlowInEasing)) }
launch {
itemScale.animateTo(
1f,
spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMedium,
),
)
}
}
val pressScale by animateFloatAsState(
targetValue = if (isSelected) 1.15f else 1f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow,
),
label = "pressScale",
)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.graphicsLayer {
alpha = itemAlpha.value
scaleX = itemScale.value * pressScale
scaleY = itemScale.value * pressScale
}
.clip(RoundedCornerShape(16.dp))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onClick,
)
.padding(4.dp),
) {
Box(
modifier = Modifier.size(52.dp),
contentAlignment = Alignment.Center,
) {
Box(
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(
if (avatarItem != null) {
avatarItem.bgColor?.let { parseHexColor(it) } ?: avatarColor
} else {
avatarColor.copy(alpha = 0.15f)
},
)
.then(
when {
isSelected -> Modifier.border(
2.5.dp,
MaterialTheme.colorScheme.primary,
CircleShape,
)
isActive -> Modifier.border(
2.dp,
avatarColor.copy(alpha = 0.6f),
CircleShape,
)
avatarItem == null -> Modifier.border(
1.5.dp,
avatarColor.copy(alpha = 0.3f),
CircleShape,
)
else -> Modifier
},
),
contentAlignment = Alignment.Center,
) {
if (avatarItem != null) {
AsyncImage(
model = avatarStorageUrl(avatarItem.storagePath),
contentDescription = profile.name,
modifier = Modifier.size(48.dp).clip(CircleShape),
contentScale = ContentScale.Crop,
)
} else if (profile.name.isNotBlank()) {
Text(
text = profile.name.take(1).uppercase(),
style = MaterialTheme.typography.titleMedium.copy(fontSize = 18.sp),
color = avatarColor,
fontWeight = FontWeight.Bold,
)
} else {
Icon(
imageVector = Icons.Rounded.Person,
contentDescription = null,
tint = avatarColor,
modifier = Modifier.size(24.dp),
)
}
}
// Lock badge for PIN-protected profiles
if (profile.pinEnabled) {
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
.size(18.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant)
.border(1.5.dp, MaterialTheme.colorScheme.surfaceContainerHigh, CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Rounded.Lock,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(10.dp),
)
}
}
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = profile.name.ifBlank { "Profile ${profile.profileIndex}" },
style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp),
color = if (isSelected) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = if (isActive || isSelected) FontWeight.Bold else FontWeight.Medium,
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.width(56.dp),
)
}
}
/**
* Compact inline PIN entry shown inside the popup when a PIN-protected
* profile is tapped.
*/
@Composable
private fun InlinePinEntry(
profileName: String,
onVerified: () -> Unit,
onCancel: () -> Unit,
verifyPin: suspend (String) -> PinVerifyResult,
) {
var pin by remember { mutableStateOf("") }
var error by remember { mutableStateOf<String?>(null) }
var isVerifying by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val haptic = LocalHapticFeedback.current
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(top = 16.dp),
) {
Text(
text = "Enter PIN for $profileName",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(14.dp))
// PIN dots with bounce animation
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
repeat(4) { index ->
val filled = index < pin.length
val dotScale = remember { Animatable(1f) }
LaunchedEffect(filled) {
if (filled) {
dotScale.snapTo(1.4f)
dotScale.animateTo(
1f,
spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessHigh,
),
)
}
}
val dotColor = when {
error != null -> MaterialTheme.colorScheme.error
filled -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.outline
}
Box(
modifier = Modifier
.graphicsLayer {
scaleX = dotScale.value
scaleY = dotScale.value
}
.size(14.dp)
.clip(CircleShape)
.then(
if (filled) Modifier.background(dotColor)
else Modifier.border(2.dp, dotColor, CircleShape),
),
)
}
}
// Error text
AnimatedVisibility(
visible = error != null,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut(),
) {
Text(
text = error.orEmpty(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 8.dp),
)
}
Spacer(modifier = Modifier.height(14.dp))
// Compact number pad
CompactPinKeypad(
onDigit = { digit ->
if (pin.length < 4 && !isVerifying) {
error = null
pin += digit
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
if (pin.length == 4) {
isVerifying = true
scope.launch {
val result = verifyPin(pin)
if (result.unlocked) {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onVerified()
} else {
error = if (result.retryAfterSeconds > 0) {
"Locked. Try again in ${result.retryAfterSeconds}s"
} else {
"Wrong PIN"
}
pin = ""
}
isVerifying = false
}
}
}
},
onBackspace = {
if (pin.isNotEmpty() && !isVerifying) {
pin = pin.dropLast(1)
error = null
}
},
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Cancel",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.SemiBold,
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.clickable(onClick = onCancel)
.padding(horizontal = 16.dp, vertical = 6.dp),
)
}
}
@Composable
private fun CompactPinKeypad(
onDigit: (String) -> Unit,
onBackspace: () -> Unit,
) {
val rows = listOf(
listOf("1", "2", "3"),
listOf("4", "5", "6"),
listOf("7", "8", "9"),
listOf("", "0", ""),
)
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
rows.forEach { row ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
) {
row.forEach { key ->
when (key) {
"" -> Spacer(modifier = Modifier.size(48.dp))
"" -> {
Box(
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant)
.clickable(onClick = onBackspace),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.Backspace,
contentDescription = "Backspace",
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(20.dp),
)
}
}
else -> {
Box(
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant)
.clickable { onDigit(key) },
contentAlignment = Alignment.Center,
) {
Text(
text = key,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.Medium,
)
}
}
}
}
}
}
}
}
@Composable
fun ActiveProfileMiniAvatar(
profile: NuvioProfile?,
avatars: List<AvatarCatalogItem>,
selected: Boolean,
size: Int = 24,
) {
if (profile == null) {
Icon(
imageVector = Icons.Rounded.Person,
contentDescription = "Profile",
modifier = Modifier.size(size.dp),
)
return
}
val avatarColor = remember(profile.avatarColorHex) { parseHexColor(profile.avatarColorHex) }
val avatarItem = remember(profile.avatarId, avatars) {
profile.avatarId?.let { id -> avatars.find { it.id == id } }
}
val borderColor = if (selected) {
MaterialTheme.colorScheme.primary
} else {
avatarColor.copy(alpha = 0.5f)
}
Box(
modifier = Modifier
.size(size.dp)
.clip(CircleShape)
.background(
if (avatarItem != null) {
avatarItem.bgColor?.let { parseHexColor(it) } ?: avatarColor
} else {
avatarColor.copy(alpha = 0.15f)
},
)
.border(1.5.dp, borderColor, CircleShape),
contentAlignment = Alignment.Center,
) {
if (avatarItem != null) {
AsyncImage(
model = avatarStorageUrl(avatarItem.storagePath),
contentDescription = profile.name,
modifier = Modifier.size(size.dp).clip(CircleShape),
contentScale = ContentScale.Crop,
)
} else if (profile.name.isNotBlank()) {
Text(
text = profile.name.take(1).uppercase(),
fontSize = (size * 0.45f).sp,
color = avatarColor,
fontWeight = FontWeight.Bold,
)
} else {
Icon(
imageVector = Icons.Rounded.Person,
contentDescription = null,
tint = avatarColor,
modifier = Modifier.size((size * 0.6f).dp),
)
}
}
}