feat: Enhance avatar fetching and improve animations in ProfileSelectionScreen

This commit is contained in:
tapframe 2026-03-28 20:30:39 +05:30
parent dea3291fec
commit c5cf82d54b
3 changed files with 251 additions and 78 deletions

View file

@ -1,6 +1,11 @@
package com.nuvio.app package com.nuvio.app
import androidx.compose.animation.Crossfade import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
@ -42,6 +47,7 @@ import androidx.navigation.compose.rememberNavController
import androidx.navigation.toRoute import androidx.navigation.toRoute
import coil3.ImageLoader import coil3.ImageLoader
import coil3.compose.setSingletonImageLoaderFactory import coil3.compose.setSingletonImageLoaderFactory
import coil3.request.CachePolicy
import coil3.request.crossfade import coil3.request.crossfade
import com.nuvio.app.core.auth.AuthRepository import com.nuvio.app.core.auth.AuthRepository
import com.nuvio.app.core.auth.AuthState import com.nuvio.app.core.auth.AuthState
@ -133,6 +139,8 @@ fun App() {
setSingletonImageLoaderFactory { context -> setSingletonImageLoaderFactory { context ->
ImageLoader.Builder(context) ImageLoader.Builder(context)
.crossfade(true) .crossfade(true)
.diskCachePolicy(CachePolicy.ENABLED)
.memoryCachePolicy(CachePolicy.ENABLED)
.build() .build()
} }
NuvioTheme { NuvioTheme {
@ -157,9 +165,13 @@ fun App() {
} }
} }
Crossfade( AnimatedContent(
targetState = gateScreen, targetState = gateScreen,
label = "app_gate", label = "app_gate",
transitionSpec = {
(fadeIn(tween(400)) + scaleIn(tween(400), initialScale = 0.94f))
.togetherWith(fadeOut(tween(250)))
},
) { currentGate -> ) { currentGate ->
when (currentGate) { when (currentGate) {
AppGateScreen.Loading.name -> { AppGateScreen.Loading.name -> {

View file

@ -18,6 +18,14 @@ object AvatarRepository {
suspend fun fetchAvatars() { suspend fun fetchAvatars() {
if (loaded && _avatars.value.isNotEmpty()) return if (loaded && _avatars.value.isNotEmpty()) return
doFetch()
}
suspend fun refreshAvatars() {
doFetch()
}
private suspend fun doFetch() {
runCatching { runCatching {
val result = SupabaseProvider.client.postgrest.rpc("get_avatar_catalog") val result = SupabaseProvider.client.postgrest.rpc("get_avatar_catalog")
val items = result.decodeList<AvatarCatalogItem>() val items = result.decodeList<AvatarCatalogItem>()

View file

@ -1,21 +1,27 @@
package com.nuvio.app.features.profiles package com.nuvio.app.features.profiles
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@ -26,7 +32,6 @@ import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material.icons.rounded.Lock import androidx.compose.material.icons.rounded.Lock
import androidx.compose.material.icons.rounded.Person import androidx.compose.material.icons.rounded.Person
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -39,17 +44,20 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
fun ProfileSelectionScreen( fun ProfileSelectionScreen(
onProfileSelected: (NuvioProfile) -> Unit, onProfileSelected: (NuvioProfile) -> Unit,
@ -62,9 +70,21 @@ fun ProfileSelectionScreen(
var pinDialogProfile by remember { mutableStateOf<NuvioProfile?>(null) } var pinDialogProfile by remember { mutableStateOf<NuvioProfile?>(null) }
var isEditMode by remember { mutableStateOf(false) } var isEditMode by remember { mutableStateOf(false) }
val titleAlpha = remember { Animatable(0f) }
val titleOffset = remember { Animatable(20f) }
val manageAlpha = remember { Animatable(0f) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
ProfileRepository.pullProfiles() ProfileRepository.pullProfiles()
AvatarRepository.fetchAvatars() AvatarRepository.fetchAvatars()
AvatarRepository.refreshAvatars()
}
LaunchedEffect(Unit) {
launch { titleAlpha.animateTo(1f, tween(600, easing = FastOutSlowInEasing)) }
launch { titleOffset.animateTo(0f, tween(600, easing = FastOutSlowInEasing)) }
delay(300)
manageAlpha.animateTo(1f, tween(500))
} }
val statusBarTop = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() val statusBarTop = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
@ -72,63 +92,120 @@ fun ProfileSelectionScreen(
Box( Box(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.background) .background(
Brush.verticalGradient(
colors = listOf(
MaterialTheme.colorScheme.background,
MaterialTheme.colorScheme.background,
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.15f),
),
),
)
.padding(top = statusBarTop), .padding(top = statusBarTop),
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.padding(horizontal = 32.dp, vertical = 48.dp), .padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Spacer(modifier = Modifier.height(60.dp))
Text( Text(
text = "Who's watching?", text = "Who's watching?",
style = MaterialTheme.typography.headlineLarge, style = MaterialTheme.typography.headlineLarge.copy(
fontSize = 30.sp,
letterSpacing = (-0.5).sp,
),
color = MaterialTheme.colorScheme.onBackground, color = MaterialTheme.colorScheme.onBackground,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
modifier = Modifier.graphicsLayer {
alpha = titleAlpha.value
translationY = titleOffset.value
},
) )
Spacer(modifier = Modifier.height(40.dp)) Spacer(modifier = Modifier.height(48.dp))
FlowRow( val profiles = profileState.profiles
horizontalArrangement = Arrangement.spacedBy(24.dp, Alignment.CenterHorizontally), val items = profiles.size + if (profiles.size < 4) 1 else 0
verticalArrangement = Arrangement.spacedBy(24.dp),
Column(
verticalArrangement = Arrangement.spacedBy(20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth(),
) { ) {
profileState.profiles.forEach { profile -> var index = 0
ProfileAvatar( while (index < items) {
profile = profile, Row(
isEditMode = isEditMode, horizontalArrangement = Arrangement.spacedBy(20.dp, Alignment.CenterHorizontally),
onClick = { modifier = Modifier.fillMaxWidth(),
if (isEditMode) { ) {
onEditProfile(profile) for (col in 0..1) {
} else if (profile.pinEnabled) { if (index < items) {
pinDialogProfile = profile val currentIndex = index
if (currentIndex < profiles.size) {
val profile = profiles[currentIndex]
ProfileAvatarCard(
profile = profile,
isEditMode = isEditMode,
animDelay = currentIndex * 80,
onClick = {
if (isEditMode) {
onEditProfile(profile)
} else if (profile.pinEnabled) {
pinDialogProfile = profile
} else {
ProfileRepository.selectProfile(profile.profileIndex)
onProfileSelected(profile)
}
},
)
} else {
AddProfileCard(
animDelay = currentIndex * 80,
onClick = onAddProfile,
)
}
index++
} else { } else {
ProfileRepository.selectProfile(profile.profileIndex) Spacer(modifier = Modifier.width(150.dp))
onProfileSelected(profile)
} }
}, }
) }
}
if (profileState.profiles.size < 4) {
AddProfileButton(onClick = onAddProfile)
} }
} }
Spacer(modifier = Modifier.height(40.dp)) Spacer(modifier = Modifier.height(48.dp))
Text( Box(
text = if (isEditMode) "Done" else "Manage Profiles",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Medium,
modifier = Modifier modifier = Modifier
.clip(RoundedCornerShape(8.dp)) .graphicsLayer { alpha = manageAlpha.value }
.clip(RoundedCornerShape(24.dp))
.background(
if (isEditMode) MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
else Color.Transparent,
)
.border(
width = 1.dp,
color = if (isEditMode) MaterialTheme.colorScheme.primary.copy(alpha = 0.4f)
else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f),
shape = RoundedCornerShape(24.dp),
)
.clickable { isEditMode = !isEditMode } .clickable { isEditMode = !isEditMode }
.padding(horizontal = 16.dp, vertical = 8.dp), .padding(horizontal = 24.dp, vertical = 10.dp),
) ) {
Text(
text = if (isEditMode) "Done" else "Manage Profiles",
style = MaterialTheme.typography.bodyLarge,
color = if (isEditMode) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.SemiBold,
)
}
Spacer(modifier = Modifier.height(32.dp))
} }
} }
@ -150,9 +227,10 @@ fun ProfileSelectionScreen(
} }
@Composable @Composable
private fun ProfileAvatar( private fun ProfileAvatarCard(
profile: NuvioProfile, profile: NuvioProfile,
isEditMode: Boolean, isEditMode: Boolean,
animDelay: Int,
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
val avatarColor = remember(profile.avatarColorHex) { val avatarColor = remember(profile.avatarColorHex) {
@ -163,32 +241,67 @@ private fun ProfileAvatar(
profile.avatarId?.let { id -> avatars.find { it.id == id } } profile.avatarId?.let { id -> avatars.find { it.id == id } }
} }
val animAlpha = remember { Animatable(0f) }
val animScale = remember { Animatable(0.85f) }
val animOffset = remember { Animatable(30f) }
LaunchedEffect(Unit) {
delay(animDelay.toLong() + 150)
launch { animAlpha.animateTo(1f, tween(450, easing = FastOutSlowInEasing)) }
launch { animScale.animateTo(1f, tween(500, easing = FastOutSlowInEasing)) }
launch { animOffset.animateTo(0f, tween(500, easing = FastOutSlowInEasing)) }
}
val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()
val pressScale = if (isPressed) 0.95f else 1f
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier modifier = Modifier
.clip(RoundedCornerShape(16.dp)) .width(150.dp)
.clickable(onClick = onClick) .graphicsLayer {
alpha = animAlpha.value
scaleX = animScale.value * pressScale
scaleY = animScale.value * pressScale
translationY = animOffset.value
}
.clip(RoundedCornerShape(20.dp))
.clickable(
interactionSource = interactionSource,
indication = null,
onClick = onClick,
)
.padding(8.dp), .padding(8.dp),
) { ) {
Box( Box(
modifier = Modifier.size(96.dp), modifier = Modifier.size(110.dp),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
if (avatarItem != null) {
val bgColor = avatarItem.bgColor?.let { parseHexColor(it) } ?: avatarColor
Box(
modifier = Modifier
.size(110.dp)
.clip(CircleShape)
.background(bgColor.copy(alpha = 0.2f)),
)
}
Box( Box(
modifier = Modifier modifier = Modifier
.size(96.dp) .size(100.dp)
.clip(CircleShape) .clip(CircleShape)
.background( .background(
if (avatarItem != null) { if (avatarItem != null) {
avatarItem.bgColor?.let { parseHexColor(it) } ?: avatarColor avatarItem.bgColor?.let { parseHexColor(it) } ?: avatarColor
} else { } else {
avatarColor.copy(alpha = 0.2f) avatarColor.copy(alpha = 0.15f)
} },
) )
.border( .then(
2.dp, if (avatarItem == null) Modifier.border(2.dp, avatarColor.copy(alpha = 0.4f), CircleShape)
if (avatarItem != null) Color.Transparent else avatarColor.copy(alpha = 0.5f), else Modifier,
CircleShape,
), ),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
@ -196,13 +309,13 @@ private fun ProfileAvatar(
AsyncImage( AsyncImage(
model = avatarStorageUrl(avatarItem.storagePath), model = avatarStorageUrl(avatarItem.storagePath),
contentDescription = avatarItem.displayName, contentDescription = avatarItem.displayName,
modifier = Modifier.size(96.dp).clip(CircleShape), modifier = Modifier.size(100.dp).clip(CircleShape),
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
) )
} else if (profile.name.isNotBlank()) { } else if (profile.name.isNotBlank()) {
Text( Text(
text = profile.name.take(1).uppercase(), text = profile.name.take(1).uppercase(),
style = MaterialTheme.typography.headlineLarge, style = MaterialTheme.typography.headlineLarge.copy(fontSize = 38.sp),
color = avatarColor, color = avatarColor,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
) )
@ -211,7 +324,7 @@ private fun ProfileAvatar(
imageVector = Icons.Rounded.Person, imageVector = Icons.Rounded.Person,
contentDescription = null, contentDescription = null,
tint = avatarColor, tint = avatarColor,
modifier = Modifier.size(42.dp), modifier = Modifier.size(46.dp),
) )
} }
} }
@ -220,9 +333,10 @@ private fun ProfileAvatar(
Box( Box(
modifier = Modifier modifier = Modifier
.align(Alignment.BottomEnd) .align(Alignment.BottomEnd)
.size(32.dp) .size(34.dp)
.clip(CircleShape) .clip(CircleShape)
.background(MaterialTheme.colorScheme.primary), .background(MaterialTheme.colorScheme.primary)
.border(2.dp, MaterialTheme.colorScheme.background, CircleShape),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Icon( Icon(
@ -238,9 +352,10 @@ private fun ProfileAvatar(
Box( Box(
modifier = Modifier modifier = Modifier
.align(Alignment.BottomEnd) .align(Alignment.BottomEnd)
.size(28.dp) .size(30.dp)
.clip(CircleShape) .clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant), .background(MaterialTheme.colorScheme.surfaceVariant)
.border(2.dp, MaterialTheme.colorScheme.background, CircleShape),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Icon( Icon(
@ -253,13 +368,13 @@ private fun ProfileAvatar(
} }
} }
Spacer(modifier = Modifier.height(10.dp)) Spacer(modifier = Modifier.height(12.dp))
Text( Text(
text = profile.name.ifBlank { "Profile ${profile.profileIndex}" }, text = profile.name.ifBlank { "Profile ${profile.profileIndex}" },
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge.copy(fontSize = 16.sp),
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
@ -268,37 +383,75 @@ private fun ProfileAvatar(
} }
@Composable @Composable
private fun AddProfileButton(onClick: () -> Unit) { private fun AddProfileCard(
animDelay: Int,
onClick: () -> Unit,
) {
val animAlpha = remember { Animatable(0f) }
val animScale = remember { Animatable(0.85f) }
val animOffset = remember { Animatable(30f) }
LaunchedEffect(Unit) {
delay(animDelay.toLong() + 150)
launch { animAlpha.animateTo(1f, tween(450, easing = FastOutSlowInEasing)) }
launch { animScale.animateTo(1f, tween(500, easing = FastOutSlowInEasing)) }
launch { animOffset.animateTo(0f, tween(500, easing = FastOutSlowInEasing)) }
}
val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()
val pressScale = if (isPressed) 0.95f else 1f
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier modifier = Modifier
.clip(RoundedCornerShape(16.dp)) .width(150.dp)
.clickable(onClick = onClick) .graphicsLayer {
alpha = animAlpha.value
scaleX = animScale.value * pressScale
scaleY = animScale.value * pressScale
translationY = animOffset.value
}
.clip(RoundedCornerShape(20.dp))
.clickable(
interactionSource = interactionSource,
indication = null,
onClick = onClick,
)
.padding(8.dp), .padding(8.dp),
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier.size(110.dp),
.size(96.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant)
.border(2.dp, MaterialTheme.colorScheme.outline, CircleShape),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Icon( Box(
imageVector = Icons.Rounded.Add, modifier = Modifier
contentDescription = null, .size(100.dp)
tint = MaterialTheme.colorScheme.onSurfaceVariant, .clip(CircleShape)
modifier = Modifier.size(36.dp), .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
) .border(
2.dp,
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.2f),
CircleShape,
),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Rounded.Add,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
modifier = Modifier.size(40.dp),
)
}
} }
Spacer(modifier = Modifier.height(10.dp)) Spacer(modifier = Modifier.height(12.dp))
Text( Text(
text = "Add Profile", text = "Add Profile",
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge.copy(fontSize = 16.sp),
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
) )
} }