mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-07-26 22:42:17 +00:00
Merge pull request #1562 from skoruppa/modern-menu
modern floating pill navigation bar with scroll-responsive labels
This commit is contained in:
commit
77049f4e00
12 changed files with 689 additions and 18 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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) },
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue