From 84cc669bd5655c58749bce1d37b8db2e06e24fe4 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Tue, 9 Jun 2026 23:10:01 +0530 Subject: [PATCH] feat: sidebar for desktop --- .../settings/ThemeSettingsStorage.android.kt | 14 + .../composeResources/values/strings.xml | 4 + .../commonMain/kotlin/com/nuvio/app/App.kt | 293 +++++++++++++- .../features/profiles/ProfileSwitcherTab.kt | 361 +++++++++++++++++- .../settings/AppearanceSettingsPage.kt | 90 +++++ .../settings/DesktopNavigationLayout.kt | 21 + .../settings/ThemeSettingsRepository.kt | 14 + .../features/settings/ThemeSettingsStorage.kt | 2 + .../settings/ThemeSettingsStorage.desktop.kt | 11 + .../settings/ThemeSettingsStorage.ios.kt | 14 + 10 files changed, 820 insertions(+), 4 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DesktopNavigationLayout.kt diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.android.kt index e082a536..b4a6e5c9 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.android.kt @@ -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) } diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index af2b860e..48dfbdd0 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -536,6 +536,10 @@ App Language Choose Language Settings for the Continue Watching section. + Desktop Navigation + Choose Desktop Navigation + Sidebar + Top Bar Liquid Glass Use the native iPhone tab bar on iOS 26 and later. Instant profile switching from the tab bar is unavailable while this is on. Tune card width and corner radius. diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 6784c276..babed0ce 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -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(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, + 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, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt index baf082f2..81545f14 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt @@ -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(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, + 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(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, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppearanceSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppearanceSettingsPage.kt index abbf58e7..39130eb0 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppearanceSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppearanceSettingsPage.kt @@ -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, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DesktopNavigationLayout.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DesktopNavigationLayout.kt new file mode 100644 index 00000000..507a8ea5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DesktopNavigationLayout.kt @@ -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 + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsRepository.kt index 431b9d20..956dc217 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsRepository.kt @@ -17,6 +17,9 @@ object ThemeSettingsRepository { private val _liquidGlassNativeTabBarEnabled = MutableStateFlow(false) val liquidGlassNativeTabBarEnabled: StateFlow = _liquidGlassNativeTabBarEnabled.asStateFlow() + private val _desktopNavigationLayout = MutableStateFlow(DesktopNavigationLayout.Default) + val desktopNavigationLayout: StateFlow = _desktopNavigationLayout.asStateFlow() + private val _selectedAppLanguage = MutableStateFlow(AppLanguage.ENGLISH) val selectedAppLanguage: StateFlow = _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 diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.kt index 2a788baf..5bb6a284 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.kt @@ -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) diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.desktop.kt index 77d3d4ec..68891f4a 100644 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.desktop.kt +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.desktop.kt @@ -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) } diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.ios.kt index f66f8b8c..dcef54f8 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.ios.kt @@ -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) }