mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-07-26 22:42:17 +00:00
feat: implement profile switcher tab with animated popup and inline PIN entry for profile selection
This commit is contained in:
parent
b11988ecbb
commit
0b01e7fb30
3 changed files with 722 additions and 7 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue