Merge pull request #1562 from skoruppa/modern-menu

modern floating pill navigation bar with scroll-responsive labels
This commit is contained in:
Nayif 2026-07-18 02:06:19 +05:30 committed by GitHub
commit 77049f4e00
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 689 additions and 18 deletions

View file

@ -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)
}
}

View file

@ -1879,4 +1879,111 @@
<string name="updates_github_api_error">Błąd API GitHub releases: %1$d</string>
<string name="updates_no_channel_release">Nie opublikowano jeszcze żadnej aktualizacji.</string>
<string name="updates_release_missing_title">Wydanie nie ma tagu ani nazwy</string>
<string name="action_switch">Przełącz</string>
<string name="compose_auth_terms_prefix">Rejestrując się, akceptuję</string>
<string name="compose_auth_terms_link">Regulamin</string>
<string name="settings_advanced_section_diagnostics">DIAGNOSTYKA</string>
<string name="settings_advanced_sentry_reports">Raporty awarii Sentry</string>
<string name="settings_advanced_sentry_reports_subtitle">Wysyłaj raporty awarii i ANR z bezpiecznym kontekstem. Domyślnie włączone.</string>
<string name="sentry_enable_dialog_title">Włączyć raporty awarii?</string>
<string name="sentry_enable_dialog_subtitle">Raporty awarii pomagają identyfikować problemy specyficzne dla urządzenia bez konieczności reprodukcji błędu z logami ADB.</string>
<string name="sentry_disable_dialog_title">Wyłączyć raporty awarii?</string>
<string name="sentry_disable_dialog_subtitle">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.</string>
<string name="sentry_help_title">Jak to pomaga</string>
<string name="sentry_help_body">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.</string>
<string name="sentry_sent_title">Wysyłane</string>
<string name="sentry_sent_body">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.</string>
<string name="sentry_not_sent_title">Nie wysyłane</string>
<string name="sentry_not_sent_body">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.</string>
<string name="sentry_keep_enabled">Pozostaw włączone</string>
<string name="sentry_turn_off">Wyłącz</string>
<string name="sentry_turn_on">Włącz</string>
<string name="downloads_open_directory">Otwórz folder pobranych</string>
<string name="downloads_open_directory_failed">Nie udało się otworzyć folderu pobranych</string>
<string name="settings_appearance_nav_bar_style">Pasek nawigacji</string>
<string name="settings_appearance_nav_bar_style_sheet_title">Styl paska nawigacji</string>
<string name="settings_nav_bar_style_adaptive">Adaptacyjny</string>
<string name="settings_nav_bar_style_expanded">Zawsze rozwinięty</string>
<string name="settings_nav_bar_style_compact">Zawsze kompaktowy</string>
<string name="settings_nav_bar_style_classic">Klasyczny</string>
<string name="settings_card_depth_title">Efekt głębi karty</string>
<string name="settings_card_depth_description">Dodaje podświetloną górną krawędź i delikatny połysk do kart graficznych, tworząc subtelne wrażenie głębi.</string>
<string name="settings_card_depth_enabled">Włącz efekt głębi</string>
<string name="settings_card_depth_edge">Blask krawędzi</string>
<string name="settings_card_depth_edge_subtle">Subtelny</string>
<string name="settings_card_depth_edge_balanced">Zrównoważony</string>
<string name="settings_card_depth_edge_bold">Wyrazisty</string>
<string name="settings_card_depth_sheen">Górny połysk</string>
<string name="settings_card_depth_sheen_off">Wył.</string>
<string name="settings_card_depth_sheen_soft">Miękki</string>
<string name="settings_card_depth_sheen_bright">Jasny</string>
<string name="settings_card_depth_apply_to">Zastosuj do</string>
<string name="settings_card_depth_fine_tune">Dostrojenie</string>
<string name="settings_card_depth_fine_tune_title">Dostrojenie głębi</string>
<string name="settings_card_depth_fine_tune_hint">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.</string>
<string name="settings_card_depth_pad_edge_axis">Blask krawędzi →</string>
<string name="settings_card_depth_pad_sheen_axis">↑ Połysk</string>
<string name="settings_card_depth_edge_value">Blask krawędzi</string>
<string name="settings_card_depth_sheen_value">Połysk</string>
<string name="settings_card_depth_edge_coverage">Pokrycie krawędzi</string>
<string name="settings_card_depth_coverage_top">Tylko góra</string>
<string name="settings_card_depth_coverage_half">Połowa</string>
<string name="settings_card_depth_coverage_full">Pełny kontur</string>
<string name="settings_card_depth_coverage_value">Pokrycie krawędzi</string>
<string name="settings_card_depth_preview_title">Tytuł odcinka</string>
<string name="settings_card_depth_preview_meta">S1 · E1 · 45 min</string>
<string name="settings_card_depth_surface_posters">Plakaty</string>
<string name="settings_card_depth_surface_continue_watching">Kontynuuj oglądanie</string>
<string name="settings_card_depth_surface_episodes">Karty odcinków</string>
<string name="settings_card_depth_surface_cast">Obsada</string>
<string name="settings_card_depth_surface_trailers">Zwiastuny</string>
<string name="settings_meta_background_mode">Tryb tła</string>
<string name="settings_meta_background_mode_description">Wybierz sposób wyświetlania grafiki za stronami metadanych.</string>
<string name="settings_meta_background_mode_normal">Normalny</string>
<string name="settings_meta_background_mode_normal_description">Użyj standardowego tła aplikacji.</string>
<string name="settings_meta_background_mode_cinematic">Kinowe</string>
<string name="settings_meta_background_mode_cinematic_description">Pokaż miękkie rozmyte tło za stroną.</string>
<string name="settings_meta_background_mode_dominant">Kolor dominujący</string>
<string name="settings_meta_background_mode_dominant_description">Dopasuj tło strony do głównego koloru z grafiki tła.</string>
<string name="community_donation_progress_title">Serwer i utrzymanie w tym miesiącu</string>
<string name="community_donation_progress_complete">Pokryte. Dodatkowe wsparcie trafia teraz na rozwój.</string>
<string name="community_donation_progress_remaining">Po 100% dodatkowe wsparcie trafia na rozwój.</string>
<string name="community_loading_donation_progress">Ładowanie postępu finansowania...</string>
<string name="settings_playback_external_player_send_skip_segments">Wysyłaj znaczniki intro i outro</string>
<string name="settings_playback_external_player_send_skip_segments_description">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ą.</string>
<string name="settings_playback_engine">Silnik odtwarzania</string>
<string name="settings_playback_engine_auto_description">Użyj najpierw ExoPlayera i przejdź na libmpv, jeśli odtwarzanie się nie powiedzie.</string>
<string name="settings_playback_engine_exoplayer_description">Użyj ExoPlayera i dekoderów Android Media3.</string>
<string name="settings_playback_engine_libmpv_description">Użyj libmpv do odtwarzania na Androidzie.</string>
<string name="settings_playback_libmpv_video_output">Renderer libmpv</string>
<string name="settings_playback_libmpv_video_output_dialog">Renderer libmpv</string>
<string name="settings_playback_libmpv_hardware_decoding">Dekodowanie sprzętowe libmpv</string>
<string name="settings_playback_libmpv_hardware_decoding_description">Używaj dekodowania sprzętowego mpv, gdy jest dostępne.</string>
<string name="settings_playback_libmpv_yuv420p">Kompatybilność YUV420P libmpv</string>
<string name="settings_playback_libmpv_yuv420p_description">Wymuś wyjście YUV420P dla urządzeń z problemami renderera lub kolorów.</string>
<string name="settings_playback_parental_guide">Ostrzeżenia dotyczące treści</string>
<string name="settings_playback_parental_guide_description">Pokaż nakładkę z informacjami o treści przy rozpoczęciu odtwarzania.</string>
<string name="streams_loading_skip_segments">Ładowanie segmentów do pominięcia…</string>
<string name="updates_debug_test_complete">Test banera zakończony</string>
<string name="updates_debug_test_description">Podgląd banera i symulowanego postępu pobierania.</string>
<string name="updates_debug_test_title">Testuj baner aktualizacji</string>
<string name="updates_no_release_notes">Nie podano informacji o wydaniu.</string>
<string name="collections_import_error_collection_duplicate_id">ID kolekcji „%1$s" jest użyte więcej niż raz.</string>
<string name="collections_import_error_folder_duplicate_id">ID folderu „%1$s" jest użyte więcej niż raz w „%2$s".</string>
<string name="person_detail_born">Urodzony</string>
<string name="person_detail_place_of_birth">Miejsce urodzenia</string>
<string name="person_detail_credits">Filmografia</string>
<string name="person_detail_biography">Biografia</string>
<string name="entity_browse_country">Kraj</string>
<string name="entity_browse_type">Typ</string>
<string name="entity_browse_catalogue">Katalog</string>
<string name="entity_browse_about">O</string>
<string name="player_external_loading_subtitles">Ładowanie napisów z dodatków...</string>
<string name="player_external_downloading_subtitles">Pobieranie napisów...</string>
<plurals name="entity_browse_title_count">
<item quantity="one">%d tytuł</item>
<item quantity="few">%d tytuły</item>
<item quantity="many">%d tytułów</item>
<item quantity="other">%d tytułów</item>
</plurals>
</resources>

View file

@ -567,6 +567,12 @@
<string name="settings_appearance_app_language">App Language</string>
<string name="settings_appearance_app_language_device">Device language</string>
<string name="settings_appearance_app_language_sheet_title">Choose Language</string>
<string name="settings_appearance_nav_bar_style">Navigation Bar</string>
<string name="settings_appearance_nav_bar_style_sheet_title">Navigation Bar Style</string>
<string name="settings_nav_bar_style_adaptive">Adaptive</string>
<string name="settings_nav_bar_style_expanded">Always Expanded</string>
<string name="settings_nav_bar_style_compact">Always Compact</string>
<string name="settings_nav_bar_style_classic">Classic</string>
<string name="settings_appearance_continue_watching_description">Settings for the Continue Watching section.</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.</string>

View file

@ -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,
)
}
}
}
}
}
}

View file

@ -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

View file

@ -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<NuvioNavBarScrollState?> { null }
@Composable
internal fun nuvioSafeBottomPadding(extra: Dp = 0.dp): Dp {
val navigationBarBottom = nuvioBottomNavigationBarInsets()

View file

@ -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,
)
}
},
)
}
}
}
}

View file

@ -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
}
}

View file

@ -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) },

View file

@ -20,6 +20,9 @@ object ThemeSettingsRepository {
private val _selectedAppLanguage = MutableStateFlow(AppLanguage.DEVICE)
val selectedAppLanguage: StateFlow<AppLanguage> = _selectedAppLanguage.asStateFlow()
private val _navBarStyle = MutableStateFlow(NavBarStyle.ADAPTIVE)
val navBarStyle: StateFlow<NavBarStyle> = _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 =

View file

@ -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)
}

View file

@ -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)
}
}