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 1/3] 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 {
From 38a786850c05e9ed5d80a319864d8a475924e09e Mon Sep 17 00:00:00 2001
From: tapframe <85391825+tapframe@users.noreply.github.com>
Date: Wed, 6 May 2026 19:39:06 +0530
Subject: [PATCH 2/3] ref(ios): native tab to use app icons and avatars
---
.../app/core/ui/NativeTabBridge.android.kt | 9 +
.../commonMain/kotlin/com/nuvio/app/App.kt | 26 ++
.../com/nuvio/app/core/ui/NativeTabBridge.kt | 27 ++
.../settings/ThemeSettingsRepository.kt | 13 +
.../nuvio/app/core/ui/NativeTabBridge.ios.kt | 31 ++
iosApp/iosApp/ContentView.swift | 379 +++++++++++++++++-
6 files changed, 477 insertions(+), 8 deletions(-)
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
index a638c7fa..c7c556c5 100644
--- 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
@@ -7,3 +7,12 @@ internal actual fun publishLiquidGlassNativeTabBarEnabled(enabled: Boolean) = Un
internal actual fun publishNativeTabBarVisible(visible: Boolean) = Unit
internal actual fun publishNativeSelectedTab(tabName: String) = Unit
+
+internal actual fun publishNativeTabAccentColor(hexColor: String) = Unit
+
+internal actual fun publishNativeProfileTabIcon(
+ name: String?,
+ avatarColorHex: String?,
+ avatarImageUrl: String?,
+ avatarBackgroundColorHex: String?,
+) = Unit
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt
index ab33b6d4..51020aaf 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt
@@ -127,11 +127,13 @@ import com.nuvio.app.features.player.PlayerRoute
import com.nuvio.app.features.player.PlayerScreen
import com.nuvio.app.features.player.sanitizePlaybackHeaders
import com.nuvio.app.features.player.sanitizePlaybackResponseHeaders
+import com.nuvio.app.features.profiles.AvatarRepository
import com.nuvio.app.features.profiles.NuvioProfile
import com.nuvio.app.features.profiles.ProfileEditScreen
import com.nuvio.app.features.profiles.ProfileRepository
import com.nuvio.app.features.profiles.ProfileSelectionScreen
import com.nuvio.app.features.profiles.ProfileSwitcherTab
+import com.nuvio.app.features.profiles.avatarStorageUrl
import com.nuvio.app.features.search.SearchScreen
import com.nuvio.app.features.settings.SettingsScreen
import com.nuvio.app.features.settings.HomescreenSettingsScreen
@@ -316,9 +318,33 @@ fun App() {
val authState by AuthRepository.state.collectAsStateWithLifecycle()
val profileState by ProfileRepository.state.collectAsStateWithLifecycle()
+ val profileAvatars by AvatarRepository.avatars.collectAsStateWithLifecycle()
val networkStatusUiState by remember {
NetworkStatusRepository.uiState
}.collectAsStateWithLifecycle()
+
+ LaunchedEffect(
+ profileState.activeProfile?.profileIndex,
+ profileState.activeProfile?.name,
+ profileState.activeProfile?.avatarColorHex,
+ profileState.activeProfile?.avatarId,
+ profileAvatars,
+ ) {
+ val activeProfile = profileState.activeProfile
+ val avatarItem = activeProfile?.avatarId?.let { avatarId ->
+ profileAvatars.find { it.id == avatarId }
+ }
+ NativeTabBridge.publishProfileTabIcon(
+ name = activeProfile?.name,
+ avatarColorHex = activeProfile?.avatarColorHex,
+ avatarImageUrl = avatarItem
+ ?.storagePath
+ ?.takeIf { it.isNotBlank() }
+ ?.let(::avatarStorageUrl),
+ avatarBackgroundColorHex = avatarItem?.bgColor,
+ )
+ }
+
var gateScreen by rememberSaveable { mutableStateOf(AppGateScreen.Loading.name) }
var editingProfile by remember { mutableStateOf(null) }
var isNewProfile by remember { mutableStateOf(false) }
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
index b9eefda2..d7422533 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.kt
@@ -36,6 +36,24 @@ internal object NativeTabBridge {
fun publishLiquidGlassEnabled(enabled: Boolean) {
publishLiquidGlassNativeTabBarEnabled(enabled && isLiquidGlassNativeTabBarSupported())
}
+
+ fun publishAccentColor(hexColor: String) {
+ publishNativeTabAccentColor(hexColor)
+ }
+
+ fun publishProfileTabIcon(
+ name: String?,
+ avatarColorHex: String?,
+ avatarImageUrl: String?,
+ avatarBackgroundColorHex: String?,
+ ) {
+ publishNativeProfileTabIcon(
+ name = name,
+ avatarColorHex = avatarColorHex,
+ avatarImageUrl = avatarImageUrl,
+ avatarBackgroundColorHex = avatarBackgroundColorHex,
+ )
+ }
}
fun nativeTabSelect(tabName: String) {
@@ -49,3 +67,12 @@ internal expect fun publishLiquidGlassNativeTabBarEnabled(enabled: Boolean)
internal expect fun publishNativeTabBarVisible(visible: Boolean)
internal expect fun publishNativeSelectedTab(tabName: String)
+
+internal expect fun publishNativeTabAccentColor(hexColor: String)
+
+internal expect fun publishNativeProfileTabIcon(
+ name: String?,
+ avatarColorHex: String?,
+ avatarImageUrl: String?,
+ avatarBackgroundColorHex: String?,
+)
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 41d53fc6..2f1221dd 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
@@ -35,6 +35,7 @@ object ThemeSettingsRepository {
_selectedTheme.value = AppTheme.WHITE
_amoledEnabled.value = false
_liquidGlassNativeTabBarEnabled.value = false
+ NativeTabBridge.publishAccentColor(AppTheme.WHITE.nativeTabAccentHex())
NativeTabBridge.publishLiquidGlassEnabled(false)
_selectedAppLanguage.value = AppLanguage.ENGLISH
}
@@ -52,6 +53,7 @@ object ThemeSettingsRepository {
AppTheme.WHITE
}
_selectedTheme.value = theme
+ NativeTabBridge.publishAccentColor(theme.nativeTabAccentHex())
_amoledEnabled.value = ThemeSettingsStorage.loadAmoledEnabled() ?: false
val liquidGlassEnabled = ThemeSettingsStorage.loadLiquidGlassNativeTabBarEnabled() ?: false
_liquidGlassNativeTabBarEnabled.value = liquidGlassEnabled
@@ -66,6 +68,7 @@ object ThemeSettingsRepository {
if (_selectedTheme.value == theme) return
_selectedTheme.value = theme
ThemeSettingsStorage.saveSelectedTheme(theme.name)
+ NativeTabBridge.publishAccentColor(theme.nativeTabAccentHex())
}
fun setAmoled(enabled: Boolean) {
@@ -91,3 +94,13 @@ object ThemeSettingsRepository {
_selectedAppLanguage.value = language
}
}
+
+private fun AppTheme.nativeTabAccentHex(): String = when (this) {
+ AppTheme.CRIMSON -> "#E53935"
+ AppTheme.OCEAN -> "#1E88E5"
+ AppTheme.VIOLET -> "#8E24AA"
+ AppTheme.EMERALD -> "#43A047"
+ AppTheme.AMBER -> "#FB8C00"
+ AppTheme.ROSE -> "#D81B60"
+ AppTheme.WHITE -> "#F5F5F5"
+}
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
index 7e23415c..1b72da7c 100644
--- 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
@@ -8,6 +8,11 @@ import platform.UIKit.UIUserInterfaceIdiomPhone
private const val liquidGlassNativeTabBarEnabledKey = "NuvioLiquidGlassNativeTabBarEnabled"
private const val nativeTabBarVisibleKey = "NuvioNativeTabBarVisible"
private const val nativeSelectedTabKey = "NuvioNativeSelectedTab"
+private const val nativeTabAccentColorKey = "NuvioNativeTabAccentColor"
+private const val nativeProfileNameKey = "NuvioNativeProfileName"
+private const val nativeProfileAvatarColorKey = "NuvioNativeProfileAvatarColor"
+private const val nativeProfileAvatarUrlKey = "NuvioNativeProfileAvatarURL"
+private const val nativeProfileAvatarBackgroundColorKey = "NuvioNativeProfileAvatarBackgroundColor"
private const val nativeTabChromeDidChangeNotification = "NuvioNativeTabChromeDidChange"
internal actual fun isLiquidGlassNativeTabBarSupported(): Boolean {
@@ -28,11 +33,37 @@ internal actual fun publishNativeSelectedTab(tabName: String) {
notifyNativeTabChromeChanged()
}
+internal actual fun publishNativeTabAccentColor(hexColor: String) {
+ NSUserDefaults.standardUserDefaults.setObject(hexColor, forKey = nativeTabAccentColorKey)
+ notifyNativeTabChromeChanged()
+}
+
+internal actual fun publishNativeProfileTabIcon(
+ name: String?,
+ avatarColorHex: String?,
+ avatarImageUrl: String?,
+ avatarBackgroundColorHex: String?,
+) {
+ publishString(nativeProfileNameKey, name)
+ publishString(nativeProfileAvatarColorKey, avatarColorHex)
+ publishString(nativeProfileAvatarUrlKey, avatarImageUrl)
+ publishString(nativeProfileAvatarBackgroundColorKey, avatarBackgroundColorHex)
+ notifyNativeTabChromeChanged()
+}
+
private fun publishBool(key: String, value: Boolean) {
NSUserDefaults.standardUserDefaults.setBool(value, forKey = key)
notifyNativeTabChromeChanged()
}
+private fun publishString(key: String, value: String?) {
+ if (value.isNullOrBlank()) {
+ NSUserDefaults.standardUserDefaults.removeObjectForKey(key)
+ } else {
+ NSUserDefaults.standardUserDefaults.setObject(value, forKey = key)
+ }
+}
+
private fun notifyNativeTabChromeChanged() {
NSNotificationCenter.defaultCenter.postNotificationName(nativeTabChromeDidChangeNotification, null)
}
diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift
index a4268fa7..14f5664a 100644
--- a/iosApp/iosApp/ContentView.swift
+++ b/iosApp/iosApp/ContentView.swift
@@ -2,6 +2,257 @@ import UIKit
import SwiftUI
import ComposeApp
+private enum NuvioNativeTabIcon {
+ static let home = vectorIcon(
+ viewport: CGSize(width: 24, height: 24),
+ paths: [
+ "M10,20V14H14V20H19V12H22L12,3L2,12H5V20Z",
+ ]
+ )
+
+ static let search = drawnIcon { context, rect in
+ drawInViewport(context: context, rect: rect, viewport: CGSize(width: 20, height: 20)) {
+ context.setStrokeColor(UIColor.black.cgColor)
+ context.setLineWidth(2)
+ context.setLineCap(.round)
+ context.strokeEllipse(in: CGRect(x: 3, y: 3, width: 12, height: 12))
+ context.move(to: CGPoint(x: 13.6, y: 13.6))
+ context.addLine(to: CGPoint(x: 17, y: 17))
+ context.strokePath()
+ }
+ }
+
+ static let library = vectorIcon(
+ viewport: CGSize(width: 24, height: 24),
+ paths: [
+ "M8.50989,2.00001H15.49C15.7225,1.99995 15.9007,1.99991 16.0565,2.01515C17.1643,2.12352 18.0711,2.78958 18.4556,3.68678H5.54428C5.92879,2.78958 6.83555,2.12352 7.94337,2.01515C8.09917,1.99991 8.27741,1.99995 8.50989,2.00001Z",
+ "M6.31052,4.72312C4.91989,4.72312 3.77963,5.56287 3.3991,6.67691C3.39117,6.70013 3.38356,6.72348 3.37629,6.74693C3.77444,6.62636 4.18881,6.54759 4.60827,6.49382C5.68865,6.35531 7.05399,6.35538 8.64002,6.35547L8.75846,6.35547L15.5321,6.35547C17.1181,6.35538 18.4835,6.35531 19.5639,6.49382C19.9833,6.54759 20.3977,6.62636 20.7958,6.74693C20.7886,6.72348 20.781,6.70013 20.773,6.67691C20.3925,5.56287 19.2522,4.72312 17.8616,4.72312H6.31052Z",
+ "M8.67239,7.54204H15.3276C18.7024,7.54204 20.3898,7.54204 21.3377,8.52887C22.2855,9.5157 22.0625,11.0403 21.6165,14.0896L21.1935,16.9811C20.8437,19.3724 20.6689,20.568 19.7717,21.284C18.8745,22 17.5512,22 14.9046,22H9.09536C6.44881,22 5.12553,22 4.22834,21.284C3.33115,20.568 3.15626,19.3724 2.80648,16.9811L2.38351,14.0896C1.93748,11.0403 1.71447,9.5157 2.66232,8.52887C3.61017,7.54204 5.29758,7.54204 8.67239,7.54204ZM8,18.0001C8,17.5859 8.3731,17.2501 8.83333,17.2501H15.1667C15.6269,17.2501 16,17.5859 16,18.0001C16,18.4144 15.6269,18.7502 15.1667,18.7502H8.83333C8.3731,18.7502 8,18.4144 8,18.0001Z",
+ ]
+ )
+
+ static let profileFallback = vectorIcon(
+ viewport: CGSize(width: 24, height: 24),
+ paths: [
+ "M12,12C14.21,12 16,10.21 16,8C16,5.79 14.21,4 12,4C9.79,4 8,5.79 8,8C8,10.21 9.79,12 12,12ZM12,14C9.33,14 4,15.34 4,18V19C4,19.55 4.45,20 5,20H19C19.55,20 20,19.55 20,19V18C20,15.34 14.67,14 12,14Z",
+ ]
+ )
+
+ static func profileAvatar(
+ name: String?,
+ avatarColor: UIColor?,
+ backgroundColor: UIColor?,
+ avatarImage: UIImage?,
+ selected: Bool,
+ accent: UIColor
+ ) -> UIImage {
+ guard name != nil || avatarColor != nil || avatarImage != nil else {
+ return profileFallback
+ }
+
+ let size = CGSize(width: 28, height: 28)
+ let baseColor = avatarColor ?? UIColor(red: 30.0 / 255.0, green: 136.0 / 255.0, blue: 229.0 / 255.0, alpha: 1)
+ let fillColor = backgroundColor ?? baseColor.withAlphaComponent(0.15)
+ let borderColor = selected ? accent : baseColor.withAlphaComponent(0.5)
+ let initial = name?
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ .prefix(1)
+ .uppercased() ?? ""
+
+ return UIGraphicsImageRenderer(size: size).image { _ in
+ let rect = CGRect(origin: .zero, size: size).insetBy(dx: 1, dy: 1)
+ fillColor.setFill()
+ UIBezierPath(ovalIn: rect).fill()
+
+ if let avatarImage {
+ UIBezierPath(ovalIn: rect).addClip()
+ drawAspectFill(image: avatarImage, in: rect)
+ } else if !initial.isEmpty {
+ let font = UIFont.systemFont(ofSize: size.height * 0.45, weight: .bold)
+ let attributes: [NSAttributedString.Key: Any] = [
+ .font: font,
+ .foregroundColor: baseColor,
+ ]
+ let textSize = initial.size(withAttributes: attributes)
+ initial.draw(
+ at: CGPoint(
+ x: rect.midX - textSize.width / 2,
+ y: rect.midY - textSize.height / 2
+ ),
+ withAttributes: attributes
+ )
+ } else {
+ profileFallback
+ .withTintColor(baseColor, renderingMode: .alwaysOriginal)
+ .draw(in: rect.insetBy(dx: 5.5, dy: 5.5))
+ }
+
+ borderColor.setStroke()
+ let borderPath = UIBezierPath(ovalIn: rect.insetBy(dx: 0.75, dy: 0.75))
+ borderPath.lineWidth = 1.5
+ borderPath.stroke()
+ }.withRenderingMode(.alwaysOriginal)
+ }
+
+ private static func drawInViewport(
+ context: CGContext,
+ rect: CGRect,
+ viewport: CGSize,
+ draw: () -> Void
+ ) {
+ let scale = min(rect.width / viewport.width, rect.height / viewport.height)
+ let x = rect.midX - viewport.width * scale / 2
+ let y = rect.midY - viewport.height * scale / 2
+ context.saveGState()
+ context.translateBy(x: x, y: y)
+ context.scaleBy(x: scale, y: scale)
+ draw()
+ context.restoreGState()
+ }
+
+ private static func vectorIcon(viewport: CGSize, paths: [String], size: CGSize = CGSize(width: 25, height: 25)) -> UIImage {
+ drawnIcon(size: size) { context, rect in
+ drawInViewport(context: context, rect: rect, viewport: viewport) {
+ context.setFillColor(UIColor.black.cgColor)
+ paths.compactMap { SVGPath(data: $0).cgPath }.forEach { path in
+ context.addPath(path)
+ context.fillPath(using: .evenOdd)
+ }
+ }
+ }
+ }
+
+ private static func drawnIcon(
+ size: CGSize = CGSize(width: 25, height: 25),
+ draw: @escaping (CGContext, CGRect) -> Void
+ ) -> UIImage {
+ UIGraphicsImageRenderer(size: size).image { rendererContext in
+ draw(rendererContext.cgContext, CGRect(origin: .zero, size: size))
+ }.withRenderingMode(.alwaysTemplate)
+ }
+
+ private static func drawAspectFill(image: UIImage, in rect: CGRect) {
+ guard image.size.width > 0, image.size.height > 0 else { return }
+ let scale = max(rect.width / image.size.width, rect.height / image.size.height)
+ let drawSize = CGSize(width: image.size.width * scale, height: image.size.height * scale)
+ let drawRect = CGRect(
+ x: rect.midX - drawSize.width / 2,
+ y: rect.midY - drawSize.height / 2,
+ width: drawSize.width,
+ height: drawSize.height
+ )
+ image.draw(in: drawRect)
+ }
+
+ private struct SVGPath {
+ private enum Token {
+ case command(Character)
+ case number(CGFloat)
+ }
+
+ let data: String
+
+ var cgPath: CGPath? {
+ let tokens = Self.tokens(from: data)
+ var index = 0
+ var command: Character?
+ var current = CGPoint.zero
+ var subpathStart = CGPoint.zero
+ let path = CGMutablePath()
+
+ func hasNumber() -> Bool {
+ guard index < tokens.count else { return false }
+ if case .number = tokens[index] { return true }
+ return false
+ }
+
+ func readNumber() -> CGFloat? {
+ guard index < tokens.count else { return nil }
+ guard case let .number(value) = tokens[index] else { return nil }
+ index += 1
+ return value
+ }
+
+ func readPoint(relative: Bool) -> CGPoint? {
+ guard let x = readNumber(), let y = readNumber() else { return nil }
+ let point = CGPoint(x: x, y: y)
+ return relative ? CGPoint(x: current.x + point.x, y: current.y + point.y) : point
+ }
+
+ while index < tokens.count {
+ if case let .command(value) = tokens[index] {
+ command = value
+ index += 1
+ }
+
+ guard let activeCommand = command else { return nil }
+ let relative = activeCommand.isLowercase
+
+ switch activeCommand.uppercased() {
+ case "M":
+ guard let point = readPoint(relative: relative) else { return nil }
+ path.move(to: point)
+ current = point
+ subpathStart = point
+ command = relative ? "l" : "L"
+ case "L":
+ while hasNumber() {
+ guard let point = readPoint(relative: relative) else { return nil }
+ path.addLine(to: point)
+ current = point
+ }
+ case "H":
+ while hasNumber() {
+ guard let x = readNumber() else { return nil }
+ let point = CGPoint(x: relative ? current.x + x : x, y: current.y)
+ path.addLine(to: point)
+ current = point
+ }
+ case "V":
+ while hasNumber() {
+ guard let y = readNumber() else { return nil }
+ let point = CGPoint(x: current.x, y: relative ? current.y + y : y)
+ path.addLine(to: point)
+ current = point
+ }
+ case "C":
+ while hasNumber() {
+ guard
+ let c1 = readPoint(relative: relative),
+ let c2 = readPoint(relative: relative),
+ let end = readPoint(relative: relative)
+ else { return nil }
+ path.addCurve(to: end, control1: c1, control2: c2)
+ current = end
+ }
+ case "Z":
+ path.closeSubpath()
+ current = subpathStart
+ default:
+ return nil
+ }
+ }
+
+ return path
+ }
+
+ private static func tokens(from data: String) -> [Token] {
+ let pattern = "[MmLlHhVvCcZz]|[-+]?(?:\\d*\\.\\d+|\\d+\\.?)(?:[eE][-+]?\\d+)?"
+ guard let regex = try? NSRegularExpression(pattern: pattern) else { return [] }
+ let range = NSRange(data.startIndex.. UIImage {
+ guard tab == .settings else {
+ return tab.iconImage
+ }
+
+ let defaults = UserDefaults.standard
+ return NuvioNativeTabIcon.profileAvatar(
+ name: defaults.string(forKey: Self.nativeProfileNameKey),
+ avatarColor: UIColor(hexString: defaults.string(forKey: Self.nativeProfileAvatarColorKey)),
+ backgroundColor: UIColor(hexString: defaults.string(forKey: Self.nativeProfileAvatarBackgroundColorKey)),
+ avatarImage: profileAvatarImage,
+ selected: selected,
+ accent: accent
+ )
+ }
+
+ private func refreshProfileAvatarImageIfNeeded() {
+ let urlString = UserDefaults.standard.string(forKey: Self.nativeProfileAvatarURLKey)
+ guard urlString != profileAvatarImageURL else { return }
+
+ profileAvatarImageTask?.cancel()
+ profileAvatarImageTask = nil
+ profileAvatarImageURL = urlString
+ profileAvatarImage = nil
+
+ guard let urlString, let url = URL(string: urlString) else { return }
+
+ profileAvatarImageTask = URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in
+ guard
+ let self,
+ let data,
+ let image = UIImage(data: data)
+ else { return }
+
+ DispatchQueue.main.async {
+ guard self.profileAvatarImageURL == urlString else { return }
+ self.profileAvatarImage = image
+ self.applyNativeTabBarAppearance()
+ }
+ }
+ profileAvatarImageTask?.resume()
+ }
+}
+
+private extension UIColor {
+ convenience init?(hexString: String?) {
+ guard var value = hexString?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
+ return nil
+ }
+ if value.hasPrefix("#") {
+ value.removeFirst()
+ }
+ guard value.count == 6, let rgb = UInt64(value, radix: 16) else {
+ return nil
+ }
+ self.init(
+ red: CGFloat((rgb >> 16) & 0xFF) / 255,
+ green: CGFloat((rgb >> 8) & 0xFF) / 255,
+ blue: CGFloat(rgb & 0xFF) / 255,
+ alpha: 1
+ )
+ }
}
struct ComposeView: UIViewControllerRepresentable {
From 5926f239e30725fc139595942a8052709f898ec4 Mon Sep 17 00:00:00 2001
From: tapframe <85391825+tapframe@users.noreply.github.com>
Date: Wed, 6 May 2026 19:57:12 +0530
Subject: [PATCH 3/3] update strings
---
composeApp/src/commonMain/composeResources/values/strings.xml | 2 +-
composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt | 1 +
2 files changed, 2 insertions(+), 1 deletion(-)
diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index 42d4c850..81460967 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -450,7 +450,7 @@
Choose Language
Settings for the Continue Watching section.
Liquid Glass
- Use the native iPhone tab bar on iOS 26 and later.
+ Use the native iPhone tab bar on iOS 26 and later. Instant profile switching from the tab bar is unavailable while this is on.
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 51020aaf..0cda0cb5 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt
@@ -314,6 +314,7 @@ fun App() {
LaunchedEffect(Unit) {
NetworkStatusRepository.ensureStarted()
ProfileRepository.loadCachedProfiles()
+ AvatarRepository.fetchAvatars()
}
val authState by AuthRepository.state.collectAsStateWithLifecycle()