From 8b2a635174447b754f7e8f0526ced22ff8820a7a Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 6 May 2026 19:20:32 +0530 Subject: [PATCH] feat(ios): ios native navbar --- .../app/core/ui/NativeTabBridge.android.kt | 9 + .../settings/ThemeSettingsStorage.android.kt | 22 ++- .../composeResources/values/strings.xml | 2 + .../commonMain/kotlin/com/nuvio/app/App.kt | 151 ++++++++++----- .../app/core/sync/ProfileSettingsSync.kt | 2 + .../com/nuvio/app/core/ui/NativeTabBridge.kt | 51 +++++ .../nuvio/app/core/ui/NuvioPlatformInsets.kt | 7 +- .../com/nuvio/app/features/home/HomeScreen.kt | 14 +- .../settings/AppearanceSettingsPage.kt | 15 ++ .../app/features/settings/SettingsScreen.kt | 27 ++- .../settings/ThemeSettingsRepository.kt | 17 ++ .../features/settings/ThemeSettingsStorage.kt | 2 + .../nuvio/app/core/ui/NativeTabBridge.ios.kt | 38 ++++ .../settings/ThemeSettingsStorage.ios.kt | 26 ++- iosApp/Configuration/Version.xcconfig | 2 +- iosApp/iosApp/ContentView.swift | 181 +++++++++++++++++- 16 files changed, 506 insertions(+), 60 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.android.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.kt create mode 100644 composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.ios.kt 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 {