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 07954478b..bb27c4ec2 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 @@ -19,10 +19,12 @@ actual object ThemeSettingsStorage { private const val amoledEnabledKey = "amoled_enabled" private const val liquidGlassNativeTabBarEnabledKey = "liquid_glass_native_tab_bar_enabled" private const val selectedAppLanguageKey = "selected_app_language" + private const val NAV_BAR_STYLE_KEY = "nav_bar_style" private val profileScopedSyncKeys = listOf( selectedThemeKey, amoledEnabledKey, liquidGlassNativeTabBarEnabledKey, + NAV_BAR_STYLE_KEY, ) private var preferences: SharedPreferences? = null @@ -93,10 +95,21 @@ actual object ThemeSettingsStorage { } } + actual fun loadNavBarStyle(): String? = + preferences?.getString(ProfileScopedKey.of(NAV_BAR_STYLE_KEY), null) + + actual fun saveNavBarStyle(styleKey: String) { + preferences + ?.edit() + ?.putString(ProfileScopedKey.of(NAV_BAR_STYLE_KEY), styleKey) + ?.apply() + } + actual fun exportToSyncPayload(): JsonObject = buildJsonObject { loadSelectedTheme()?.let { put(selectedThemeKey, encodeSyncString(it)) } loadAmoledEnabled()?.let { put(amoledEnabledKey, encodeSyncBoolean(it)) } loadLiquidGlassNativeTabBarEnabled()?.let { put(liquidGlassNativeTabBarEnabledKey, encodeSyncBoolean(it)) } + loadNavBarStyle()?.let { put(NAV_BAR_STYLE_KEY, encodeSyncString(it)) } } actual fun replaceFromSyncPayload(payload: JsonObject) { @@ -107,6 +120,7 @@ actual object ThemeSettingsStorage { payload.decodeSyncString(selectedThemeKey)?.let(::saveSelectedTheme) payload.decodeSyncBoolean(amoledEnabledKey)?.let(::saveAmoledEnabled) payload.decodeSyncBoolean(liquidGlassNativeTabBarEnabledKey)?.let(::saveLiquidGlassNativeTabBarEnabled) + payload.decodeSyncString(NAV_BAR_STYLE_KEY)?.let(::saveNavBarStyle) applySelectedAppLanguage(loadSelectedAppLanguage() ?: AppLanguage.DEVICE.code) } } diff --git a/composeApp/src/commonMain/composeResources/values-pl/strings.xml b/composeApp/src/commonMain/composeResources/values-pl/strings.xml index 473e6a21a..012ba9ae2 100644 --- a/composeApp/src/commonMain/composeResources/values-pl/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-pl/strings.xml @@ -1879,4 +1879,111 @@ Błąd API GitHub releases: %1$d Nie opublikowano jeszcze żadnej aktualizacji. Wydanie nie ma tagu ani nazwy + Przełącz + Rejestrując się, akceptuję + Regulamin + DIAGNOSTYKA + Raporty awarii Sentry + Wysyłaj raporty awarii i ANR z bezpiecznym kontekstem. Domyślnie włączone. + Włączyć raporty awarii? + Raporty awarii pomagają identyfikować problemy specyficzne dla urządzenia bez konieczności reprodukcji błędu z logami ADB. + Wyłączyć raporty awarii? + Przyszłe awarie i bieżące ANR z tego urządzenia nie będą zgłaszane. Może to znacznie utrudnić naprawę błędów specyficznych dla urządzenia. + Jak to pomaga + Raporty są grupowane według wersji aplikacji, modelu urządzenia, wersji Androida i śladu stosu, dzięki czemu najczęstsze awarie mogą być naprawiane w pierwszej kolejności. + Wysyłane + Awarie, bieżące ANR, ślady stosu, wersja aplikacji, typ kompilacji, wariant, metadane urządzenia i systemu operacyjnego oraz bezpieczne breadcrumby sieciowe z metodą, hostem, ścieżką, statusem, czasem trwania i typem wyjątku. + Nie wysyłane + Hasła, tokeny dostępu, tokeny odświeżania, treści żądań, treści odpowiedzi, nagłówki, ciasteczka, zrzuty ekranu, hierarchia widoków, odtwarzanie sesji, surowa diagnostyka oraz wartości zapytania lub fragmentu URL strumienia. + Pozostaw włączone + Wyłącz + Włącz + Otwórz folder pobranych + Nie udało się otworzyć folderu pobranych + Pasek nawigacji + Styl paska nawigacji + Adaptacyjny + Zawsze rozwinięty + Zawsze kompaktowy + Klasyczny + Efekt głębi karty + Dodaje podświetloną górną krawędź i delikatny połysk do kart graficznych, tworząc subtelne wrażenie głębi. + Włącz efekt głębi + Blask krawędzi + Subtelny + Zrównoważony + Wyrazisty + Górny połysk + Wył. + Miękki + Jasny + Zastosuj do + Dostrojenie + Dostrojenie głębi + Przeciągnij punkt w prawo dla jaśniejszej krawędzi, w górę dla większego połysku. Użyj suwaka, aby rozszerzyć blask na cały kontur. + Blask krawędzi → + ↑ Połysk + Blask krawędzi + Połysk + Pokrycie krawędzi + Tylko góra + Połowa + Pełny kontur + Pokrycie krawędzi + Tytuł odcinka + S1 · E1 · 45 min + Plakaty + Kontynuuj oglądanie + Karty odcinków + Obsada + Zwiastuny + Tryb tła + Wybierz sposób wyświetlania grafiki za stronami metadanych. + Normalny + Użyj standardowego tła aplikacji. + Kinowe + Pokaż miękkie rozmyte tło za stroną. + Kolor dominujący + Dopasuj tło strony do głównego koloru z grafiki tła. + Serwer i utrzymanie w tym miesiącu + Pokryte. Dodatkowe wsparcie trafia teraz na rozwój. + Po 100% dodatkowe wsparcie trafia na rozwój. + Ładowanie postępu finansowania... + Wysyłaj znaczniki intro i outro + Przekaż rozwiązane znaczniki pomijania intro i outro do zewnętrznego odtwarzacza w celu automatycznego pomijania. Działa tylko w odtwarzaczach, które to obsługują; inne odtwarzacze to ignorują. + Silnik odtwarzania + Użyj najpierw ExoPlayera i przejdź na libmpv, jeśli odtwarzanie się nie powiedzie. + Użyj ExoPlayera i dekoderów Android Media3. + Użyj libmpv do odtwarzania na Androidzie. + Renderer libmpv + Renderer libmpv + Dekodowanie sprzętowe libmpv + Używaj dekodowania sprzętowego mpv, gdy jest dostępne. + Kompatybilność YUV420P libmpv + Wymuś wyjście YUV420P dla urządzeń z problemami renderera lub kolorów. + Ostrzeżenia dotyczące treści + Pokaż nakładkę z informacjami o treści przy rozpoczęciu odtwarzania. + Ładowanie segmentów do pominięcia… + Test banera zakończony + Podgląd banera i symulowanego postępu pobierania. + Testuj baner aktualizacji + Nie podano informacji o wydaniu. + ID kolekcji „%1$s" jest użyte więcej niż raz. + ID folderu „%1$s" jest użyte więcej niż raz w „%2$s". + Urodzony + Miejsce urodzenia + Filmografia + Biografia + Kraj + Typ + Katalog + O + Ładowanie napisów z dodatków... + Pobieranie napisów... + + %d tytuł + %d tytuły + %d tytułów + %d tytułów + diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index e02d10ec7..e120b6c0c 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -567,6 +567,12 @@ App Language Device language Choose Language + Navigation Bar + Navigation Bar Style + Adaptive + Always Expanded + Always Compact + Classic Settings for the Continue Watching section. Liquid Glass Use the native iPhone tab bar on iOS 26 and later. diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 94d278da5..ebdba5e9d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -59,6 +59,7 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment import androidx.compose.ui.draw.alpha +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.graphics.Color import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.layout.ContentScale @@ -93,7 +94,11 @@ import com.nuvio.app.core.sync.ProfileSettingsSync import com.nuvio.app.core.sync.RealtimeSyncConfig import com.nuvio.app.core.sync.RealtimeSyncInvalidationService import com.nuvio.app.core.sync.SyncManager +import com.nuvio.app.core.ui.LocalNuvioNavBarScrollState import com.nuvio.app.core.ui.NuvioNavigationBar +import com.nuvio.app.core.ui.NuvioClassicNavigationBar +import com.nuvio.app.core.ui.NuvioNavBarScrollState +import com.nuvio.app.core.ui.rememberNuvioNavBarScrollState import com.nuvio.app.core.format.formatReleaseDateForDisplay import com.nuvio.app.core.ui.NuvioContinueWatchingActionSheet import com.nuvio.app.core.ui.NuvioPosterZoomActionOverlay @@ -190,6 +195,7 @@ import com.nuvio.app.features.settings.PluginsSettingsScreen import com.nuvio.app.features.settings.AccountSettingsScreen import com.nuvio.app.features.settings.SupportersContributorsSettingsScreen import com.nuvio.app.features.settings.LicensesAttributionsSettingsScreen +import com.nuvio.app.features.settings.NavBarStyle import com.nuvio.app.features.settings.ThemeSettingsRepository import com.nuvio.app.features.collection.CollectionManagementScreen import com.nuvio.app.features.collection.CollectionEditorScreen @@ -1834,6 +1840,9 @@ private fun MainAppContent( liquidGlassNativeTabBarSupported && liquidGlassNativeTabBarEnabled && initialHomeReady } val tabsRouteActive = currentRoute is TabsRoute + val navBarScrollState = rememberNuvioNavBarScrollState() + val navBarHazeState = rememberHazeState() + val navBarStyleSetting by remember { ThemeSettingsRepository.navBarStyle }.collectAsStateWithLifecycle() val onProfileSelected: (NuvioProfile) -> Unit = { profile -> profileSwitchLoading = true NativeTabBridge.publishTabBarVisible(false) @@ -1849,8 +1858,8 @@ private fun MainAppContent( containerColor = Color.Transparent, contentWindowInsets = WindowInsets(0), bottomBar = { - if (!isTabletLayout && !useNativeBottomTabs) { - NuvioNavigationBar { + if (!isTabletLayout && !useNativeBottomTabs && navBarStyleSetting == NavBarStyle.CLASSIC) { + NuvioClassicNavigationBar { NavItem( selected = selectedTab == AppScreenTab.Home, onClick = { handleRootTabClick(AppScreenTab.Home) }, @@ -1886,11 +1895,14 @@ private fun MainAppContent( ) { innerPadding -> Box(modifier = Modifier.fillMaxSize()) { CompositionLocalProvider( - LocalNuvioBottomNavigationOverlayPadding provides if (useNativeBottomTabs) 49.dp else 0.dp, + LocalNuvioBottomNavigationOverlayPadding provides if (useNativeBottomTabs) 49.dp else if (!isTabletLayout && navBarStyleSetting != NavBarStyle.CLASSIC) 72.dp else 0.dp, + LocalNuvioNavBarScrollState provides navBarScrollState, ) { AppTabHost( modifier = Modifier .fillMaxSize() + .then(if (navBarStyleSetting != NavBarStyle.CLASSIC) Modifier.hazeSource(state = navBarHazeState) else Modifier) + .then(if (navBarStyleSetting == NavBarStyle.ADAPTIVE) Modifier.nestedScroll(navBarScrollState.nestedScrollConnection) else Modifier) .padding(innerPadding), selectedTab = selectedTab, searchFocusRequestCount = searchFocusRequestCount, @@ -2033,6 +2045,55 @@ private fun MainAppContent( onAddProfileRequested = onSwitchProfile, ) } + + // Floating pill navigation bar overlay + if (!isTabletLayout && !useNativeBottomTabs && navBarStyleSetting != NavBarStyle.CLASSIC) { + // Force expand/collapse for non-adaptive modes + when (navBarStyleSetting) { + NavBarStyle.EXPANDED -> navBarScrollState.expand() + NavBarStyle.COMPACT -> navBarScrollState.collapse() + else -> {} // ADAPTIVE — scroll controls it + } + NuvioNavigationBar( + modifier = Modifier.align(Alignment.BottomCenter), + scrollState = navBarScrollState, + hazeState = navBarHazeState, + ) { + NavItem( + selected = selectedTab == AppScreenTab.Home, + onClick = { handleRootTabClick(AppScreenTab.Home) }, + icon = Icons.Filled.Home, + contentDescription = stringResource(Res.string.compose_nav_home), + label = stringResource(Res.string.compose_nav_home), + ) + NavItem( + selected = selectedTab == AppScreenTab.Search, + onClick = { handleRootTabClick(AppScreenTab.Search) }, + icon = Res.drawable.sidebar_search, + contentDescription = stringResource(Res.string.compose_nav_search), + label = stringResource(Res.string.compose_nav_search), + ) + NavItem( + selected = selectedTab == AppScreenTab.Library, + onClick = { handleRootTabClick(AppScreenTab.Library) }, + icon = Res.drawable.sidebar_library, + contentDescription = stringResource(Res.string.compose_nav_library), + label = stringResource(Res.string.compose_nav_library), + ) + NavItem( + selected = selectedTab == AppScreenTab.Settings, + onClick = { handleRootTabClick(AppScreenTab.Settings) }, + label = stringResource(Res.string.compose_nav_profile), + ) { + ProfileSwitcherTab( + selected = selectedTab == AppScreenTab.Settings, + onClick = { handleRootTabClick(AppScreenTab.Settings) }, + onProfileSelected = onProfileSelected, + onAddProfileRequested = onSwitchProfile, + ) + } + } + } } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NavigationBar.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NavigationBar.kt index 2d3eb4b4a..f5a452e0e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NavigationBar.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NavigationBar.kt @@ -1,48 +1,176 @@ package com.nuvio.app.core.ui import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background 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.asPaddingValues 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.widthIn import androidx.compose.foundation.selection.selectable -import androidx.compose.material3.HorizontalDivider +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.hazeEffect import org.jetbrains.compose.resources.DrawableResource import org.jetbrains.compose.resources.painterResource +/** + * Scroll-aware state for the floating navigation bar. + * Tracks scroll direction and exposes a label visibility fraction (1 = fully visible, 0 = hidden). + */ +@Stable +class NuvioNavBarScrollState { + /** 1f = labels fully visible (expanded), 0f = labels hidden (collapsed, icons only) */ + var labelVisibility by mutableFloatStateOf(1f) + private set + + private var accumulatedDelta = 0f + + /** Call to expand (show labels) – e.g. when user scrolls back to top */ + fun expand() { + labelVisibility = 1f + accumulatedDelta = 0f + } + + /** Call to collapse (hide labels) */ + fun collapse() { + labelVisibility = 0f + accumulatedDelta = 0f + } + + val nestedScrollConnection: NestedScrollConnection = object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + val deltaY = available.y + if (deltaY == 0f) return Offset.Zero + + accumulatedDelta += deltaY + + if (accumulatedDelta < -SCROLL_THRESHOLD && labelVisibility != 0f) { + // Scrolling down past threshold → snap collapse + labelVisibility = 0f + accumulatedDelta = 0f + } else if (accumulatedDelta > SCROLL_THRESHOLD && labelVisibility != 1f) { + // Scrolling up past threshold → snap expand + labelVisibility = 1f + accumulatedDelta = 0f + } + + // Reset accumulator if direction changed + if (deltaY < 0f && accumulatedDelta > 0f) accumulatedDelta = deltaY + if (deltaY > 0f && accumulatedDelta < 0f) accumulatedDelta = deltaY + + return Offset.Zero // Don't consume any scroll + } + } + + companion object { + private const val SCROLL_THRESHOLD = 60f + } +} + +@Composable +fun rememberNuvioNavBarScrollState(): NuvioNavBarScrollState { + return androidx.compose.runtime.remember { NuvioNavBarScrollState() } +} + +/** + * Floating pill-shaped navigation bar with scroll-responsive labels. + * + * @param hazeState Optional [HazeState] whose source is placed on the content behind this bar. + * When provided, the pill gets a blur-through effect. + */ @Composable fun NuvioNavigationBar( modifier: Modifier = Modifier, + scrollState: NuvioNavBarScrollState? = null, + hazeState: HazeState? = null, content: @Composable NuvioNavigationBarScope.() -> Unit, ) { - val tokens = MaterialTheme.nuvio - Column(modifier.fillMaxWidth()) { - HorizontalDivider( - thickness = tokens.borders.hairline, - color = tokens.colors.borderDefault, - ) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(nuvioBottomNavigationBarInsets().asPaddingValues()) - .padding(horizontal = NuvioTokens.Space.s4, vertical = nuvioBottomNavigationExtraVerticalPadding), - horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap, Alignment.CenterHorizontally), - ) { - NuvioNavigationBarScopeImpl(this).content() + val labelFraction by animateFloatAsState( + targetValue = scrollState?.labelVisibility ?: 1f, + animationSpec = tween( + durationMillis = NuvioTokens.Motion.sheetEnterMillis, + easing = NuvioTokens.Motion.standard, + ), + label = "nav_label_alpha", + ) + + val navigationBarInsets = nuvioBottomNavigationBarInsets() + val bottomSafePadding = navigationBarInsets.asPaddingValues().calculateBottomPadding() + + // Dynamic horizontal padding: pill shrinks when labels are hidden — driven by same labelFraction + val expandedHorizontalPadding = 28.dp + val collapsedHorizontalPadding = 58.dp + val horizontalPadding = expandedHorizontalPadding + (collapsedHorizontalPadding - expandedHorizontalPadding) * (1f - labelFraction) + + // Outer container — no background, just safe padding + Box( + modifier = modifier + .fillMaxWidth() + .padding(bottom = bottomSafePadding + nuvioBottomNavigationExtraVerticalPadding + NuvioTokens.Space.s8), + contentAlignment = Alignment.BottomCenter, + ) { + // The floating pill + val pillModifier = Modifier + .padding(horizontal = horizontalPadding) + .fillMaxWidth() + .clip(RoundedCornerShape(NuvioTokens.Radius.full)) + .then( + if (hazeState != null) { + Modifier.hazeEffect(state = hazeState) { + blurRadius = 24.dp + } + } else { + Modifier + }, + ) + .background(Color(0xFF1C1C1E).copy(alpha = if (hazeState != null) 0.55f else 0.82f)) + + Box(modifier = pillModifier) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = NuvioTokens.Space.s6, + vertical = NuvioTokens.Space.s4, + ), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + ) { + NuvioNavigationBarScopeImpl( + rowScope = this, + labelFraction = labelFraction, + ).content() + } } } } @@ -55,6 +183,7 @@ interface NuvioNavigationBarScope { icon: ImageVector, contentDescription: String?, modifier: Modifier = Modifier, + label: String? = null, ) @Composable @@ -64,6 +193,7 @@ interface NuvioNavigationBarScope { icon: DrawableResource, contentDescription: String?, modifier: Modifier = Modifier, + label: String? = null, ) @Composable @@ -71,12 +201,208 @@ interface NuvioNavigationBarScope { selected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier, + label: String? = null, content: @Composable () -> Unit, ) } private class NuvioNavigationBarScopeImpl( private val rowScope: androidx.compose.foundation.layout.RowScope, + private val labelFraction: Float, +) : NuvioNavigationBarScope { + + @Composable + override fun NavItem( + selected: Boolean, + onClick: () -> Unit, + icon: ImageVector, + contentDescription: String?, + modifier: Modifier, + label: String?, + ) { + val tokens = MaterialTheme.nuvio + val iconColor by animateColorAsState( + targetValue = if (selected) tokens.colors.accent else tokens.colors.textMuted, + label = "nav_icon_color", + ) + // Selected item gets a pill-shaped highlight using accent at low opacity + val selectedBgColor by animateColorAsState( + targetValue = if (selected) tokens.colors.accent.copy(alpha = NuvioTokens.Opacity.selected) + else Color.Transparent, + label = "nav_bg_color", + ) + + with(rowScope) { + Column( + modifier = modifier + .weight(1f) + .clip(RoundedCornerShape(NuvioTokens.Radius.full)) + .background(selectedBgColor) + .selectable( + selected = selected, + enabled = true, + role = Role.Tab, + onClick = onClick, + ) + .padding(vertical = NuvioTokens.Space.s6), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + modifier = Modifier.size(28.dp), + imageVector = icon, + contentDescription = contentDescription, + tint = iconColor, + ) + NavItemLabel(label = label, labelFraction = labelFraction, iconColor = iconColor, selected = selected) + } + } + } + + @Composable + override fun NavItem( + selected: Boolean, + onClick: () -> Unit, + icon: DrawableResource, + contentDescription: String?, + modifier: Modifier, + label: String?, + ) { + val tokens = MaterialTheme.nuvio + val iconColor by animateColorAsState( + targetValue = if (selected) tokens.colors.accent else tokens.colors.textMuted, + label = "nav_icon_color", + ) + val selectedBgColor by animateColorAsState( + targetValue = if (selected) tokens.colors.accent.copy(alpha = NuvioTokens.Opacity.selected) + else Color.Transparent, + label = "nav_bg_color", + ) + + with(rowScope) { + Column( + modifier = modifier + .weight(1f) + .clip(RoundedCornerShape(NuvioTokens.Radius.full)) + .background(selectedBgColor) + .selectable( + selected = selected, + enabled = true, + role = Role.Tab, + onClick = onClick, + ) + .padding(vertical = NuvioTokens.Space.s6), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + modifier = Modifier.size(28.dp), + painter = painterResource(icon), + contentDescription = contentDescription, + tint = iconColor, + ) + NavItemLabel(label = label, labelFraction = labelFraction, iconColor = iconColor, selected = selected) + } + } + } + + @Composable + override fun NavItem( + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier, + label: String?, + content: @Composable () -> Unit, + ) { + val tokens = MaterialTheme.nuvio + val selectedBgColor by animateColorAsState( + targetValue = if (selected) tokens.colors.accent.copy(alpha = NuvioTokens.Opacity.selected) + else Color.Transparent, + label = "nav_bg_color", + ) + val iconColor by animateColorAsState( + targetValue = if (selected) tokens.colors.accent else tokens.colors.textMuted, + label = "nav_icon_color", + ) + + with(rowScope) { + Column( + modifier = modifier + .weight(1f) + .clip(RoundedCornerShape(NuvioTokens.Radius.full)) + .background(selectedBgColor) + .selectable( + selected = selected, + enabled = true, + role = Role.Tab, + onClick = onClick, + ) + .padding(vertical = NuvioTokens.Space.s6), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + content() + NavItemLabel(label = label, labelFraction = labelFraction, iconColor = iconColor, selected = selected) + } + } + } +} + +@Composable +private fun NavItemLabel( + label: String?, + labelFraction: Float, + iconColor: Color, + selected: Boolean, +) { + if (label == null || labelFraction <= 0f) return + Spacer(modifier = Modifier.height(NuvioTokens.Space.s3 * labelFraction)) + Box( + modifier = Modifier + .height(NuvioTokens.Space.s14 * labelFraction) + .alpha(labelFraction), + ) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall.copy( + fontSize = NuvioTokens.Type.labelXs, + lineHeight = NuvioTokens.LineHeight.labelXs, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, + ), + color = iconColor, + maxLines = 1, + overflow = TextOverflow.Clip, + ) + } +} + + +/** + * Classic flat navigation bar — the original pre-pill implementation. + * No floating pill, no labels, no scroll behavior. Simple icon row with a top divider. + */ +@Composable +fun NuvioClassicNavigationBar( + modifier: Modifier = Modifier, + content: @Composable NuvioNavigationBarScope.() -> Unit, +) { + val tokens = MaterialTheme.nuvio + Column(modifier.fillMaxWidth()) { + androidx.compose.material3.HorizontalDivider( + thickness = NuvioTokens.Space.hairline, + color = tokens.colors.borderDefault, + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(nuvioBottomNavigationBarInsets().asPaddingValues()) + .padding(horizontal = NuvioTokens.Space.s4, vertical = nuvioBottomNavigationExtraVerticalPadding), + horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap, Alignment.CenterHorizontally), + ) { + NuvioClassicNavigationBarScopeImpl(this).content() + } + } +} + +private class NuvioClassicNavigationBarScopeImpl( + private val rowScope: androidx.compose.foundation.layout.RowScope, ) : NuvioNavigationBarScope { @Composable @@ -86,10 +412,12 @@ private class NuvioNavigationBarScopeImpl( icon: ImageVector, contentDescription: String?, modifier: Modifier, + label: String?, ) { val tokens = MaterialTheme.nuvio val iconColor by animateColorAsState( targetValue = if (selected) tokens.colors.accent else tokens.colors.textMuted, + label = "classic_nav_icon_color", ) with(rowScope) { Icon( @@ -120,10 +448,12 @@ private class NuvioNavigationBarScopeImpl( icon: DrawableResource, contentDescription: String?, modifier: Modifier, + label: String?, ) { val tokens = MaterialTheme.nuvio val iconColor by animateColorAsState( targetValue = if (selected) tokens.colors.accent else tokens.colors.textMuted, + label = "classic_nav_icon_color", ) with(rowScope) { Icon( @@ -152,6 +482,7 @@ private class NuvioNavigationBarScopeImpl( selected: Boolean, onClick: () -> Unit, modifier: Modifier, + label: String?, content: @Composable () -> Unit, ) { val tokens = MaterialTheme.nuvio diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PlatformInsets.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PlatformInsets.kt index bd4c58f07..ab29bb96f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PlatformInsets.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PlatformInsets.kt @@ -19,6 +19,9 @@ internal expect fun platformPhysicalTopInset(): Dp internal val LocalNuvioBottomNavigationOverlayPadding = staticCompositionLocalOf { 0.dp } +/** CompositionLocal providing the shared [NuvioNavBarScrollState] so child screens can attach the nestedScrollConnection. */ +val LocalNuvioNavBarScrollState = staticCompositionLocalOf { null } + @Composable internal fun nuvioSafeBottomPadding(extra: Dp = 0.dp): Dp { val navigationBarBottom = nuvioBottomNavigationBarInsets() 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 1ac3eb1b6..e12425041 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 @@ -38,6 +38,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.nuvio.app.core.ui.AppTheme +import com.nuvio.app.isIos import com.nuvio.app.core.ui.NuvioBottomSheetActionRow import com.nuvio.app.core.ui.NuvioBottomSheetDivider import com.nuvio.app.core.ui.NuvioModalBottomSheet @@ -55,6 +56,8 @@ import nuvio.composeapp.generated.resources.compose_settings_page_poster_customi import nuvio.composeapp.generated.resources.compose_settings_page_streams import nuvio.composeapp.generated.resources.settings_appearance_app_language import nuvio.composeapp.generated.resources.settings_appearance_app_language_sheet_title +import nuvio.composeapp.generated.resources.settings_appearance_nav_bar_style +import nuvio.composeapp.generated.resources.settings_appearance_nav_bar_style_sheet_title 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 @@ -84,6 +87,8 @@ internal fun LazyListScope.appearanceSettingsContent( onLiquidGlassNativeTabBarToggle: (Boolean) -> Unit, selectedAppLanguage: AppLanguage, onAppLanguageSelected: (AppLanguage) -> Unit, + selectedNavBarStyle: NavBarStyle, + onNavBarStyleSelected: (NavBarStyle) -> Unit, onHomescreenClick: () -> Unit, onMetaScreenClick: () -> Unit, onStreamsClick: () -> Unit, @@ -144,6 +149,7 @@ internal fun LazyListScope.appearanceSettingsContent( } item { var showLanguageSheet by remember { mutableStateOf(false) } + var showNavBarStyleSheet by remember { mutableStateOf(false) } SettingsSection( title = stringResource(Res.string.settings_appearance_section_display), isTablet = isTablet, @@ -173,6 +179,15 @@ internal fun LazyListScope.appearanceSettingsContent( isTablet = isTablet, onClick = { showLanguageSheet = true }, ) + if (!isIos) { + SettingsGroupDivider(isTablet = isTablet) + SettingsNavigationRow( + title = stringResource(Res.string.settings_appearance_nav_bar_style), + description = stringResource(selectedNavBarStyle.labelRes), + isTablet = isTablet, + onClick = { showNavBarStyleSheet = true }, + ) + } } } @@ -186,6 +201,17 @@ internal fun LazyListScope.appearanceSettingsContent( onDismiss = { showLanguageSheet = false }, ) } + + if (showNavBarStyleSheet) { + NavBarStyleBottomSheet( + selectedStyle = selectedNavBarStyle, + onStyleSelected = { + onNavBarStyleSelected(it) + showNavBarStyleSheet = false + }, + onDismiss = { showNavBarStyleSheet = false }, + ) + } } item { @@ -406,3 +432,64 @@ private fun ThemeChip( ) } } + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun NavBarStyleBottomSheet( + selectedStyle: NavBarStyle, + onStyleSelected: (NavBarStyle) -> 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_nav_bar_style_sheet_title), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), + ) + } + + itemsIndexed(NavBarStyle.entries.toList()) { index, style -> + if (index > 0) { + NuvioBottomSheetDivider() + } + NuvioBottomSheetActionRow( + title = stringResource(style.labelRes), + onClick = { + onStyleSelected(style) + coroutineScope.launch { + dismissNuvioBottomSheet(sheetState = sheetState, onDismiss = onDismiss) + } + }, + trailingContent = { + if (style == selectedStyle) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = stringResource(Res.string.cd_selected), + tint = MaterialTheme.colorScheme.primary, + ) + } + }, + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/NavBarStyle.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/NavBarStyle.kt new file mode 100644 index 000000000..fd4842b4a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/NavBarStyle.kt @@ -0,0 +1,24 @@ +package com.nuvio.app.features.settings + +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.settings_nav_bar_style_adaptive +import nuvio.composeapp.generated.resources.settings_nav_bar_style_expanded +import nuvio.composeapp.generated.resources.settings_nav_bar_style_compact +import nuvio.composeapp.generated.resources.settings_nav_bar_style_classic +import org.jetbrains.compose.resources.StringResource + +enum class NavBarStyle( + val key: String, + val labelRes: StringResource, +) { + ADAPTIVE("adaptive", Res.string.settings_nav_bar_style_adaptive), + EXPANDED("expanded", Res.string.settings_nav_bar_style_expanded), + COMPACT("compact", Res.string.settings_nav_bar_style_compact), + CLASSIC("classic", Res.string.settings_nav_bar_style_classic), + ; + + companion object { + fun fromKey(key: String?): NavBarStyle = + entries.firstOrNull { it.key.equals(key, ignoreCase = true) } ?: ADAPTIVE + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt index 9d58c39b4..9ac66fccf 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt @@ -155,6 +155,7 @@ fun SettingsScreen( !useNativeNavigation && isLiquidGlassNativeTabBarSupported() } val selectedAppLanguage by remember { ThemeSettingsRepository.selectedAppLanguage }.collectAsStateWithLifecycle() + val navBarStyle by remember { ThemeSettingsRepository.navBarStyle }.collectAsStateWithLifecycle() val tmdbSettings by remember { TmdbSettingsRepository.ensureLoaded() TmdbSettingsRepository.uiState @@ -392,6 +393,8 @@ fun SettingsScreen( onLiquidGlassNativeTabBarToggle = ThemeSettingsRepository::setLiquidGlassNativeTabBar, selectedAppLanguage = selectedAppLanguage, onAppLanguageSelected = ThemeSettingsRepository::setAppLanguage, + navBarStyle = navBarStyle, + onNavBarStyleSelected = ThemeSettingsRepository::setNavBarStyle, episodeReleaseNotificationsUiState = episodeReleaseNotificationsUiState, tmdbSettings = tmdbSettings, mdbListSettings = mdbListSettings, @@ -450,6 +453,8 @@ fun SettingsScreen( onLiquidGlassNativeTabBarToggle = ThemeSettingsRepository::setLiquidGlassNativeTabBar, selectedAppLanguage = selectedAppLanguage, onAppLanguageSelected = ThemeSettingsRepository::setAppLanguage, + navBarStyle = navBarStyle, + onNavBarStyleSelected = ThemeSettingsRepository::setNavBarStyle, episodeReleaseNotificationsUiState = episodeReleaseNotificationsUiState, tmdbSettings = tmdbSettings, mdbListSettings = mdbListSettings, @@ -518,6 +523,8 @@ private fun MobileSettingsScreen( onLiquidGlassNativeTabBarToggle: (Boolean) -> Unit, selectedAppLanguage: AppLanguage, onAppLanguageSelected: (AppLanguage) -> Unit, + navBarStyle: NavBarStyle, + onNavBarStyleSelected: (NavBarStyle) -> Unit, episodeReleaseNotificationsUiState: EpisodeReleaseNotificationsUiState, tmdbSettings: TmdbSettings, mdbListSettings: MdbListSettings, @@ -713,6 +720,8 @@ private fun MobileSettingsScreen( onLiquidGlassNativeTabBarToggle = onLiquidGlassNativeTabBarToggle, selectedAppLanguage = selectedAppLanguage, onAppLanguageSelected = onAppLanguageSelected, + selectedNavBarStyle = navBarStyle, + onNavBarStyleSelected = onNavBarStyleSelected, onHomescreenClick = onHomescreenClick, onMetaScreenClick = onMetaScreenClick, onStreamsClick = { onPageChange(SettingsPage.Streams) }, @@ -870,6 +879,8 @@ private fun TabletSettingsScreen( onLiquidGlassNativeTabBarToggle: (Boolean) -> Unit, selectedAppLanguage: AppLanguage, onAppLanguageSelected: (AppLanguage) -> Unit, + navBarStyle: NavBarStyle, + onNavBarStyleSelected: (NavBarStyle) -> Unit, episodeReleaseNotificationsUiState: EpisodeReleaseNotificationsUiState, tmdbSettings: TmdbSettings, mdbListSettings: MdbListSettings, @@ -1121,6 +1132,8 @@ private fun TabletSettingsScreen( onLiquidGlassNativeTabBarToggle = onLiquidGlassNativeTabBarToggle, selectedAppLanguage = selectedAppLanguage, onAppLanguageSelected = onAppLanguageSelected, + selectedNavBarStyle = navBarStyle, + onNavBarStyleSelected = onNavBarStyleSelected, onHomescreenClick = { openInlinePage(SettingsPage.Homescreen) }, onMetaScreenClick = { openInlinePage(SettingsPage.MetaScreen) }, onStreamsClick = { openInlinePage(SettingsPage.Streams) }, 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 217faa940..c6b5fa827 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 @@ -20,6 +20,9 @@ object ThemeSettingsRepository { private val _selectedAppLanguage = MutableStateFlow(AppLanguage.DEVICE) val selectedAppLanguage: StateFlow = _selectedAppLanguage.asStateFlow() + private val _navBarStyle = MutableStateFlow(NavBarStyle.ADAPTIVE) + val navBarStyle: StateFlow = _navBarStyle.asStateFlow() + private var hasLoaded = false fun ensureLoaded() { @@ -39,6 +42,7 @@ object ThemeSettingsRepository { NativeTabBridge.publishAccentColor(AppTheme.WHITE.nativeTabAccentHex()) NativeTabBridge.publishLiquidGlassEnabled(false) _selectedAppLanguage.value = AppLanguage.DEVICE + _navBarStyle.value = NavBarStyle.ADAPTIVE } private fun loadFromDisk() { @@ -62,6 +66,7 @@ object ThemeSettingsRepository { val appLanguage = AppLanguage.fromCode(ThemeSettingsStorage.loadSelectedAppLanguage()) ThemeSettingsStorage.applySelectedAppLanguage(appLanguage.code) _selectedAppLanguage.value = appLanguage + _navBarStyle.value = NavBarStyle.fromKey(ThemeSettingsStorage.loadNavBarStyle()) } fun setTheme(theme: AppTheme) { @@ -94,6 +99,13 @@ object ThemeSettingsRepository { ThemeSettingsStorage.applySelectedAppLanguage(language.code) _selectedAppLanguage.value = language } + + fun setNavBarStyle(style: NavBarStyle) { + ensureLoaded() + if (_navBarStyle.value == style) return + _navBarStyle.value = style + ThemeSettingsStorage.saveNavBarStyle(style.key) + } } private fun AppTheme.nativeTabAccentHex(): String = 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 2a788baf1..00cb705b7 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 @@ -12,6 +12,8 @@ internal expect object ThemeSettingsStorage { fun loadSelectedAppLanguage(): String? fun saveSelectedAppLanguage(languageCode: String) fun applySelectedAppLanguage(languageCode: String) + fun loadNavBarStyle(): String? + fun saveNavBarStyle(styleKey: String) fun exportToSyncPayload(): JsonObject fun replaceFromSyncPayload(payload: JsonObject) } 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 f36f0a921..7525e63ae 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 @@ -15,10 +15,12 @@ actual object ThemeSettingsStorage { private const val amoledEnabledKey = "amoled_enabled" private const val liquidGlassNativeTabBarEnabledKey = "liquid_glass_native_tab_bar_enabled" private const val selectedAppLanguageKey = "selected_app_language" + private const val navBarStyleKey = "nav_bar_style" private val profileScopedSyncKeys = listOf( selectedThemeKey, amoledEnabledKey, liquidGlassNativeTabBarEnabledKey, + navBarStyleKey, ) actual fun loadSelectedTheme(): String? = @@ -88,10 +90,18 @@ actual object ThemeSettingsStorage { NSUserDefaults.standardUserDefaults.synchronize() } + actual fun loadNavBarStyle(): String? = + NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(navBarStyleKey)) + + actual fun saveNavBarStyle(styleKey: String) { + NSUserDefaults.standardUserDefaults.setObject(styleKey, forKey = ProfileScopedKey.of(navBarStyleKey)) + } + actual fun exportToSyncPayload(): JsonObject = buildJsonObject { loadSelectedTheme()?.let { put(selectedThemeKey, encodeSyncString(it)) } loadAmoledEnabled()?.let { put(amoledEnabledKey, encodeSyncBoolean(it)) } loadLiquidGlassNativeTabBarEnabled()?.let { put(liquidGlassNativeTabBarEnabledKey, encodeSyncBoolean(it)) } + loadNavBarStyle()?.let { put(navBarStyleKey, encodeSyncString(it)) } } actual fun replaceFromSyncPayload(payload: JsonObject) { @@ -102,6 +112,7 @@ actual object ThemeSettingsStorage { payload.decodeSyncString(selectedThemeKey)?.let(::saveSelectedTheme) payload.decodeSyncBoolean(amoledEnabledKey)?.let(::saveAmoledEnabled) payload.decodeSyncBoolean(liquidGlassNativeTabBarEnabledKey)?.let(::saveLiquidGlassNativeTabBarEnabled) + payload.decodeSyncString(navBarStyleKey)?.let(::saveNavBarStyle) applySelectedAppLanguage(loadSelectedAppLanguage() ?: AppLanguage.DEVICE.code) } }