feat: sidebar for desktop

This commit is contained in:
tapframe 2026-06-09 23:10:01 +05:30
parent 212bab5d27
commit 84cc669bd5
10 changed files with 820 additions and 4 deletions

View file

@ -18,11 +18,13 @@ actual object ThemeSettingsStorage {
private const val selectedThemeKey = "selected_theme"
private const val amoledEnabledKey = "amoled_enabled"
private const val liquidGlassNativeTabBarEnabledKey = "liquid_glass_native_tab_bar_enabled"
private const val desktopNavigationLayoutKey = "desktop_navigation_layout"
private const val selectedAppLanguageKey = "selected_app_language"
private val profileScopedSyncKeys = listOf(
selectedThemeKey,
amoledEnabledKey,
liquidGlassNativeTabBarEnabledKey,
desktopNavigationLayoutKey,
)
private val globalSyncKeys = listOf(selectedAppLanguageKey)
@ -69,6 +71,16 @@ actual object ThemeSettingsStorage {
?.apply()
}
actual fun loadDesktopNavigationLayout(): String? =
preferences?.getString(ProfileScopedKey.of(desktopNavigationLayoutKey), null)
actual fun saveDesktopNavigationLayout(layoutName: String) {
preferences
?.edit()
?.putString(ProfileScopedKey.of(desktopNavigationLayoutKey), layoutName)
?.apply()
}
actual fun loadSelectedAppLanguage(): String? {
val value = preferences?.getString(selectedAppLanguageKey, null)
if (value != null) return value
@ -94,6 +106,7 @@ actual object ThemeSettingsStorage {
loadSelectedTheme()?.let { put(selectedThemeKey, encodeSyncString(it)) }
loadAmoledEnabled()?.let { put(amoledEnabledKey, encodeSyncBoolean(it)) }
loadLiquidGlassNativeTabBarEnabled()?.let { put(liquidGlassNativeTabBarEnabledKey, encodeSyncBoolean(it)) }
loadDesktopNavigationLayout()?.let { put(desktopNavigationLayoutKey, encodeSyncString(it)) }
loadSelectedAppLanguage()?.let { put(selectedAppLanguageKey, encodeSyncString(it)) }
}
@ -106,6 +119,7 @@ actual object ThemeSettingsStorage {
payload.decodeSyncString(selectedThemeKey)?.let(::saveSelectedTheme)
payload.decodeSyncBoolean(amoledEnabledKey)?.let(::saveAmoledEnabled)
payload.decodeSyncBoolean(liquidGlassNativeTabBarEnabledKey)?.let(::saveLiquidGlassNativeTabBarEnabled)
payload.decodeSyncString(desktopNavigationLayoutKey)?.let(::saveDesktopNavigationLayout)
payload.decodeSyncString(selectedAppLanguageKey)?.let(::saveSelectedAppLanguage)
applySelectedAppLanguage(loadSelectedAppLanguage() ?: AppLanguage.ENGLISH.code)
}

View file

@ -536,6 +536,10 @@
<string name="settings_appearance_app_language">App Language</string>
<string name="settings_appearance_app_language_sheet_title">Choose Language</string>
<string name="settings_appearance_continue_watching_description">Settings for the Continue Watching section.</string>
<string name="settings_appearance_desktop_navigation">Desktop Navigation</string>
<string name="settings_appearance_desktop_navigation_sheet_title">Choose Desktop Navigation</string>
<string name="settings_appearance_desktop_navigation_sidebar">Sidebar</string>
<string name="settings_appearance_desktop_navigation_top_bar">Top Bar</string>
<string name="settings_appearance_liquid_glass">Liquid Glass</string>
<string name="settings_appearance_liquid_glass_description">Use the native iPhone tab bar on iOS 26 and later. Instant profile switching from the tab bar is unavailable while this is on.</string>
<string name="settings_appearance_poster_customization_description">Tune card width and corner radius.</string>

View file

@ -3,6 +3,7 @@ package com.nuvio.app
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
@ -11,6 +12,9 @@ import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.Image
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
@ -19,15 +23,18 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
@ -55,6 +62,7 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.max
@ -155,12 +163,15 @@ import com.nuvio.app.features.player.prepareExternalPlayerLaunch
import com.nuvio.app.features.player.SubtitleLanguageOption
import com.nuvio.app.features.player.sanitizePlaybackHeaders
import com.nuvio.app.features.player.sanitizePlaybackResponseHeaders
import com.nuvio.app.features.profiles.ActiveProfileMiniAvatar
import com.nuvio.app.features.profiles.AvatarCatalogItem
import com.nuvio.app.features.profiles.AvatarRepository
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.profiles.SidebarProfileSwitcherStack
import com.nuvio.app.features.profiles.profileAvatarImageUrl
import com.nuvio.app.features.search.SearchScreen
import com.nuvio.app.features.settings.SettingsScreen
@ -170,6 +181,7 @@ import com.nuvio.app.features.settings.ContinueWatchingSettingsScreen
import com.nuvio.app.features.settings.AddonsSettingsScreen
import com.nuvio.app.features.settings.PluginsSettingsScreen
import com.nuvio.app.features.settings.AccountSettingsScreen
import com.nuvio.app.features.settings.DesktopNavigationLayout
import com.nuvio.app.features.settings.SupportersContributorsSettingsScreen
import com.nuvio.app.features.settings.LicensesAttributionsSettingsScreen
import com.nuvio.app.features.settings.ThemeSettingsRepository
@ -329,6 +341,11 @@ enum class AppScreenTab {
Settings,
}
private val DesktopSidebarCollapsedWidth = 76.dp
private val DesktopSidebarExpandedWidth = 184.dp
private val DesktopSidebarExpandedContentWidth = 144.dp
private val DesktopSidebarIconSlotSize = 36.dp
private fun AppScreenTab.toNativeNavigationTab(): NativeNavigationTab = when (this) {
AppScreenTab.Home -> NativeNavigationTab.Home
AppScreenTab.Search -> NativeNavigationTab.Search
@ -684,6 +701,9 @@ private fun MainAppContent(
val liquidGlassNativeTabBarEnabled by remember {
ThemeSettingsRepository.liquidGlassNativeTabBarEnabled
}.collectAsStateWithLifecycle()
val desktopNavigationLayout by remember {
ThemeSettingsRepository.desktopNavigationLayout
}.collectAsStateWithLifecycle()
val liquidGlassNativeTabBarSupported = remember { isLiquidGlassNativeTabBarSupported() }
var showExitConfirmation by rememberSaveable { mutableStateOf(false) }
var selectedPosterActionTarget by remember { mutableStateOf<PosterActionTarget?>(null) }
@ -1350,7 +1370,12 @@ private fun MainAppContent(
val isTabletLayout = maxWidth >= 768.dp
val useNativeBottomTabs =
liquidGlassNativeTabBarSupported && liquidGlassNativeTabBarEnabled && initialHomeReady
val topChromePadding = if (isTabletLayout && !useNativeBottomTabs) {
val useDesktopSidebar = isDesktop &&
isTabletLayout &&
!useNativeBottomTabs &&
desktopNavigationLayout == DesktopNavigationLayout.Sidebar
val useFloatingTopBar = isTabletLayout && !useNativeBottomTabs && !useDesktopSidebar
val topChromePadding = if (useFloatingTopBar) {
val statusBarPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
max(statusBarPadding + 24.dp, 48.dp) + 64.dp
} else {
@ -1421,7 +1446,8 @@ private fun MainAppContent(
AppTabHost(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
.padding(innerPadding)
.padding(start = if (useDesktopSidebar) DesktopSidebarCollapsedWidth else 0.dp),
selectedTab = selectedTab,
topChromePadding = topChromePadding,
searchFocusRequestCount = searchFocusRequestCount,
@ -1519,7 +1545,14 @@ private fun MainAppContent(
)
}
if (isTabletLayout && !useNativeBottomTabs) {
if (useDesktopSidebar) {
DesktopHoverSidebar(
selectedTab = selectedTab,
onTabSelected = ::handleRootTabClick,
onProfileSelected = onProfileSelected,
onAddProfileRequested = onSwitchProfile,
)
} else if (useFloatingTopBar) {
TabletFloatingTopBar(
selectedTab = selectedTab,
onTabSelected = ::handleRootTabClick,
@ -2939,6 +2972,260 @@ private fun AppTabHost(
}
}
@Composable
private fun DesktopHoverSidebar(
selectedTab: AppScreenTab,
onTabSelected: (AppScreenTab) -> Unit,
onProfileSelected: (NuvioProfile) -> Unit,
onAddProfileRequested: () -> Unit,
modifier: Modifier = Modifier,
) {
val tokens = MaterialTheme.nuvio
val statusBarPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
val profileState by ProfileRepository.state.collectAsStateWithLifecycle()
val avatars by AvatarRepository.avatars.collectAsStateWithLifecycle()
val activeProfile = profileState.activeProfile
val activeProfileName = activeProfile?.name ?: stringResource(Res.string.compose_nav_profile)
val hoverSource = remember { MutableInteractionSource() }
val hovered by hoverSource.collectIsHoveredAsState()
var profileStackVisible by remember { mutableStateOf(false) }
val sidebarExpanded = hovered || profileStackVisible
val profileTopPadding = statusBarPadding + 18.dp
fun selectTab(tab: AppScreenTab) {
profileStackVisible = false
onTabSelected(tab)
}
val sidebarWidth by animateDpAsState(
targetValue = if (sidebarExpanded) DesktopSidebarExpandedWidth else DesktopSidebarCollapsedWidth,
animationSpec = tween(durationMillis = 180),
label = "desktop_sidebar_width",
)
Surface(
modifier = modifier
.width(sidebarWidth)
.fillMaxHeight()
.hoverable(hoverSource)
.zIndex(NuvioTokens.Z.navigation),
color = tokens.colors.background,
contentColor = tokens.colors.textPrimary,
) {
Box(
modifier = Modifier.fillMaxSize(),
) {
Box(
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = profileTopPadding)
.fillMaxWidth()
.height(52.dp)
.padding(horizontal = 10.dp, vertical = 4.dp)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = { profileStackVisible = !profileStackVisible },
),
contentAlignment = Alignment.Center,
) {
DesktopSidebarProfileTrigger(
profile = activeProfile,
avatars = avatars,
label = activeProfileName,
expanded = sidebarExpanded,
)
}
if (profileStackVisible) {
SidebarProfileSwitcherStack(
onProfileSelected = onProfileSelected,
onAddProfileRequested = onAddProfileRequested,
onDismissRequest = { profileStackVisible = false },
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = profileTopPadding + 58.dp)
.width(DesktopSidebarExpandedContentWidth),
)
}
Column(
modifier = Modifier
.align(Alignment.Center)
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
DesktopSidebarItem(
label = stringResource(Res.string.compose_nav_home),
selected = selectedTab == AppScreenTab.Home,
expanded = sidebarExpanded,
onClick = { selectTab(AppScreenTab.Home) },
) { color ->
Icon(
imageVector = Icons.Filled.Home,
contentDescription = stringResource(Res.string.compose_nav_home),
modifier = Modifier.size(NuvioTokens.Space.s20),
tint = color,
)
}
DesktopSidebarItem(
label = stringResource(Res.string.compose_nav_search),
selected = selectedTab == AppScreenTab.Search,
expanded = sidebarExpanded,
onClick = { selectTab(AppScreenTab.Search) },
) { color ->
Icon(
painter = painterResource(Res.drawable.sidebar_search),
contentDescription = stringResource(Res.string.compose_nav_search),
modifier = Modifier.size(NuvioTokens.Space.s20),
tint = color,
)
}
DesktopSidebarItem(
label = stringResource(Res.string.compose_nav_library),
selected = selectedTab == AppScreenTab.Library,
expanded = sidebarExpanded,
onClick = { selectTab(AppScreenTab.Library) },
) { color ->
Icon(
painter = painterResource(Res.drawable.sidebar_library),
contentDescription = stringResource(Res.string.compose_nav_library),
modifier = Modifier.size(NuvioTokens.Space.s20),
tint = color,
)
}
DesktopSidebarItem(
label = stringResource(Res.string.compose_settings_page_root),
selected = selectedTab == AppScreenTab.Settings,
expanded = sidebarExpanded,
onClick = { selectTab(AppScreenTab.Settings) },
) { color ->
Icon(
imageVector = Icons.Rounded.Settings,
contentDescription = stringResource(Res.string.compose_settings_page_root),
modifier = Modifier.size(NuvioTokens.Space.s20),
tint = color,
)
}
}
}
}
}
@Composable
private fun DesktopSidebarProfileTrigger(
profile: NuvioProfile?,
avatars: List<AvatarCatalogItem>,
label: String,
expanded: Boolean,
) {
val tokens = MaterialTheme.nuvio
Surface(
modifier = Modifier.fillMaxSize(),
color = Color.Transparent,
shape = RoundedCornerShape(16.dp),
) {
Row(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 10.dp),
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically,
) {
Row(
modifier = Modifier.width(
if (expanded) DesktopSidebarExpandedContentWidth else DesktopSidebarIconSlotSize,
),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier.size(DesktopSidebarIconSlotSize),
contentAlignment = Alignment.Center,
) {
ActiveProfileMiniAvatar(
profile = profile,
avatars = avatars,
selected = false,
size = 28,
)
}
if (expanded) {
Spacer(modifier = Modifier.width(12.dp))
Text(
text = label,
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.labelLarge,
color = tokens.colors.textPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
}
@Composable
private fun DesktopSidebarItem(
label: String,
selected: Boolean,
expanded: Boolean,
onClick: () -> Unit,
icon: @Composable (Color) -> Unit,
) {
val tokens = MaterialTheme.nuvio
val contentColor = if (selected) tokens.colors.textPrimary else tokens.colors.textMuted
val iconColor = if (selected) tokens.colors.onAccent else contentColor
Surface(
modifier = Modifier
.fillMaxWidth()
.height(52.dp)
.padding(horizontal = 10.dp, vertical = 4.dp)
.clickable(onClick = onClick),
color = Color.Transparent,
shape = RoundedCornerShape(16.dp),
) {
Row(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 10.dp),
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically,
) {
Row(
modifier = Modifier.width(
if (expanded) DesktopSidebarExpandedContentWidth else DesktopSidebarIconSlotSize,
),
verticalAlignment = Alignment.CenterVertically,
) {
Surface(
modifier = Modifier.size(DesktopSidebarIconSlotSize),
color = if (selected) tokens.colors.accent else Color.Transparent,
shape = RoundedCornerShape(14.dp),
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
icon(iconColor)
}
}
if (expanded) {
Spacer(modifier = Modifier.width(12.dp))
Text(
text = label,
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.labelLarge,
color = contentColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
}
@Composable
private fun TabletFloatingTopBar(
selectedTab: AppScreenTab,

View file

@ -85,6 +85,7 @@ fun ProfileSwitcherTab(
onProfileSelected: (NuvioProfile) -> Unit,
onAddProfileRequested: () -> Unit,
triggerContent: (@Composable (selected: Boolean) -> Unit)? = null,
openPopupOnClick: Boolean = false,
modifier: Modifier = Modifier,
) {
val tokens = MaterialTheme.nuvio
@ -201,7 +202,13 @@ fun ProfileSwitcherTab(
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onClick,
onClick = {
if (openPopupOnClick && profiles.isNotEmpty()) {
showPopup = true
} else {
onClick()
}
},
)
.pointerInput(profiles) {
detectDragGesturesAfterLongPress(
@ -336,6 +343,358 @@ fun ProfileSwitcherTab(
}
}
@Composable
fun SidebarProfileSwitcherStack(
onProfileSelected: (NuvioProfile) -> Unit,
onAddProfileRequested: () -> Unit,
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
) {
val profileState by ProfileRepository.state.collectAsStateWithLifecycle()
val activeProfile = profileState.activeProfile
val profiles = profileState.profiles
val avatars by AvatarRepository.avatars.collectAsStateWithLifecycle()
var pinProfile by remember { mutableStateOf<NuvioProfile?>(null) }
LaunchedEffect(Unit) {
AvatarRepository.fetchAvatars()
AvatarRepository.refreshAvatars()
}
fun chooseProfile(profile: NuvioProfile) {
if (profile.profileIndex == activeProfile?.profileIndex) {
onDismissRequest()
return
}
if (profile.pinEnabled) {
pinProfile = profile
} else {
onProfileSelected(profile)
onDismissRequest()
}
}
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
profiles.forEach { profile ->
SidebarProfileSwitcherRow(
profile = profile,
avatars = avatars,
isActive = profile.profileIndex == activeProfile?.profileIndex,
onClick = { chooseProfile(profile) },
)
}
if (profiles.size < 4) {
SidebarAddProfileRow(
onClick = {
onDismissRequest()
onAddProfileRequested()
},
)
}
AnimatedVisibility(
visible = pinProfile != null,
enter = expandVertically() + fadeIn(tween(160)),
exit = shrinkVertically(tween(120)) + fadeOut(tween(90)),
) {
pinProfile?.let { profile ->
SidebarCompactPinEntry(
profileName = profile.name,
onVerified = {
onProfileSelected(profile)
pinProfile = null
onDismissRequest()
},
onCancel = { pinProfile = null },
verifyPin = { pin ->
ProfileRepository.verifyPin(profile.profileIndex, pin)
},
)
}
}
}
}
@Composable
private fun SidebarProfileSwitcherRow(
profile: NuvioProfile,
avatars: List<AvatarCatalogItem>,
isActive: Boolean,
onClick: () -> Unit,
) {
val tokens = MaterialTheme.nuvio
Row(
modifier = Modifier
.fillMaxWidth()
.height(40.dp)
.clip(tokens.shapes.compactCard)
.background(
if (isActive) {
tokens.colors.overlaySelected
} else {
tokens.colors.surface.copy(alpha = 0f)
},
)
.clickable(onClick = onClick)
.padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier.size(30.dp),
contentAlignment = Alignment.Center,
) {
ActiveProfileMiniAvatar(
profile = profile,
avatars = avatars,
selected = isActive,
size = 26,
)
}
Spacer(modifier = Modifier.width(8.dp))
Text(
text = profile.name.ifBlank {
stringResource(Res.string.profile_label_number, profile.profileIndex)
},
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.labelMedium,
color = if (isActive) tokens.colors.textPrimary else tokens.colors.textMuted,
fontWeight = if (isActive) FontWeight.Bold else FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (profile.pinEnabled) {
Icon(
imageVector = Icons.Rounded.Lock,
contentDescription = null,
tint = tokens.colors.textMuted,
modifier = Modifier.size(12.dp),
)
}
}
}
@Composable
private fun SidebarAddProfileRow(
onClick: () -> Unit,
) {
val tokens = MaterialTheme.nuvio
Row(
modifier = Modifier
.fillMaxWidth()
.height(40.dp)
.clip(tokens.shapes.compactCard)
.clickable(onClick = onClick)
.padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(30.dp)
.clip(tokens.shapes.avatar)
.background(tokens.colors.surfaceCard),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Rounded.Add,
contentDescription = stringResource(Res.string.compose_profile_add_profile),
tint = tokens.colors.textMuted,
modifier = Modifier.size(16.dp),
)
}
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(Res.string.compose_profile_add_profile),
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.labelMedium,
color = tokens.colors.textMuted,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
@Composable
private fun SidebarCompactPinEntry(
profileName: String,
onVerified: () -> Unit,
onCancel: () -> Unit,
verifyPin: suspend (String) -> PinVerifyResult,
) {
val tokens = MaterialTheme.nuvio
var pin by remember(profileName) { mutableStateOf("") }
var error by remember(profileName) { mutableStateOf<String?>(null) }
var isVerifying by remember(profileName) { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val haptic = LocalHapticFeedback.current
Column(
modifier = Modifier
.fillMaxWidth()
.clip(tokens.shapes.compactCard)
.background(tokens.colors.surfaceCard.copy(alpha = 0.72f))
.padding(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(Res.string.pin_enter_for, profileName),
style = MaterialTheme.typography.labelSmall,
color = tokens.colors.textMuted,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
repeat(4) { index ->
val filled = index < pin.length
Box(
modifier = Modifier
.size(8.dp)
.clip(tokens.shapes.avatar)
.then(
if (filled) {
Modifier.background(tokens.colors.accent)
} else {
Modifier.border(tokens.borders.thin, tokens.colors.borderDefault, tokens.shapes.avatar)
},
),
)
}
}
AnimatedVisibility(
visible = error != null,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut(),
) {
Text(
text = error.orEmpty(),
style = MaterialTheme.typography.labelSmall,
color = tokens.colors.danger,
textAlign = TextAlign.Center,
maxLines = 2,
modifier = Modifier.padding(top = 6.dp),
)
}
Spacer(modifier = Modifier.height(8.dp))
SidebarCompactPinKeypad(
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) {
getString(Res.string.pin_locked_try_again, result.retryAfterSeconds)
} else {
getString(Res.string.pin_incorrect)
}
pin = ""
}
isVerifying = false
}
}
}
},
onBackspace = {
if (pin.isNotEmpty() && !isVerifying) {
pin = pin.dropLast(1)
error = null
}
},
)
Text(
text = stringResource(Res.string.pin_cancel),
style = MaterialTheme.typography.labelSmall,
color = tokens.colors.accent,
fontWeight = FontWeight.SemiBold,
modifier = Modifier
.clip(tokens.shapes.compactCard)
.clickable(onClick = onCancel)
.padding(horizontal = 8.dp, vertical = 4.dp),
)
}
}
@Composable
private fun SidebarCompactPinKeypad(
onDigit: (String) -> Unit,
onBackspace: () -> Unit,
) {
val tokens = MaterialTheme.nuvio
val rows = listOf(
listOf("1", "2", "3"),
listOf("4", "5", "6"),
listOf("7", "8", "9"),
listOf("", "0", ""),
)
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
rows.forEach { row ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally),
) {
row.forEach { key ->
when (key) {
"" -> Spacer(modifier = Modifier.size(30.dp))
"" -> {
Box(
modifier = Modifier
.size(30.dp)
.clip(tokens.shapes.avatar)
.background(tokens.colors.surface)
.clickable(onClick = onBackspace),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.Backspace,
contentDescription = stringResource(Res.string.pin_backspace),
tint = tokens.colors.textPrimary,
modifier = Modifier.size(14.dp),
)
}
}
else -> {
Box(
modifier = Modifier
.size(30.dp)
.clip(tokens.shapes.avatar)
.background(tokens.colors.surface)
.clickable { onDigit(key) },
contentAlignment = Alignment.Center,
) {
Text(
text = key,
style = MaterialTheme.typography.labelLarge,
color = tokens.colors.textPrimary,
fontWeight = FontWeight.Medium,
)
}
}
}
}
}
}
}
}
@Composable
private fun PopupAddProfileBubble(
delayMs: Int,

View file

@ -39,6 +39,8 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.nuvio.app.isDesktop
import com.nuvio.app.core.ui.AppTheme
import com.nuvio.app.core.ui.NuvioBottomSheetActionRow
import com.nuvio.app.core.ui.NuvioBottomSheetDivider
@ -56,6 +58,8 @@ import nuvio.composeapp.generated.resources.settings_appearance_app_language_she
import nuvio.composeapp.generated.resources.settings_appearance_amoled_black
import nuvio.composeapp.generated.resources.settings_appearance_amoled_description
import nuvio.composeapp.generated.resources.settings_appearance_continue_watching_description
import nuvio.composeapp.generated.resources.settings_appearance_desktop_navigation
import nuvio.composeapp.generated.resources.settings_appearance_desktop_navigation_sheet_title
import nuvio.composeapp.generated.resources.settings_appearance_liquid_glass
import nuvio.composeapp.generated.resources.settings_appearance_liquid_glass_description
import nuvio.composeapp.generated.resources.settings_appearance_poster_customization_description
@ -113,6 +117,11 @@ internal fun LazyListScope.appearanceSettingsContent(
item {
var showLanguageSheet by remember { mutableStateOf(false) }
var showDesktopNavigationSheet by remember { mutableStateOf(false) }
val desktopNavigationLayout by remember {
ThemeSettingsRepository.ensureLoaded()
ThemeSettingsRepository.desktopNavigationLayout
}.collectAsStateWithLifecycle()
SettingsSection(
title = stringResource(Res.string.settings_appearance_section_display),
isTablet = isTablet,
@ -135,6 +144,16 @@ internal fun LazyListScope.appearanceSettingsContent(
onCheckedChange = onLiquidGlassNativeTabBarToggle,
)
}
if (isDesktop) {
SettingsGroupDivider(isTablet = isTablet)
SettingsNavigationRow(
title = stringResource(Res.string.settings_appearance_desktop_navigation),
description = stringResource(desktopNavigationLayout.labelRes),
icon = Icons.Rounded.Style,
isTablet = isTablet,
onClick = { showDesktopNavigationSheet = true },
)
}
SettingsGroupDivider(isTablet = isTablet)
SettingsNavigationRow(
title = stringResource(Res.string.settings_appearance_app_language),
@ -146,6 +165,17 @@ internal fun LazyListScope.appearanceSettingsContent(
}
}
if (showDesktopNavigationSheet) {
DesktopNavigationLayoutBottomSheet(
selectedLayout = desktopNavigationLayout,
onLayoutSelected = {
ThemeSettingsRepository.setDesktopNavigationLayout(it)
showDesktopNavigationSheet = false
},
onDismiss = { showDesktopNavigationSheet = false },
)
}
if (showLanguageSheet) {
AppearanceLanguageBottomSheet(
selectedLanguage = selectedAppLanguage,
@ -184,6 +214,66 @@ internal fun LazyListScope.appearanceSettingsContent(
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun DesktopNavigationLayoutBottomSheet(
selectedLayout: DesktopNavigationLayout,
onLayoutSelected: (DesktopNavigationLayout) -> Unit,
onDismiss: () -> Unit,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val coroutineScope = rememberCoroutineScope()
NuvioModalBottomSheet(
onDismissRequest = {
coroutineScope.launch {
dismissNuvioBottomSheet(sheetState = sheetState, onDismiss = onDismiss)
}
},
sheetState = sheetState,
) {
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
) {
item {
Text(
text = stringResource(Res.string.settings_appearance_desktop_navigation_sheet_title),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
)
}
itemsIndexed(DesktopNavigationLayout.entries) { index, layout ->
if (index > 0) {
NuvioBottomSheetDivider()
}
NuvioBottomSheetActionRow(
title = stringResource(layout.labelRes),
onClick = {
onLayoutSelected(layout)
coroutineScope.launch {
dismissNuvioBottomSheet(sheetState = sheetState, onDismiss = onDismiss)
}
},
trailingContent = {
if (layout == selectedLayout) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = stringResource(Res.string.cd_selected),
tint = MaterialTheme.colorScheme.primary,
)
}
},
)
}
}
}
}
private data class AppLanguageSheetOption(
val language: AppLanguage,
val labelRes: StringResource,

View file

@ -0,0 +1,21 @@
package com.nuvio.app.features.settings
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.settings_appearance_desktop_navigation_sidebar
import nuvio.composeapp.generated.resources.settings_appearance_desktop_navigation_top_bar
import org.jetbrains.compose.resources.StringResource
enum class DesktopNavigationLayout(
val labelRes: StringResource,
) {
Sidebar(Res.string.settings_appearance_desktop_navigation_sidebar),
TopBar(Res.string.settings_appearance_desktop_navigation_top_bar),
;
companion object {
val Default = Sidebar
fun fromName(name: String?): DesktopNavigationLayout =
entries.firstOrNull { it.name.equals(name, ignoreCase = true) } ?: Default
}
}

View file

@ -17,6 +17,9 @@ object ThemeSettingsRepository {
private val _liquidGlassNativeTabBarEnabled = MutableStateFlow(false)
val liquidGlassNativeTabBarEnabled: StateFlow<Boolean> = _liquidGlassNativeTabBarEnabled.asStateFlow()
private val _desktopNavigationLayout = MutableStateFlow(DesktopNavigationLayout.Default)
val desktopNavigationLayout: StateFlow<DesktopNavigationLayout> = _desktopNavigationLayout.asStateFlow()
private val _selectedAppLanguage = MutableStateFlow(AppLanguage.ENGLISH)
val selectedAppLanguage: StateFlow<AppLanguage> = _selectedAppLanguage.asStateFlow()
@ -36,6 +39,7 @@ object ThemeSettingsRepository {
_selectedTheme.value = AppTheme.WHITE
_amoledEnabled.value = false
_liquidGlassNativeTabBarEnabled.value = false
_desktopNavigationLayout.value = DesktopNavigationLayout.Default
NativeTabBridge.publishAccentColor(AppTheme.WHITE.nativeTabAccentHex())
NativeTabBridge.publishLiquidGlassEnabled(false)
_selectedAppLanguage.value = AppLanguage.ENGLISH
@ -59,6 +63,9 @@ object ThemeSettingsRepository {
val liquidGlassEnabled = ThemeSettingsStorage.loadLiquidGlassNativeTabBarEnabled() ?: false
_liquidGlassNativeTabBarEnabled.value = liquidGlassEnabled
NativeTabBridge.publishLiquidGlassEnabled(liquidGlassEnabled)
_desktopNavigationLayout.value = DesktopNavigationLayout.fromName(
ThemeSettingsStorage.loadDesktopNavigationLayout(),
)
val appLanguage = AppLanguage.fromCode(ThemeSettingsStorage.loadSelectedAppLanguage())
ThemeSettingsStorage.applySelectedAppLanguage(appLanguage.code)
_selectedAppLanguage.value = appLanguage
@ -87,6 +94,13 @@ object ThemeSettingsRepository {
NativeTabBridge.publishLiquidGlassEnabled(enabled)
}
fun setDesktopNavigationLayout(layout: DesktopNavigationLayout) {
ensureLoaded()
if (_desktopNavigationLayout.value == layout) return
_desktopNavigationLayout.value = layout
ThemeSettingsStorage.saveDesktopNavigationLayout(layout.name)
}
fun setAppLanguage(language: AppLanguage) {
ensureLoaded()
if (_selectedAppLanguage.value == language) return

View file

@ -9,6 +9,8 @@ internal expect object ThemeSettingsStorage {
fun saveAmoledEnabled(enabled: Boolean)
fun loadLiquidGlassNativeTabBarEnabled(): Boolean?
fun saveLiquidGlassNativeTabBarEnabled(enabled: Boolean)
fun loadDesktopNavigationLayout(): String?
fun saveDesktopNavigationLayout(layoutName: String)
fun loadSelectedAppLanguage(): String?
fun saveSelectedAppLanguage(languageCode: String)
fun applySelectedAppLanguage(languageCode: String)

View file

@ -15,11 +15,13 @@ internal actual object ThemeSettingsStorage {
private const val selectedThemeKey = "selected_theme"
private const val amoledEnabledKey = "amoled_enabled"
private const val liquidGlassNativeTabBarEnabledKey = "liquid_glass_native_tab_bar_enabled"
private const val desktopNavigationLayoutKey = "desktop_navigation_layout"
private const val selectedAppLanguageKey = "selected_app_language"
private val profileScopedSyncKeys = listOf(
selectedThemeKey,
amoledEnabledKey,
liquidGlassNativeTabBarEnabledKey,
desktopNavigationLayoutKey,
)
private val store = DesktopStorage.store("nuvio_theme_settings")
@ -44,6 +46,13 @@ internal actual object ThemeSettingsStorage {
store.putBoolean(ProfileScopedKey.of(liquidGlassNativeTabBarEnabledKey), enabled)
}
actual fun loadDesktopNavigationLayout(): String? =
store.getString(ProfileScopedKey.of(desktopNavigationLayoutKey))
actual fun saveDesktopNavigationLayout(layoutName: String) {
store.putString(ProfileScopedKey.of(desktopNavigationLayoutKey), layoutName)
}
actual fun loadSelectedAppLanguage(): String? =
store.getString(selectedAppLanguageKey)
?: Locale.getDefault().toLanguageTag().takeIf { it.isNotBlank() }
@ -60,6 +69,7 @@ internal actual object ThemeSettingsStorage {
loadSelectedTheme()?.let { put(selectedThemeKey, encodeSyncString(it)) }
loadAmoledEnabled()?.let { put(amoledEnabledKey, encodeSyncBoolean(it)) }
loadLiquidGlassNativeTabBarEnabled()?.let { put(liquidGlassNativeTabBarEnabledKey, encodeSyncBoolean(it)) }
loadDesktopNavigationLayout()?.let { put(desktopNavigationLayoutKey, encodeSyncString(it)) }
loadSelectedAppLanguage()?.let { put(selectedAppLanguageKey, encodeSyncString(it)) }
}
@ -68,6 +78,7 @@ internal actual object ThemeSettingsStorage {
payload.decodeSyncString(selectedThemeKey)?.let(::saveSelectedTheme)
payload.decodeSyncBoolean(amoledEnabledKey)?.let(::saveAmoledEnabled)
payload.decodeSyncBoolean(liquidGlassNativeTabBarEnabledKey)?.let(::saveLiquidGlassNativeTabBarEnabled)
payload.decodeSyncString(desktopNavigationLayoutKey)?.let(::saveDesktopNavigationLayout)
payload.decodeSyncString(selectedAppLanguageKey)?.let(::saveSelectedAppLanguage)
applySelectedAppLanguage(loadSelectedAppLanguage() ?: AppLanguage.ENGLISH.code)
}

View file

@ -14,11 +14,13 @@ actual object ThemeSettingsStorage {
private const val selectedThemeKey = "selected_theme"
private const val amoledEnabledKey = "amoled_enabled"
private const val liquidGlassNativeTabBarEnabledKey = "liquid_glass_native_tab_bar_enabled"
private const val desktopNavigationLayoutKey = "desktop_navigation_layout"
private const val selectedAppLanguageKey = "selected_app_language"
private val profileScopedSyncKeys = listOf(
selectedThemeKey,
amoledEnabledKey,
liquidGlassNativeTabBarEnabledKey,
desktopNavigationLayoutKey,
)
private val globalSyncKeys = listOf(selectedAppLanguageKey)
@ -60,6 +62,16 @@ actual object ThemeSettingsStorage {
)
}
actual fun loadDesktopNavigationLayout(): String? =
NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(desktopNavigationLayoutKey))
actual fun saveDesktopNavigationLayout(layoutName: String) {
NSUserDefaults.standardUserDefaults.setObject(
layoutName,
forKey = ProfileScopedKey.of(desktopNavigationLayoutKey),
)
}
actual fun loadSelectedAppLanguage(): String? {
val value = NSUserDefaults.standardUserDefaults.stringForKey(selectedAppLanguageKey)
if (value != null) return value
@ -88,6 +100,7 @@ actual object ThemeSettingsStorage {
loadSelectedTheme()?.let { put(selectedThemeKey, encodeSyncString(it)) }
loadAmoledEnabled()?.let { put(amoledEnabledKey, encodeSyncBoolean(it)) }
loadLiquidGlassNativeTabBarEnabled()?.let { put(liquidGlassNativeTabBarEnabledKey, encodeSyncBoolean(it)) }
loadDesktopNavigationLayout()?.let { put(desktopNavigationLayoutKey, encodeSyncString(it)) }
loadSelectedAppLanguage()?.let { put(selectedAppLanguageKey, encodeSyncString(it)) }
}
@ -102,6 +115,7 @@ actual object ThemeSettingsStorage {
payload.decodeSyncString(selectedThemeKey)?.let(::saveSelectedTheme)
payload.decodeSyncBoolean(amoledEnabledKey)?.let(::saveAmoledEnabled)
payload.decodeSyncBoolean(liquidGlassNativeTabBarEnabledKey)?.let(::saveLiquidGlassNativeTabBarEnabled)
payload.decodeSyncString(desktopNavigationLayoutKey)?.let(::saveDesktopNavigationLayout)
payload.decodeSyncString(selectedAppLanguageKey)?.let(::saveSelectedAppLanguage)
applySelectedAppLanguage(loadSelectedAppLanguage() ?: AppLanguage.ENGLISH.code)
}