diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.android.kt
new file mode 100644
index 00000000..a638c7fa
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.android.kt
@@ -0,0 +1,9 @@
+package com.nuvio.app.core.ui
+
+internal actual fun isLiquidGlassNativeTabBarSupported(): Boolean = false
+
+internal actual fun publishLiquidGlassNativeTabBarEnabled(enabled: Boolean) = Unit
+
+internal actual fun publishNativeTabBarVisible(visible: Boolean) = Unit
+
+internal actual fun publishNativeSelectedTab(tabName: String) = Unit
diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.android.kt
index 8b1506f0..e082a536 100644
--- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.android.kt
+++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.android.kt
@@ -17,8 +17,13 @@ actual object ThemeSettingsStorage {
private const val preferencesName = "nuvio_theme_settings"
private const val selectedThemeKey = "selected_theme"
private const val amoledEnabledKey = "amoled_enabled"
+ private const val liquidGlassNativeTabBarEnabledKey = "liquid_glass_native_tab_bar_enabled"
private const val selectedAppLanguageKey = "selected_app_language"
- private val profileScopedSyncKeys = listOf(selectedThemeKey, amoledEnabledKey)
+ private val profileScopedSyncKeys = listOf(
+ selectedThemeKey,
+ amoledEnabledKey,
+ liquidGlassNativeTabBarEnabledKey,
+ )
private val globalSyncKeys = listOf(selectedAppLanguageKey)
private var preferences: SharedPreferences? = null
@@ -51,6 +56,19 @@ actual object ThemeSettingsStorage {
?.apply()
}
+ actual fun loadLiquidGlassNativeTabBarEnabled(): Boolean? =
+ preferences?.let { prefs ->
+ val key = ProfileScopedKey.of(liquidGlassNativeTabBarEnabledKey)
+ if (prefs.contains(key)) prefs.getBoolean(key, false) else null
+ }
+
+ actual fun saveLiquidGlassNativeTabBarEnabled(enabled: Boolean) {
+ preferences
+ ?.edit()
+ ?.putBoolean(ProfileScopedKey.of(liquidGlassNativeTabBarEnabledKey), enabled)
+ ?.apply()
+ }
+
actual fun loadSelectedAppLanguage(): String? {
val value = preferences?.getString(selectedAppLanguageKey, null)
if (value != null) return value
@@ -75,6 +93,7 @@ actual object ThemeSettingsStorage {
actual fun exportToSyncPayload(): JsonObject = buildJsonObject {
loadSelectedTheme()?.let { put(selectedThemeKey, encodeSyncString(it)) }
loadAmoledEnabled()?.let { put(amoledEnabledKey, encodeSyncBoolean(it)) }
+ loadLiquidGlassNativeTabBarEnabled()?.let { put(liquidGlassNativeTabBarEnabledKey, encodeSyncBoolean(it)) }
loadSelectedAppLanguage()?.let { put(selectedAppLanguageKey, encodeSyncString(it)) }
}
@@ -86,6 +105,7 @@ actual object ThemeSettingsStorage {
payload.decodeSyncString(selectedThemeKey)?.let(::saveSelectedTheme)
payload.decodeSyncBoolean(amoledEnabledKey)?.let(::saveAmoledEnabled)
+ payload.decodeSyncBoolean(liquidGlassNativeTabBarEnabledKey)?.let(::saveLiquidGlassNativeTabBarEnabled)
payload.decodeSyncString(selectedAppLanguageKey)?.let(::saveSelectedAppLanguage)
applySelectedAppLanguage(loadSelectedAppLanguage() ?: AppLanguage.ENGLISH.code)
}
diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index d69fdba4..42d4c850 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -449,6 +449,8 @@
App Language
Choose Language
Settings for the Continue Watching section.
+ Liquid Glass
+ Use the native iPhone tab bar on iOS 26 and later.
Tune card width and corner radius.
DISPLAY
HOME
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt
index eea60cd6..ab33b6d4 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt
@@ -39,6 +39,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
+import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
@@ -92,6 +93,10 @@ import com.nuvio.app.core.ui.NuvioToastController
import com.nuvio.app.core.ui.NuvioFloatingPrompt
import com.nuvio.app.core.ui.TraktListPickerDialog
import com.nuvio.app.core.ui.NuvioTheme
+import com.nuvio.app.core.ui.LocalNuvioBottomNavigationOverlayPadding
+import com.nuvio.app.core.ui.NativeNavigationTab
+import com.nuvio.app.core.ui.NativeTabBridge
+import com.nuvio.app.core.ui.isLiquidGlassNativeTabBarSupported
import com.nuvio.app.core.ui.localizedContinueWatchingSubtitle
import com.nuvio.app.features.auth.AuthScreen
import com.nuvio.app.features.addons.AddonRepository
@@ -260,6 +265,20 @@ enum class AppScreenTab {
Settings,
}
+private fun AppScreenTab.toNativeNavigationTab(): NativeNavigationTab = when (this) {
+ AppScreenTab.Home -> NativeNavigationTab.Home
+ AppScreenTab.Search -> NativeNavigationTab.Search
+ AppScreenTab.Library -> NativeNavigationTab.Library
+ AppScreenTab.Settings -> NativeNavigationTab.Settings
+}
+
+private fun NativeNavigationTab.toAppScreenTab(): AppScreenTab = when (this) {
+ NativeNavigationTab.Home -> AppScreenTab.Home
+ NativeNavigationTab.Search -> AppScreenTab.Search
+ NativeNavigationTab.Library -> AppScreenTab.Library
+ NativeNavigationTab.Settings -> AppScreenTab.Settings
+}
+
private enum class AppGateScreen {
Loading,
Auth,
@@ -466,6 +485,11 @@ private fun MainAppContent(
val hapticFeedback = LocalHapticFeedback.current
val coroutineScope = rememberCoroutineScope()
var selectedTab by rememberSaveable { mutableStateOf(AppScreenTab.Home) }
+ val nativeRequestedTab by remember { NativeTabBridge.requestedTab }.collectAsStateWithLifecycle()
+ val liquidGlassNativeTabBarEnabled by remember {
+ ThemeSettingsRepository.liquidGlassNativeTabBarEnabled
+ }.collectAsStateWithLifecycle()
+ val liquidGlassNativeTabBarSupported = remember { isLiquidGlassNativeTabBarSupported() }
var showExitConfirmation by rememberSaveable { mutableStateOf(false) }
var selectedPosterForActions by remember { mutableStateOf(null) }
var selectedContinueWatchingForActions by remember { mutableStateOf(null) }
@@ -515,6 +539,16 @@ private fun MainAppContent(
.sorted()
}
+ LaunchedEffect(nativeRequestedTab) {
+ if (liquidGlassNativeTabBarSupported && liquidGlassNativeTabBarEnabled) {
+ selectedTab = nativeRequestedTab.toAppScreenTab()
+ }
+ }
+
+ LaunchedEffect(selectedTab) {
+ NativeTabBridge.publishSelectedTab(selectedTab.toNativeNavigationTab())
+ }
+
LaunchedEffect(Unit) {
NetworkStatusRepository.ensureStarted()
EpisodeReleaseNotificationsRepository.refreshAsync()
@@ -886,6 +920,8 @@ private fun MainAppContent(
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val isTabletLayout = maxWidth >= 768.dp
+ val useNativeBottomTabs =
+ liquidGlassNativeTabBarSupported && liquidGlassNativeTabBarEnabled && initialHomeReady
val onProfileSelected: (NuvioProfile) -> Unit = { profile ->
profileSwitchLoading = true
selectedTab = AppScreenTab.Home
@@ -893,6 +929,13 @@ private fun MainAppContent(
com.nuvio.app.core.sync.SyncManager.pullAllForProfile(profile.profileIndex)
}
+ DisposableEffect(useNativeBottomTabs) {
+ NativeTabBridge.publishTabBarVisible(useNativeBottomTabs)
+ onDispose {
+ NativeTabBridge.publishTabBarVisible(false)
+ }
+ }
+
Scaffold(
modifier = Modifier
.fillMaxSize()
@@ -900,7 +943,7 @@ private fun MainAppContent(
containerColor = Color.Transparent,
contentWindowInsets = WindowInsets(0),
bottomBar = {
- if (!isTabletLayout) {
+ if (!isTabletLayout && !useNativeBottomTabs) {
NuvioNavigationBar {
NavItem(
selected = selectedTab == AppScreenTab.Home,
@@ -936,58 +979,62 @@ private fun MainAppContent(
},
) { innerPadding ->
Box(modifier = Modifier.fillMaxSize()) {
- AppTabHost(
- modifier = Modifier
- .fillMaxSize()
- .padding(innerPadding),
- selectedTab = selectedTab,
- onCatalogClick = onCatalogClick,
- onPosterClick = { meta ->
- navController.navigate(DetailRoute(type = meta.type, id = meta.id))
- },
- onPosterLongClick = { meta ->
- hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
- selectedPosterForActions = meta
- },
- onLibraryPosterClick = { item ->
- navController.navigate(DetailRoute(type = item.type, id = item.id))
- },
- onLibrarySectionViewAllClick = onLibrarySectionViewAllClick,
- onContinueWatchingClick = onContinueWatchingClick,
- onContinueWatchingLongPress = onContinueWatchingLongPress,
- onSwitchProfile = onSwitchProfile,
- onHomescreenSettingsClick = { navController.navigate(HomescreenSettingsRoute) },
- onMetaScreenSettingsClick = { navController.navigate(MetaScreenSettingsRoute) },
- onContinueWatchingSettingsClick = { navController.navigate(ContinueWatchingSettingsRoute) },
- onDownloadsSettingsClick = { navController.navigate(DownloadsSettingsRoute) },
- onAddonsSettingsClick = { navController.navigate(AddonsSettingsRoute) },
- onPluginsSettingsClick = {
- if (AppFeaturePolicy.pluginsEnabled) {
- navController.navigate(PluginsSettingsRoute)
- }
- },
- onAccountSettingsClick = { navController.navigate(AccountSettingsRoute) },
- onSupportersContributorsSettingsClick = {
- navController.navigate(SupportersContributorsSettingsRoute)
- },
- onCheckForUpdatesClick = if (AppFeaturePolicy.inAppUpdaterEnabled) {
- {
- appUpdaterController.checkForUpdates(
- force = true,
- showNoUpdateFeedback = true,
- )
- }
- } else {
- null
- },
- onCollectionsSettingsClick = { navController.navigate(CollectionsRoute) },
- onFolderClick = { collectionId, folderId ->
- navController.navigate(FolderDetailRoute(collectionId = collectionId, folderId = folderId))
- },
- onInitialHomeContentRendered = { initialHomeReady = true },
- )
+ CompositionLocalProvider(
+ LocalNuvioBottomNavigationOverlayPadding provides if (useNativeBottomTabs) 49.dp else 0.dp,
+ ) {
+ AppTabHost(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(innerPadding),
+ selectedTab = selectedTab,
+ onCatalogClick = onCatalogClick,
+ onPosterClick = { meta ->
+ navController.navigate(DetailRoute(type = meta.type, id = meta.id))
+ },
+ onPosterLongClick = { meta ->
+ hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
+ selectedPosterForActions = meta
+ },
+ onLibraryPosterClick = { item ->
+ navController.navigate(DetailRoute(type = item.type, id = item.id))
+ },
+ onLibrarySectionViewAllClick = onLibrarySectionViewAllClick,
+ onContinueWatchingClick = onContinueWatchingClick,
+ onContinueWatchingLongPress = onContinueWatchingLongPress,
+ onSwitchProfile = onSwitchProfile,
+ onHomescreenSettingsClick = { navController.navigate(HomescreenSettingsRoute) },
+ onMetaScreenSettingsClick = { navController.navigate(MetaScreenSettingsRoute) },
+ onContinueWatchingSettingsClick = { navController.navigate(ContinueWatchingSettingsRoute) },
+ onDownloadsSettingsClick = { navController.navigate(DownloadsSettingsRoute) },
+ onAddonsSettingsClick = { navController.navigate(AddonsSettingsRoute) },
+ onPluginsSettingsClick = {
+ if (AppFeaturePolicy.pluginsEnabled) {
+ navController.navigate(PluginsSettingsRoute)
+ }
+ },
+ onAccountSettingsClick = { navController.navigate(AccountSettingsRoute) },
+ onSupportersContributorsSettingsClick = {
+ navController.navigate(SupportersContributorsSettingsRoute)
+ },
+ onCheckForUpdatesClick = if (AppFeaturePolicy.inAppUpdaterEnabled) {
+ {
+ appUpdaterController.checkForUpdates(
+ force = true,
+ showNoUpdateFeedback = true,
+ )
+ }
+ } else {
+ null
+ },
+ onCollectionsSettingsClick = { navController.navigate(CollectionsRoute) },
+ onFolderClick = { collectionId, folderId ->
+ navController.navigate(FolderDetailRoute(collectionId = collectionId, folderId = folderId))
+ },
+ onInitialHomeContentRendered = { initialHomeReady = true },
+ )
+ }
- if (isTabletLayout) {
+ if (isTabletLayout && !useNativeBottomTabs) {
TabletFloatingTopBar(
selectedTab = selectedTab,
onTabSelected = { selectedTab = it },
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt
index cdbd477a..9dd7a999 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt
@@ -152,6 +152,7 @@ object ProfileSettingsSync {
val signatureFlows = listOf(
ThemeSettingsRepository.selectedTheme.map { "theme" },
ThemeSettingsRepository.amoledEnabled.map { "amoled" },
+ ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.map { "liquid_glass_tab_bar" },
PosterCardStyleRepository.uiState.map { "poster_card_style" },
PlayerSettingsRepository.uiState.map { "player" },
TmdbSettingsRepository.uiState.map { "tmdb" },
@@ -265,6 +266,7 @@ object ProfileSettingsSync {
private fun currentObservedStateSignature(): String = listOf(
"theme=${ThemeSettingsRepository.selectedTheme.value.name}",
"amoled=${ThemeSettingsRepository.amoledEnabled.value}",
+ "liquid_glass_tab_bar=${ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.value}",
"poster_card_style=${PosterCardStyleRepository.uiState.value}",
"player=${PlayerSettingsRepository.uiState.value}",
"tmdb=${TmdbSettingsRepository.uiState.value}",
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.kt
new file mode 100644
index 00000000..b9eefda2
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.kt
@@ -0,0 +1,51 @@
+package com.nuvio.app.core.ui
+
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+internal enum class NativeNavigationTab {
+ Home,
+ Search,
+ Library,
+ Settings,
+ ;
+
+ companion object {
+ fun fromName(name: String): NativeNavigationTab =
+ entries.firstOrNull { it.name.equals(name, ignoreCase = true) } ?: Home
+ }
+}
+
+internal object NativeTabBridge {
+ private val _requestedTab = MutableStateFlow(NativeNavigationTab.Home)
+ val requestedTab: StateFlow = _requestedTab.asStateFlow()
+
+ fun requestTab(tabName: String) {
+ _requestedTab.value = NativeNavigationTab.fromName(tabName)
+ }
+
+ fun publishSelectedTab(tab: NativeNavigationTab) {
+ publishNativeSelectedTab(tab.name)
+ }
+
+ fun publishTabBarVisible(visible: Boolean) {
+ publishNativeTabBarVisible(visible && isLiquidGlassNativeTabBarSupported())
+ }
+
+ fun publishLiquidGlassEnabled(enabled: Boolean) {
+ publishLiquidGlassNativeTabBarEnabled(enabled && isLiquidGlassNativeTabBarSupported())
+ }
+}
+
+fun nativeTabSelect(tabName: String) {
+ NativeTabBridge.requestTab(tabName)
+}
+
+internal expect fun isLiquidGlassNativeTabBarSupported(): Boolean
+
+internal expect fun publishLiquidGlassNativeTabBarEnabled(enabled: Boolean)
+
+internal expect fun publishNativeTabBarVisible(visible: Boolean)
+
+internal expect fun publishNativeSelectedTab(tabName: String)
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioPlatformInsets.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioPlatformInsets.kt
index b6ea9a37..2fc73a4f 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioPlatformInsets.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioPlatformInsets.kt
@@ -3,6 +3,7 @@ package com.nuvio.app.core.ui
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@@ -12,10 +13,14 @@ internal expect val nuvioBottomNavigationExtraVerticalPadding: Dp
@Composable
internal expect fun nuvioBottomNavigationBarInsets(): WindowInsets
+internal val LocalNuvioBottomNavigationOverlayPadding = staticCompositionLocalOf { 0.dp }
+
@Composable
internal fun nuvioSafeBottomPadding(extra: Dp = 0.dp): Dp {
val navigationBarBottom = nuvioBottomNavigationBarInsets()
.asPaddingValues()
.calculateBottomPadding()
- return navigationBarBottom.coerceAtLeast(nuvioPlatformExtraBottomPadding) + extra
+ return navigationBarBottom.coerceAtLeast(nuvioPlatformExtraBottomPadding) +
+ LocalNuvioBottomNavigationOverlayPadding.current +
+ extra
}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt
index b7fa9134..87879839 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt
@@ -16,8 +16,10 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.nuvio.app.core.network.NetworkCondition
import com.nuvio.app.core.network.NetworkStatusRepository
+import com.nuvio.app.core.ui.LocalNuvioBottomNavigationOverlayPadding
import com.nuvio.app.core.ui.NuvioScreen
import com.nuvio.app.core.ui.NuvioNetworkOfflineCard
+import com.nuvio.app.core.ui.nuvioSafeBottomPadding
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.details.nextReleasedEpisodeAfter
@@ -405,12 +407,19 @@ fun HomeScreen(
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
val homeSectionPadding = homeSectionHorizontalPaddingForWidth(maxWidth.value)
val continueWatchingLayout = rememberContinueWatchingLayout(maxWidth.value)
+ val nativeBottomNavigationOverlayHeight =
+ if (LocalNuvioBottomNavigationOverlayPadding.current > 0.dp) {
+ nuvioSafeBottomPadding()
+ } else {
+ 0.dp
+ }
val mobileHeroBelowSectionHeightHint = remember(
maxWidth.value,
continueWatchingPreferences.isVisible,
continueWatchingPreferences.style,
continueWatchingItems.isNotEmpty(),
continueWatchingLayout,
+ nativeBottomNavigationOverlayHeight,
) {
heroMobileBelowSectionHeightHint(
maxWidthDp = maxWidth.value,
@@ -418,6 +427,7 @@ fun HomeScreen(
hasContinueWatchingItems = continueWatchingItems.isNotEmpty(),
continueWatchingStyle = continueWatchingPreferences.style,
continueWatchingLayout = continueWatchingLayout,
+ bottomNavigationOverlayHeight = nativeBottomNavigationOverlayHeight,
)
}
@@ -605,14 +615,16 @@ private fun heroMobileBelowSectionHeightHint(
hasContinueWatchingItems: Boolean,
continueWatchingStyle: ContinueWatchingSectionStyle,
continueWatchingLayout: ContinueWatchingLayout,
+ bottomNavigationOverlayHeight: Dp,
): Dp? {
if (maxWidthDp >= 600f || !continueWatchingVisible || !hasContinueWatchingItems) return null
- return when (continueWatchingStyle) {
+ val sectionHeight = when (continueWatchingStyle) {
ContinueWatchingSectionStyle.Wide -> continueWatchingLayout.wideCardHeight + 56.dp
ContinueWatchingSectionStyle.Poster ->
continueWatchingLayout.posterCardHeight + continueWatchingLayout.posterTitleBlockHeight + 70.dp
}
+ return sectionHeight + bottomNavigationOverlayHeight
}
internal fun buildHomeContinueWatchingItems(
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppearanceSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppearanceSettingsPage.kt
index f697b48d..bc312982 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppearanceSettingsPage.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppearanceSettingsPage.kt
@@ -54,6 +54,8 @@ import nuvio.composeapp.generated.resources.settings_appearance_app_language_she
import nuvio.composeapp.generated.resources.settings_appearance_amoled_black
import nuvio.composeapp.generated.resources.settings_appearance_amoled_description
import nuvio.composeapp.generated.resources.settings_appearance_continue_watching_description
+import nuvio.composeapp.generated.resources.settings_appearance_liquid_glass
+import nuvio.composeapp.generated.resources.settings_appearance_liquid_glass_description
import nuvio.composeapp.generated.resources.settings_appearance_poster_customization_description
import nuvio.composeapp.generated.resources.settings_appearance_section_display
import nuvio.composeapp.generated.resources.settings_appearance_section_home
@@ -70,6 +72,9 @@ internal fun LazyListScope.appearanceSettingsContent(
onThemeSelected: (AppTheme) -> Unit,
amoledEnabled: Boolean,
onAmoledToggle: (Boolean) -> Unit,
+ liquidGlassNativeTabBarSupported: Boolean,
+ liquidGlassNativeTabBarEnabled: Boolean,
+ onLiquidGlassNativeTabBarToggle: (Boolean) -> Unit,
selectedAppLanguage: AppLanguage,
onAppLanguageSelected: (AppLanguage) -> Unit,
onContinueWatchingClick: () -> Unit,
@@ -118,6 +123,16 @@ internal fun LazyListScope.appearanceSettingsContent(
isTablet = isTablet,
onCheckedChange = onAmoledToggle,
)
+ if (liquidGlassNativeTabBarSupported) {
+ SettingsGroupDivider(isTablet = isTablet)
+ SettingsSwitchRow(
+ title = stringResource(Res.string.settings_appearance_liquid_glass),
+ description = stringResource(Res.string.settings_appearance_liquid_glass_description),
+ checked = liquidGlassNativeTabBarEnabled,
+ isTablet = isTablet,
+ onCheckedChange = onLiquidGlassNativeTabBarToggle,
+ )
+ }
SettingsGroupDivider(isTablet = isTablet)
SettingsNavigationRow(
title = stringResource(Res.string.settings_appearance_app_language),
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt
index dd9ae84b..b625c9dc 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt
@@ -38,9 +38,11 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.max
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.nuvio.app.core.ui.AppTheme
+import com.nuvio.app.core.ui.LocalNuvioBottomNavigationOverlayPadding
import com.nuvio.app.core.ui.NuvioScreen
import com.nuvio.app.core.ui.NuvioScreenHeader
import com.nuvio.app.core.ui.PlatformBackHandler
+import com.nuvio.app.core.ui.isLiquidGlassNativeTabBarSupported
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.details.MetaScreenSettingsRepository
import com.nuvio.app.features.details.MetaScreenSettingsUiState
@@ -94,6 +96,10 @@ fun SettingsScreen(
ThemeSettingsRepository.selectedTheme
}.collectAsStateWithLifecycle()
val amoledEnabled by remember { ThemeSettingsRepository.amoledEnabled }.collectAsStateWithLifecycle()
+ val liquidGlassNativeTabBarEnabled by remember {
+ ThemeSettingsRepository.liquidGlassNativeTabBarEnabled
+ }.collectAsStateWithLifecycle()
+ val liquidGlassNativeTabBarSupported = remember { isLiquidGlassNativeTabBarSupported() }
val selectedAppLanguage by remember { ThemeSettingsRepository.selectedAppLanguage }.collectAsStateWithLifecycle()
val tmdbSettings by remember {
TmdbSettingsRepository.ensureLoaded()
@@ -191,6 +197,9 @@ fun SettingsScreen(
onThemeSelected = ThemeSettingsRepository::setTheme,
amoledEnabled = amoledEnabled,
onAmoledToggle = ThemeSettingsRepository::setAmoled,
+ liquidGlassNativeTabBarSupported = liquidGlassNativeTabBarSupported,
+ liquidGlassNativeTabBarEnabled = liquidGlassNativeTabBarEnabled,
+ onLiquidGlassNativeTabBarToggle = ThemeSettingsRepository::setLiquidGlassNativeTabBar,
selectedAppLanguage = selectedAppLanguage,
onAppLanguageSelected = ThemeSettingsRepository::setAppLanguage,
episodeReleaseNotificationsUiState = episodeReleaseNotificationsUiState,
@@ -233,6 +242,9 @@ fun SettingsScreen(
onThemeSelected = ThemeSettingsRepository::setTheme,
amoledEnabled = amoledEnabled,
onAmoledToggle = ThemeSettingsRepository::setAmoled,
+ liquidGlassNativeTabBarSupported = liquidGlassNativeTabBarSupported,
+ liquidGlassNativeTabBarEnabled = liquidGlassNativeTabBarEnabled,
+ onLiquidGlassNativeTabBarToggle = ThemeSettingsRepository::setLiquidGlassNativeTabBar,
selectedAppLanguage = selectedAppLanguage,
onAppLanguageSelected = ThemeSettingsRepository::setAppLanguage,
episodeReleaseNotificationsUiState = episodeReleaseNotificationsUiState,
@@ -285,6 +297,9 @@ private fun MobileSettingsScreen(
onThemeSelected: (AppTheme) -> Unit,
amoledEnabled: Boolean,
onAmoledToggle: (Boolean) -> Unit,
+ liquidGlassNativeTabBarSupported: Boolean,
+ liquidGlassNativeTabBarEnabled: Boolean,
+ onLiquidGlassNativeTabBarToggle: (Boolean) -> Unit,
selectedAppLanguage: AppLanguage,
onAppLanguageSelected: (AppLanguage) -> Unit,
episodeReleaseNotificationsUiState: EpisodeReleaseNotificationsUiState,
@@ -366,6 +381,9 @@ private fun MobileSettingsScreen(
onThemeSelected = onThemeSelected,
amoledEnabled = amoledEnabled,
onAmoledToggle = onAmoledToggle,
+ liquidGlassNativeTabBarSupported = liquidGlassNativeTabBarSupported,
+ liquidGlassNativeTabBarEnabled = liquidGlassNativeTabBarEnabled,
+ onLiquidGlassNativeTabBarToggle = onLiquidGlassNativeTabBarToggle,
selectedAppLanguage = selectedAppLanguage,
onAppLanguageSelected = onAppLanguageSelected,
onContinueWatchingClick = onContinueWatchingClick,
@@ -457,6 +475,9 @@ private fun TabletSettingsScreen(
onThemeSelected: (AppTheme) -> Unit,
amoledEnabled: Boolean,
onAmoledToggle: (Boolean) -> Unit,
+ liquidGlassNativeTabBarSupported: Boolean,
+ liquidGlassNativeTabBarEnabled: Boolean,
+ onLiquidGlassNativeTabBarToggle: (Boolean) -> Unit,
selectedAppLanguage: AppLanguage,
onAppLanguageSelected: (AppLanguage) -> Unit,
episodeReleaseNotificationsUiState: EpisodeReleaseNotificationsUiState,
@@ -539,6 +560,7 @@ private fun TabletSettingsScreen(
saveableStateHolder.SaveableStateProvider(page.name) {
val listState = rememberLazyListState()
+ val bottomOverlayPadding = LocalNuvioBottomNavigationOverlayPadding.current
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
@@ -546,7 +568,7 @@ private fun TabletSettingsScreen(
start = 40.dp,
top = topOffset,
end = 40.dp,
- bottom = 40.dp,
+ bottom = 40.dp + bottomOverlayPadding,
),
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
@@ -609,6 +631,9 @@ private fun TabletSettingsScreen(
onThemeSelected = onThemeSelected,
amoledEnabled = amoledEnabled,
onAmoledToggle = onAmoledToggle,
+ liquidGlassNativeTabBarSupported = liquidGlassNativeTabBarSupported,
+ liquidGlassNativeTabBarEnabled = liquidGlassNativeTabBarEnabled,
+ onLiquidGlassNativeTabBarToggle = onLiquidGlassNativeTabBarToggle,
selectedAppLanguage = selectedAppLanguage,
onAppLanguageSelected = onAppLanguageSelected,
onContinueWatchingClick = { openInlinePage(SettingsPage.ContinueWatching) },
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsRepository.kt
index 863dd04f..41d53fc6 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsRepository.kt
@@ -1,6 +1,7 @@
package com.nuvio.app.features.settings
import com.nuvio.app.core.ui.AppTheme
+import com.nuvio.app.core.ui.NativeTabBridge
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -12,6 +13,9 @@ object ThemeSettingsRepository {
private val _amoledEnabled = MutableStateFlow(false)
val amoledEnabled: StateFlow = _amoledEnabled.asStateFlow()
+ private val _liquidGlassNativeTabBarEnabled = MutableStateFlow(false)
+ val liquidGlassNativeTabBarEnabled: StateFlow = _liquidGlassNativeTabBarEnabled.asStateFlow()
+
private val _selectedAppLanguage = MutableStateFlow(AppLanguage.ENGLISH)
val selectedAppLanguage: StateFlow = _selectedAppLanguage.asStateFlow()
@@ -30,6 +34,8 @@ object ThemeSettingsRepository {
hasLoaded = false
_selectedTheme.value = AppTheme.WHITE
_amoledEnabled.value = false
+ _liquidGlassNativeTabBarEnabled.value = false
+ NativeTabBridge.publishLiquidGlassEnabled(false)
_selectedAppLanguage.value = AppLanguage.ENGLISH
}
@@ -47,6 +53,9 @@ object ThemeSettingsRepository {
}
_selectedTheme.value = theme
_amoledEnabled.value = ThemeSettingsStorage.loadAmoledEnabled() ?: false
+ val liquidGlassEnabled = ThemeSettingsStorage.loadLiquidGlassNativeTabBarEnabled() ?: false
+ _liquidGlassNativeTabBarEnabled.value = liquidGlassEnabled
+ NativeTabBridge.publishLiquidGlassEnabled(liquidGlassEnabled)
val appLanguage = AppLanguage.fromCode(ThemeSettingsStorage.loadSelectedAppLanguage())
ThemeSettingsStorage.applySelectedAppLanguage(appLanguage.code)
_selectedAppLanguage.value = appLanguage
@@ -66,6 +75,14 @@ object ThemeSettingsRepository {
ThemeSettingsStorage.saveAmoledEnabled(enabled)
}
+ fun setLiquidGlassNativeTabBar(enabled: Boolean) {
+ ensureLoaded()
+ if (_liquidGlassNativeTabBarEnabled.value == enabled) return
+ _liquidGlassNativeTabBarEnabled.value = enabled
+ ThemeSettingsStorage.saveLiquidGlassNativeTabBarEnabled(enabled)
+ NativeTabBridge.publishLiquidGlassEnabled(enabled)
+ }
+
fun setAppLanguage(language: AppLanguage) {
ensureLoaded()
if (_selectedAppLanguage.value == language) return
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.kt
index dc39dee5..2a788baf 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.kt
@@ -7,6 +7,8 @@ internal expect object ThemeSettingsStorage {
fun saveSelectedTheme(themeName: String)
fun loadAmoledEnabled(): Boolean?
fun saveAmoledEnabled(enabled: Boolean)
+ fun loadLiquidGlassNativeTabBarEnabled(): Boolean?
+ fun saveLiquidGlassNativeTabBarEnabled(enabled: Boolean)
fun loadSelectedAppLanguage(): String?
fun saveSelectedAppLanguage(languageCode: String)
fun applySelectedAppLanguage(languageCode: String)
diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.ios.kt
new file mode 100644
index 00000000..7e23415c
--- /dev/null
+++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.ios.kt
@@ -0,0 +1,38 @@
+package com.nuvio.app.core.ui
+
+import platform.Foundation.NSNotificationCenter
+import platform.Foundation.NSUserDefaults
+import platform.UIKit.UIDevice
+import platform.UIKit.UIUserInterfaceIdiomPhone
+
+private const val liquidGlassNativeTabBarEnabledKey = "NuvioLiquidGlassNativeTabBarEnabled"
+private const val nativeTabBarVisibleKey = "NuvioNativeTabBarVisible"
+private const val nativeSelectedTabKey = "NuvioNativeSelectedTab"
+private const val nativeTabChromeDidChangeNotification = "NuvioNativeTabChromeDidChange"
+
+internal actual fun isLiquidGlassNativeTabBarSupported(): Boolean {
+ return UIDevice.currentDevice.userInterfaceIdiom == UIUserInterfaceIdiomPhone &&
+ (UIDevice.currentDevice.systemVersion.substringBefore(".").toIntOrNull() ?: 0) >= 26
+}
+
+internal actual fun publishLiquidGlassNativeTabBarEnabled(enabled: Boolean) {
+ publishBool(liquidGlassNativeTabBarEnabledKey, enabled)
+}
+
+internal actual fun publishNativeTabBarVisible(visible: Boolean) {
+ publishBool(nativeTabBarVisibleKey, visible)
+}
+
+internal actual fun publishNativeSelectedTab(tabName: String) {
+ NSUserDefaults.standardUserDefaults.setObject(tabName, forKey = nativeSelectedTabKey)
+ notifyNativeTabChromeChanged()
+}
+
+private fun publishBool(key: String, value: Boolean) {
+ NSUserDefaults.standardUserDefaults.setBool(value, forKey = key)
+ notifyNativeTabChromeChanged()
+}
+
+private fun notifyNativeTabChromeChanged() {
+ NSNotificationCenter.defaultCenter.postNotificationName(nativeTabChromeDidChangeNotification, null)
+}
diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.ios.kt
index c878b4a8..f66f8b8c 100644
--- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.ios.kt
+++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.ios.kt
@@ -13,8 +13,13 @@ import platform.Foundation.NSUserDefaults
actual object ThemeSettingsStorage {
private const val selectedThemeKey = "selected_theme"
private const val amoledEnabledKey = "amoled_enabled"
+ private const val liquidGlassNativeTabBarEnabledKey = "liquid_glass_native_tab_bar_enabled"
private const val selectedAppLanguageKey = "selected_app_language"
- private val profileScopedSyncKeys = listOf(selectedThemeKey, amoledEnabledKey)
+ private val profileScopedSyncKeys = listOf(
+ selectedThemeKey,
+ amoledEnabledKey,
+ liquidGlassNativeTabBarEnabledKey,
+ )
private val globalSyncKeys = listOf(selectedAppLanguageKey)
actual fun loadSelectedTheme(): String? =
@@ -38,6 +43,23 @@ actual object ThemeSettingsStorage {
NSUserDefaults.standardUserDefaults.setBool(enabled, forKey = ProfileScopedKey.of(amoledEnabledKey))
}
+ actual fun loadLiquidGlassNativeTabBarEnabled(): Boolean? {
+ val defaults = NSUserDefaults.standardUserDefaults
+ val key = ProfileScopedKey.of(liquidGlassNativeTabBarEnabledKey)
+ return if (defaults.objectForKey(key) != null) {
+ defaults.boolForKey(key)
+ } else {
+ null
+ }
+ }
+
+ actual fun saveLiquidGlassNativeTabBarEnabled(enabled: Boolean) {
+ NSUserDefaults.standardUserDefaults.setBool(
+ enabled,
+ forKey = ProfileScopedKey.of(liquidGlassNativeTabBarEnabledKey),
+ )
+ }
+
actual fun loadSelectedAppLanguage(): String? {
val value = NSUserDefaults.standardUserDefaults.stringForKey(selectedAppLanguageKey)
if (value != null) return value
@@ -65,6 +87,7 @@ actual object ThemeSettingsStorage {
actual fun exportToSyncPayload(): JsonObject = buildJsonObject {
loadSelectedTheme()?.let { put(selectedThemeKey, encodeSyncString(it)) }
loadAmoledEnabled()?.let { put(amoledEnabledKey, encodeSyncBoolean(it)) }
+ loadLiquidGlassNativeTabBarEnabled()?.let { put(liquidGlassNativeTabBarEnabledKey, encodeSyncBoolean(it)) }
loadSelectedAppLanguage()?.let { put(selectedAppLanguageKey, encodeSyncString(it)) }
}
@@ -78,6 +101,7 @@ actual object ThemeSettingsStorage {
payload.decodeSyncString(selectedThemeKey)?.let(::saveSelectedTheme)
payload.decodeSyncBoolean(amoledEnabledKey)?.let(::saveAmoledEnabled)
+ payload.decodeSyncBoolean(liquidGlassNativeTabBarEnabledKey)?.let(::saveLiquidGlassNativeTabBarEnabled)
payload.decodeSyncString(selectedAppLanguageKey)?.let(::saveSelectedAppLanguage)
applySelectedAppLanguage(loadSelectedAppLanguage() ?: AppLanguage.ENGLISH.code)
}
diff --git a/iosApp/Configuration/Version.xcconfig b/iosApp/Configuration/Version.xcconfig
index a772ced9..965f9e75 100644
--- a/iosApp/Configuration/Version.xcconfig
+++ b/iosApp/Configuration/Version.xcconfig
@@ -1,3 +1,3 @@
CURRENT_PROJECT_VERSION=54
-MARKETING_VERSION=0.1.15
+MARKETING_VERSION=0.1.0
diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift
index 8b736eb9..a4268fa7 100644
--- a/iosApp/iosApp/ContentView.swift
+++ b/iosApp/iosApp/ContentView.swift
@@ -2,8 +2,57 @@ import UIKit
import SwiftUI
import ComposeApp
-final class RootComposeViewController: UIViewController {
+final class RootComposeViewController: UIViewController, UITabBarDelegate {
+ private enum NativeTab: String, CaseIterable {
+ case home = "Home"
+ case search = "Search"
+ case library = "Library"
+ case settings = "Settings"
+
+ var tag: Int {
+ switch self {
+ case .home: return 0
+ case .search: return 1
+ case .library: return 2
+ case .settings: return 3
+ }
+ }
+
+ var title: String {
+ switch self {
+ case .home: return "Home"
+ case .search: return "Search"
+ case .library: return "Library"
+ case .settings: return "Profile"
+ }
+ }
+
+ var systemImageName: String {
+ switch self {
+ case .home: return "house"
+ case .search: return "magnifyingglass"
+ case .library: return "books.vertical"
+ case .settings: return "person.crop.circle"
+ }
+ }
+
+ init?(tag: Int) {
+ guard let tab = Self.allCases.first(where: { $0.tag == tag }) else { return nil }
+ self = tab
+ }
+ }
+
+ private static let liquidGlassEnabledKey = "NuvioLiquidGlassNativeTabBarEnabled"
+ private static let nativeTabBarVisibleKey = "NuvioNativeTabBarVisible"
+ private static let nativeSelectedTabKey = "NuvioNativeSelectedTab"
+ private static let nativeTabChromeDidChangeNotification = Notification.Name("NuvioNativeTabChromeDidChange")
+
private let contentController: UIViewController
+ private let tabBar = UITabBar()
+ private var contentBottomToViewBottom: NSLayoutConstraint?
+ private var tabBarHeightConstraint: NSLayoutConstraint?
+ private var userDefaultsObserver: NSObjectProtocol?
+ private var tabChromeObserver: NSObjectProtocol?
init(contentController: UIViewController) {
self.contentController = contentController
@@ -20,17 +69,44 @@ final class RootComposeViewController: UIViewController {
view.backgroundColor = .black
contentController.view.backgroundColor = .black
+ UserDefaults.standard.set(false, forKey: Self.nativeTabBarVisibleKey)
addChild(contentController)
view.addSubview(contentController.view)
contentController.view.translatesAutoresizingMaskIntoConstraints = false
+ let bottomToViewBottom = contentController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor)
+ self.contentBottomToViewBottom = bottomToViewBottom
NSLayoutConstraint.activate([
contentController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
contentController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
contentController.view.topAnchor.constraint(equalTo: view.topAnchor),
- contentController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
+ bottomToViewBottom,
])
contentController.didMove(toParent: self)
+
+ configureNativeTabBar()
+ installNativeTabObservers()
+ syncNativeTabChrome(animated: false)
+ }
+
+ deinit {
+ if let userDefaultsObserver {
+ NotificationCenter.default.removeObserver(userDefaultsObserver)
+ }
+ if let tabChromeObserver {
+ NotificationCenter.default.removeObserver(tabChromeObserver)
+ }
+ }
+
+ override func viewSafeAreaInsetsDidChange() {
+ super.viewSafeAreaInsetsDidChange()
+ updateTabBarHeight()
+ }
+
+ func tabBar(_ tabBar: UITabBar, didSelect item: UITabBarItem) {
+ guard let tab = NativeTab(tag: item.tag) else { return }
+ UserDefaults.standard.set(tab.rawValue, forKey: Self.nativeSelectedTabKey)
+ NativeTabBridgeKt.nativeTabSelect(tabName: tab.rawValue)
}
override var childForHomeIndicatorAutoHidden: UIViewController? {
@@ -88,6 +164,107 @@ final class RootComposeViewController: UIViewController {
return nil
}
+
+ private var nativeTabsSupported: Bool {
+ UIDevice.current.userInterfaceIdiom == .phone &&
+ ProcessInfo.processInfo.operatingSystemVersion.majorVersion >= 26
+ }
+
+ private var shouldShowNativeTabBar: Bool {
+ nativeTabsSupported &&
+ UserDefaults.standard.bool(forKey: Self.liquidGlassEnabledKey) &&
+ UserDefaults.standard.bool(forKey: Self.nativeTabBarVisibleKey)
+ }
+
+ private func configureNativeTabBar() {
+ tabBar.delegate = self
+ tabBar.translatesAutoresizingMaskIntoConstraints = false
+ tabBar.items = NativeTab.allCases.map { tab in
+ UITabBarItem(
+ title: tab.title,
+ image: UIImage(systemName: tab.systemImageName),
+ tag: tab.tag
+ )
+ }
+ tabBar.selectedItem = tabBar.items?.first
+ tabBar.alpha = 0
+ tabBar.isHidden = true
+
+ view.addSubview(tabBar)
+ let heightConstraint = tabBar.heightAnchor.constraint(equalToConstant: tabBarHeight)
+ tabBarHeightConstraint = heightConstraint
+ NSLayoutConstraint.activate([
+ tabBar.leadingAnchor.constraint(equalTo: view.leadingAnchor),
+ tabBar.trailingAnchor.constraint(equalTo: view.trailingAnchor),
+ tabBar.bottomAnchor.constraint(equalTo: view.bottomAnchor),
+ heightConstraint,
+ ])
+ }
+
+ private func installNativeTabObservers() {
+ userDefaultsObserver = NotificationCenter.default.addObserver(
+ forName: UserDefaults.didChangeNotification,
+ object: nil,
+ queue: .main
+ ) { [weak self] _ in
+ self?.syncNativeTabChrome(animated: true)
+ }
+
+ tabChromeObserver = NotificationCenter.default.addObserver(
+ forName: Self.nativeTabChromeDidChangeNotification,
+ object: nil,
+ queue: .main
+ ) { [weak self] _ in
+ self?.syncNativeTabChrome(animated: true)
+ }
+ }
+
+ private var tabBarHeight: CGFloat {
+ 49 + view.safeAreaInsets.bottom
+ }
+
+ private func updateTabBarHeight() {
+ tabBarHeightConstraint?.constant = tabBarHeight
+ }
+
+ private func syncNativeTabChrome(animated: Bool) {
+ updateTabBarHeight()
+ syncSelectedNativeTab()
+
+ let visible = shouldShowNativeTabBar
+ contentBottomToViewBottom?.isActive = true
+ if visible {
+ tabBar.isHidden = false
+ }
+
+ let changes = {
+ self.tabBar.alpha = visible ? 1 : 0
+ self.view.layoutIfNeeded()
+ }
+
+ let completion: (Bool) -> Void = { _ in
+ self.tabBar.isHidden = !visible
+ }
+
+ if animated && view.window != nil {
+ UIView.animate(
+ withDuration: 0.22,
+ delay: 0,
+ options: [.beginFromCurrentState, .curveEaseInOut],
+ animations: changes,
+ completion: completion
+ )
+ } else {
+ changes()
+ completion(true)
+ }
+ }
+
+ private func syncSelectedNativeTab() {
+ let rawValue = UserDefaults.standard.string(forKey: Self.nativeSelectedTabKey) ?? NativeTab.home.rawValue
+ let selectedTab = NativeTab(rawValue: rawValue) ?? .home
+ tabBar.selectedItem = tabBar.items?.first(where: { $0.tag == selectedTab.tag })
+ }
}
struct ComposeView: UIViewControllerRepresentable {