Merge branch 'ios' into cmp-rewrite

This commit is contained in:
tapframe 2026-05-06 19:57:39 +05:30
commit 96c04c86e8
16 changed files with 976 additions and 60 deletions

View file

@ -0,0 +1,18 @@
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
internal actual fun publishNativeTabAccentColor(hexColor: String) = Unit
internal actual fun publishNativeProfileTabIcon(
name: String?,
avatarColorHex: String?,
avatarImageUrl: String?,
avatarBackgroundColorHex: String?,
) = Unit

View file

@ -17,8 +17,13 @@ actual object ThemeSettingsStorage {
private const val preferencesName = "nuvio_theme_settings" private const val preferencesName = "nuvio_theme_settings"
private const val selectedThemeKey = "selected_theme" private const val selectedThemeKey = "selected_theme"
private const val amoledEnabledKey = "amoled_enabled" private const val amoledEnabledKey = "amoled_enabled"
private const val liquidGlassNativeTabBarEnabledKey = "liquid_glass_native_tab_bar_enabled"
private const val selectedAppLanguageKey = "selected_app_language" private const val selectedAppLanguageKey = "selected_app_language"
private val profileScopedSyncKeys = listOf(selectedThemeKey, amoledEnabledKey) private val profileScopedSyncKeys = listOf(
selectedThemeKey,
amoledEnabledKey,
liquidGlassNativeTabBarEnabledKey,
)
private val globalSyncKeys = listOf(selectedAppLanguageKey) private val globalSyncKeys = listOf(selectedAppLanguageKey)
private var preferences: SharedPreferences? = null private var preferences: SharedPreferences? = null
@ -51,6 +56,19 @@ actual object ThemeSettingsStorage {
?.apply() ?.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? { actual fun loadSelectedAppLanguage(): String? {
val value = preferences?.getString(selectedAppLanguageKey, null) val value = preferences?.getString(selectedAppLanguageKey, null)
if (value != null) return value if (value != null) return value
@ -75,6 +93,7 @@ actual object ThemeSettingsStorage {
actual fun exportToSyncPayload(): JsonObject = buildJsonObject { actual fun exportToSyncPayload(): JsonObject = buildJsonObject {
loadSelectedTheme()?.let { put(selectedThemeKey, encodeSyncString(it)) } loadSelectedTheme()?.let { put(selectedThemeKey, encodeSyncString(it)) }
loadAmoledEnabled()?.let { put(amoledEnabledKey, encodeSyncBoolean(it)) } loadAmoledEnabled()?.let { put(amoledEnabledKey, encodeSyncBoolean(it)) }
loadLiquidGlassNativeTabBarEnabled()?.let { put(liquidGlassNativeTabBarEnabledKey, encodeSyncBoolean(it)) }
loadSelectedAppLanguage()?.let { put(selectedAppLanguageKey, encodeSyncString(it)) } loadSelectedAppLanguage()?.let { put(selectedAppLanguageKey, encodeSyncString(it)) }
} }
@ -86,6 +105,7 @@ actual object ThemeSettingsStorage {
payload.decodeSyncString(selectedThemeKey)?.let(::saveSelectedTheme) payload.decodeSyncString(selectedThemeKey)?.let(::saveSelectedTheme)
payload.decodeSyncBoolean(amoledEnabledKey)?.let(::saveAmoledEnabled) payload.decodeSyncBoolean(amoledEnabledKey)?.let(::saveAmoledEnabled)
payload.decodeSyncBoolean(liquidGlassNativeTabBarEnabledKey)?.let(::saveLiquidGlassNativeTabBarEnabled)
payload.decodeSyncString(selectedAppLanguageKey)?.let(::saveSelectedAppLanguage) payload.decodeSyncString(selectedAppLanguageKey)?.let(::saveSelectedAppLanguage)
applySelectedAppLanguage(loadSelectedAppLanguage() ?: AppLanguage.ENGLISH.code) applySelectedAppLanguage(loadSelectedAppLanguage() ?: AppLanguage.ENGLISH.code)
} }

View file

@ -449,6 +449,8 @@
<string name="settings_appearance_app_language">App Language</string> <string name="settings_appearance_app_language">App Language</string>
<string name="settings_appearance_app_language_sheet_title">Choose Language</string> <string name="settings_appearance_app_language_sheet_title">Choose Language</string>
<string name="settings_appearance_continue_watching_description">Settings for the Continue Watching section.</string> <string name="settings_appearance_continue_watching_description">Settings for the Continue Watching section.</string>
<string name="settings_appearance_liquid_glass">Liquid Glass</string>
<string name="settings_appearance_liquid_glass_description">Use the native iPhone tab bar on iOS 26 and later. Instant profile switching from the tab bar is unavailable while this is on.</string>
<string name="settings_appearance_poster_customization_description">Tune card width and corner radius.</string> <string name="settings_appearance_poster_customization_description">Tune card width and corner radius.</string>
<string name="settings_appearance_section_display">DISPLAY</string> <string name="settings_appearance_section_display">DISPLAY</string>
<string name="settings_appearance_section_home">HOME</string> <string name="settings_appearance_section_home">HOME</string>

View file

@ -39,6 +39,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue 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.NuvioFloatingPrompt
import com.nuvio.app.core.ui.TraktListPickerDialog import com.nuvio.app.core.ui.TraktListPickerDialog
import com.nuvio.app.core.ui.NuvioTheme 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.core.ui.localizedContinueWatchingSubtitle
import com.nuvio.app.features.auth.AuthScreen import com.nuvio.app.features.auth.AuthScreen
import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.addons.AddonRepository
@ -122,11 +127,13 @@ import com.nuvio.app.features.player.PlayerRoute
import com.nuvio.app.features.player.PlayerScreen import com.nuvio.app.features.player.PlayerScreen
import com.nuvio.app.features.player.sanitizePlaybackHeaders import com.nuvio.app.features.player.sanitizePlaybackHeaders
import com.nuvio.app.features.player.sanitizePlaybackResponseHeaders 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.NuvioProfile
import com.nuvio.app.features.profiles.ProfileEditScreen import com.nuvio.app.features.profiles.ProfileEditScreen
import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.profiles.ProfileRepository
import com.nuvio.app.features.profiles.ProfileSelectionScreen import com.nuvio.app.features.profiles.ProfileSelectionScreen
import com.nuvio.app.features.profiles.ProfileSwitcherTab 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.search.SearchScreen
import com.nuvio.app.features.settings.SettingsScreen import com.nuvio.app.features.settings.SettingsScreen
import com.nuvio.app.features.settings.HomescreenSettingsScreen import com.nuvio.app.features.settings.HomescreenSettingsScreen
@ -260,6 +267,20 @@ enum class AppScreenTab {
Settings, 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 { private enum class AppGateScreen {
Loading, Loading,
Auth, Auth,
@ -293,13 +314,38 @@ fun App() {
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
NetworkStatusRepository.ensureStarted() NetworkStatusRepository.ensureStarted()
ProfileRepository.loadCachedProfiles() ProfileRepository.loadCachedProfiles()
AvatarRepository.fetchAvatars()
} }
val authState by AuthRepository.state.collectAsStateWithLifecycle() val authState by AuthRepository.state.collectAsStateWithLifecycle()
val profileState by ProfileRepository.state.collectAsStateWithLifecycle() val profileState by ProfileRepository.state.collectAsStateWithLifecycle()
val profileAvatars by AvatarRepository.avatars.collectAsStateWithLifecycle()
val networkStatusUiState by remember { val networkStatusUiState by remember {
NetworkStatusRepository.uiState NetworkStatusRepository.uiState
}.collectAsStateWithLifecycle() }.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 gateScreen by rememberSaveable { mutableStateOf(AppGateScreen.Loading.name) }
var editingProfile by remember { mutableStateOf<NuvioProfile?>(null) } var editingProfile by remember { mutableStateOf<NuvioProfile?>(null) }
var isNewProfile by remember { mutableStateOf(false) } var isNewProfile by remember { mutableStateOf(false) }
@ -466,6 +512,11 @@ private fun MainAppContent(
val hapticFeedback = LocalHapticFeedback.current val hapticFeedback = LocalHapticFeedback.current
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
var selectedTab by rememberSaveable { mutableStateOf(AppScreenTab.Home) } 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 showExitConfirmation by rememberSaveable { mutableStateOf(false) }
var selectedPosterForActions by remember { mutableStateOf<MetaPreview?>(null) } var selectedPosterForActions by remember { mutableStateOf<MetaPreview?>(null) }
var selectedContinueWatchingForActions by remember { mutableStateOf<ContinueWatchingItem?>(null) } var selectedContinueWatchingForActions by remember { mutableStateOf<ContinueWatchingItem?>(null) }
@ -515,6 +566,16 @@ private fun MainAppContent(
.sorted() .sorted()
} }
LaunchedEffect(nativeRequestedTab) {
if (liquidGlassNativeTabBarSupported && liquidGlassNativeTabBarEnabled) {
selectedTab = nativeRequestedTab.toAppScreenTab()
}
}
LaunchedEffect(selectedTab) {
NativeTabBridge.publishSelectedTab(selectedTab.toNativeNavigationTab())
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
NetworkStatusRepository.ensureStarted() NetworkStatusRepository.ensureStarted()
EpisodeReleaseNotificationsRepository.refreshAsync() EpisodeReleaseNotificationsRepository.refreshAsync()
@ -886,6 +947,8 @@ private fun MainAppContent(
BoxWithConstraints(modifier = Modifier.fillMaxSize()) { BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val isTabletLayout = maxWidth >= 768.dp val isTabletLayout = maxWidth >= 768.dp
val useNativeBottomTabs =
liquidGlassNativeTabBarSupported && liquidGlassNativeTabBarEnabled && initialHomeReady
val onProfileSelected: (NuvioProfile) -> Unit = { profile -> val onProfileSelected: (NuvioProfile) -> Unit = { profile ->
profileSwitchLoading = true profileSwitchLoading = true
selectedTab = AppScreenTab.Home selectedTab = AppScreenTab.Home
@ -893,6 +956,13 @@ private fun MainAppContent(
com.nuvio.app.core.sync.SyncManager.pullAllForProfile(profile.profileIndex) com.nuvio.app.core.sync.SyncManager.pullAllForProfile(profile.profileIndex)
} }
DisposableEffect(useNativeBottomTabs) {
NativeTabBridge.publishTabBarVisible(useNativeBottomTabs)
onDispose {
NativeTabBridge.publishTabBarVisible(false)
}
}
Scaffold( Scaffold(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@ -900,7 +970,7 @@ private fun MainAppContent(
containerColor = Color.Transparent, containerColor = Color.Transparent,
contentWindowInsets = WindowInsets(0), contentWindowInsets = WindowInsets(0),
bottomBar = { bottomBar = {
if (!isTabletLayout) { if (!isTabletLayout && !useNativeBottomTabs) {
NuvioNavigationBar { NuvioNavigationBar {
NavItem( NavItem(
selected = selectedTab == AppScreenTab.Home, selected = selectedTab == AppScreenTab.Home,
@ -936,58 +1006,62 @@ private fun MainAppContent(
}, },
) { innerPadding -> ) { innerPadding ->
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
AppTabHost( CompositionLocalProvider(
modifier = Modifier LocalNuvioBottomNavigationOverlayPadding provides if (useNativeBottomTabs) 49.dp else 0.dp,
.fillMaxSize() ) {
.padding(innerPadding), AppTabHost(
selectedTab = selectedTab, modifier = Modifier
onCatalogClick = onCatalogClick, .fillMaxSize()
onPosterClick = { meta -> .padding(innerPadding),
navController.navigate(DetailRoute(type = meta.type, id = meta.id)) selectedTab = selectedTab,
}, onCatalogClick = onCatalogClick,
onPosterLongClick = { meta -> onPosterClick = { meta ->
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) navController.navigate(DetailRoute(type = meta.type, id = meta.id))
selectedPosterForActions = meta },
}, onPosterLongClick = { meta ->
onLibraryPosterClick = { item -> hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
navController.navigate(DetailRoute(type = item.type, id = item.id)) selectedPosterForActions = meta
}, },
onLibrarySectionViewAllClick = onLibrarySectionViewAllClick, onLibraryPosterClick = { item ->
onContinueWatchingClick = onContinueWatchingClick, navController.navigate(DetailRoute(type = item.type, id = item.id))
onContinueWatchingLongPress = onContinueWatchingLongPress, },
onSwitchProfile = onSwitchProfile, onLibrarySectionViewAllClick = onLibrarySectionViewAllClick,
onHomescreenSettingsClick = { navController.navigate(HomescreenSettingsRoute) }, onContinueWatchingClick = onContinueWatchingClick,
onMetaScreenSettingsClick = { navController.navigate(MetaScreenSettingsRoute) }, onContinueWatchingLongPress = onContinueWatchingLongPress,
onContinueWatchingSettingsClick = { navController.navigate(ContinueWatchingSettingsRoute) }, onSwitchProfile = onSwitchProfile,
onDownloadsSettingsClick = { navController.navigate(DownloadsSettingsRoute) }, onHomescreenSettingsClick = { navController.navigate(HomescreenSettingsRoute) },
onAddonsSettingsClick = { navController.navigate(AddonsSettingsRoute) }, onMetaScreenSettingsClick = { navController.navigate(MetaScreenSettingsRoute) },
onPluginsSettingsClick = { onContinueWatchingSettingsClick = { navController.navigate(ContinueWatchingSettingsRoute) },
if (AppFeaturePolicy.pluginsEnabled) { onDownloadsSettingsClick = { navController.navigate(DownloadsSettingsRoute) },
navController.navigate(PluginsSettingsRoute) onAddonsSettingsClick = { navController.navigate(AddonsSettingsRoute) },
} onPluginsSettingsClick = {
}, if (AppFeaturePolicy.pluginsEnabled) {
onAccountSettingsClick = { navController.navigate(AccountSettingsRoute) }, navController.navigate(PluginsSettingsRoute)
onSupportersContributorsSettingsClick = { }
navController.navigate(SupportersContributorsSettingsRoute) },
}, onAccountSettingsClick = { navController.navigate(AccountSettingsRoute) },
onCheckForUpdatesClick = if (AppFeaturePolicy.inAppUpdaterEnabled) { onSupportersContributorsSettingsClick = {
{ navController.navigate(SupportersContributorsSettingsRoute)
appUpdaterController.checkForUpdates( },
force = true, onCheckForUpdatesClick = if (AppFeaturePolicy.inAppUpdaterEnabled) {
showNoUpdateFeedback = true, {
) appUpdaterController.checkForUpdates(
} force = true,
} else { showNoUpdateFeedback = true,
null )
}, }
onCollectionsSettingsClick = { navController.navigate(CollectionsRoute) }, } else {
onFolderClick = { collectionId, folderId -> null
navController.navigate(FolderDetailRoute(collectionId = collectionId, folderId = folderId)) },
}, onCollectionsSettingsClick = { navController.navigate(CollectionsRoute) },
onInitialHomeContentRendered = { initialHomeReady = true }, onFolderClick = { collectionId, folderId ->
) navController.navigate(FolderDetailRoute(collectionId = collectionId, folderId = folderId))
},
onInitialHomeContentRendered = { initialHomeReady = true },
)
}
if (isTabletLayout) { if (isTabletLayout && !useNativeBottomTabs) {
TabletFloatingTopBar( TabletFloatingTopBar(
selectedTab = selectedTab, selectedTab = selectedTab,
onTabSelected = { selectedTab = it }, onTabSelected = { selectedTab = it },

View file

@ -152,6 +152,7 @@ object ProfileSettingsSync {
val signatureFlows = listOf( val signatureFlows = listOf(
ThemeSettingsRepository.selectedTheme.map { "theme" }, ThemeSettingsRepository.selectedTheme.map { "theme" },
ThemeSettingsRepository.amoledEnabled.map { "amoled" }, ThemeSettingsRepository.amoledEnabled.map { "amoled" },
ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.map { "liquid_glass_tab_bar" },
PosterCardStyleRepository.uiState.map { "poster_card_style" }, PosterCardStyleRepository.uiState.map { "poster_card_style" },
PlayerSettingsRepository.uiState.map { "player" }, PlayerSettingsRepository.uiState.map { "player" },
TmdbSettingsRepository.uiState.map { "tmdb" }, TmdbSettingsRepository.uiState.map { "tmdb" },
@ -265,6 +266,7 @@ object ProfileSettingsSync {
private fun currentObservedStateSignature(): String = listOf( private fun currentObservedStateSignature(): String = listOf(
"theme=${ThemeSettingsRepository.selectedTheme.value.name}", "theme=${ThemeSettingsRepository.selectedTheme.value.name}",
"amoled=${ThemeSettingsRepository.amoledEnabled.value}", "amoled=${ThemeSettingsRepository.amoledEnabled.value}",
"liquid_glass_tab_bar=${ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.value}",
"poster_card_style=${PosterCardStyleRepository.uiState.value}", "poster_card_style=${PosterCardStyleRepository.uiState.value}",
"player=${PlayerSettingsRepository.uiState.value}", "player=${PlayerSettingsRepository.uiState.value}",
"tmdb=${TmdbSettingsRepository.uiState.value}", "tmdb=${TmdbSettingsRepository.uiState.value}",

View file

@ -0,0 +1,78 @@
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<NativeNavigationTab> = _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 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) {
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)
internal expect fun publishNativeTabAccentColor(hexColor: String)
internal expect fun publishNativeProfileTabIcon(
name: String?,
avatarColorHex: String?,
avatarImageUrl: String?,
avatarBackgroundColorHex: String?,
)

View file

@ -3,6 +3,7 @@ package com.nuvio.app.core.ui
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -12,10 +13,14 @@ internal expect val nuvioBottomNavigationExtraVerticalPadding: Dp
@Composable @Composable
internal expect fun nuvioBottomNavigationBarInsets(): WindowInsets internal expect fun nuvioBottomNavigationBarInsets(): WindowInsets
internal val LocalNuvioBottomNavigationOverlayPadding = staticCompositionLocalOf { 0.dp }
@Composable @Composable
internal fun nuvioSafeBottomPadding(extra: Dp = 0.dp): Dp { internal fun nuvioSafeBottomPadding(extra: Dp = 0.dp): Dp {
val navigationBarBottom = nuvioBottomNavigationBarInsets() val navigationBarBottom = nuvioBottomNavigationBarInsets()
.asPaddingValues() .asPaddingValues()
.calculateBottomPadding() .calculateBottomPadding()
return navigationBarBottom.coerceAtLeast(nuvioPlatformExtraBottomPadding) + extra return navigationBarBottom.coerceAtLeast(nuvioPlatformExtraBottomPadding) +
LocalNuvioBottomNavigationOverlayPadding.current +
extra
} }

View file

@ -16,8 +16,10 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.nuvio.app.core.network.NetworkCondition import com.nuvio.app.core.network.NetworkCondition
import com.nuvio.app.core.network.NetworkStatusRepository 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.NuvioScreen
import com.nuvio.app.core.ui.NuvioNetworkOfflineCard 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.addons.AddonRepository
import com.nuvio.app.features.details.MetaDetailsRepository import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.details.nextReleasedEpisodeAfter import com.nuvio.app.features.details.nextReleasedEpisodeAfter
@ -405,12 +407,19 @@ fun HomeScreen(
BoxWithConstraints(modifier = modifier.fillMaxSize()) { BoxWithConstraints(modifier = modifier.fillMaxSize()) {
val homeSectionPadding = homeSectionHorizontalPaddingForWidth(maxWidth.value) val homeSectionPadding = homeSectionHorizontalPaddingForWidth(maxWidth.value)
val continueWatchingLayout = rememberContinueWatchingLayout(maxWidth.value) val continueWatchingLayout = rememberContinueWatchingLayout(maxWidth.value)
val nativeBottomNavigationOverlayHeight =
if (LocalNuvioBottomNavigationOverlayPadding.current > 0.dp) {
nuvioSafeBottomPadding()
} else {
0.dp
}
val mobileHeroBelowSectionHeightHint = remember( val mobileHeroBelowSectionHeightHint = remember(
maxWidth.value, maxWidth.value,
continueWatchingPreferences.isVisible, continueWatchingPreferences.isVisible,
continueWatchingPreferences.style, continueWatchingPreferences.style,
continueWatchingItems.isNotEmpty(), continueWatchingItems.isNotEmpty(),
continueWatchingLayout, continueWatchingLayout,
nativeBottomNavigationOverlayHeight,
) { ) {
heroMobileBelowSectionHeightHint( heroMobileBelowSectionHeightHint(
maxWidthDp = maxWidth.value, maxWidthDp = maxWidth.value,
@ -418,6 +427,7 @@ fun HomeScreen(
hasContinueWatchingItems = continueWatchingItems.isNotEmpty(), hasContinueWatchingItems = continueWatchingItems.isNotEmpty(),
continueWatchingStyle = continueWatchingPreferences.style, continueWatchingStyle = continueWatchingPreferences.style,
continueWatchingLayout = continueWatchingLayout, continueWatchingLayout = continueWatchingLayout,
bottomNavigationOverlayHeight = nativeBottomNavigationOverlayHeight,
) )
} }
@ -605,14 +615,16 @@ private fun heroMobileBelowSectionHeightHint(
hasContinueWatchingItems: Boolean, hasContinueWatchingItems: Boolean,
continueWatchingStyle: ContinueWatchingSectionStyle, continueWatchingStyle: ContinueWatchingSectionStyle,
continueWatchingLayout: ContinueWatchingLayout, continueWatchingLayout: ContinueWatchingLayout,
bottomNavigationOverlayHeight: Dp,
): Dp? { ): Dp? {
if (maxWidthDp >= 600f || !continueWatchingVisible || !hasContinueWatchingItems) return null if (maxWidthDp >= 600f || !continueWatchingVisible || !hasContinueWatchingItems) return null
return when (continueWatchingStyle) { val sectionHeight = when (continueWatchingStyle) {
ContinueWatchingSectionStyle.Wide -> continueWatchingLayout.wideCardHeight + 56.dp ContinueWatchingSectionStyle.Wide -> continueWatchingLayout.wideCardHeight + 56.dp
ContinueWatchingSectionStyle.Poster -> ContinueWatchingSectionStyle.Poster ->
continueWatchingLayout.posterCardHeight + continueWatchingLayout.posterTitleBlockHeight + 70.dp continueWatchingLayout.posterCardHeight + continueWatchingLayout.posterTitleBlockHeight + 70.dp
} }
return sectionHeight + bottomNavigationOverlayHeight
} }
internal fun buildHomeContinueWatchingItems( internal fun buildHomeContinueWatchingItems(

View file

@ -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_black
import nuvio.composeapp.generated.resources.settings_appearance_amoled_description 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_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_poster_customization_description
import nuvio.composeapp.generated.resources.settings_appearance_section_display import nuvio.composeapp.generated.resources.settings_appearance_section_display
import nuvio.composeapp.generated.resources.settings_appearance_section_home import nuvio.composeapp.generated.resources.settings_appearance_section_home
@ -70,6 +72,9 @@ internal fun LazyListScope.appearanceSettingsContent(
onThemeSelected: (AppTheme) -> Unit, onThemeSelected: (AppTheme) -> Unit,
amoledEnabled: Boolean, amoledEnabled: Boolean,
onAmoledToggle: (Boolean) -> Unit, onAmoledToggle: (Boolean) -> Unit,
liquidGlassNativeTabBarSupported: Boolean,
liquidGlassNativeTabBarEnabled: Boolean,
onLiquidGlassNativeTabBarToggle: (Boolean) -> Unit,
selectedAppLanguage: AppLanguage, selectedAppLanguage: AppLanguage,
onAppLanguageSelected: (AppLanguage) -> Unit, onAppLanguageSelected: (AppLanguage) -> Unit,
onContinueWatchingClick: () -> Unit, onContinueWatchingClick: () -> Unit,
@ -118,6 +123,16 @@ internal fun LazyListScope.appearanceSettingsContent(
isTablet = isTablet, isTablet = isTablet,
onCheckedChange = onAmoledToggle, 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) SettingsGroupDivider(isTablet = isTablet)
SettingsNavigationRow( SettingsNavigationRow(
title = stringResource(Res.string.settings_appearance_app_language), title = stringResource(Res.string.settings_appearance_app_language),

View file

@ -38,9 +38,11 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.max import androidx.compose.ui.unit.max
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.nuvio.app.core.ui.AppTheme 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.NuvioScreen
import com.nuvio.app.core.ui.NuvioScreenHeader import com.nuvio.app.core.ui.NuvioScreenHeader
import com.nuvio.app.core.ui.PlatformBackHandler 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.addons.AddonRepository
import com.nuvio.app.features.details.MetaScreenSettingsRepository import com.nuvio.app.features.details.MetaScreenSettingsRepository
import com.nuvio.app.features.details.MetaScreenSettingsUiState import com.nuvio.app.features.details.MetaScreenSettingsUiState
@ -94,6 +96,10 @@ fun SettingsScreen(
ThemeSettingsRepository.selectedTheme ThemeSettingsRepository.selectedTheme
}.collectAsStateWithLifecycle() }.collectAsStateWithLifecycle()
val amoledEnabled by remember { ThemeSettingsRepository.amoledEnabled }.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 selectedAppLanguage by remember { ThemeSettingsRepository.selectedAppLanguage }.collectAsStateWithLifecycle()
val tmdbSettings by remember { val tmdbSettings by remember {
TmdbSettingsRepository.ensureLoaded() TmdbSettingsRepository.ensureLoaded()
@ -191,6 +197,9 @@ fun SettingsScreen(
onThemeSelected = ThemeSettingsRepository::setTheme, onThemeSelected = ThemeSettingsRepository::setTheme,
amoledEnabled = amoledEnabled, amoledEnabled = amoledEnabled,
onAmoledToggle = ThemeSettingsRepository::setAmoled, onAmoledToggle = ThemeSettingsRepository::setAmoled,
liquidGlassNativeTabBarSupported = liquidGlassNativeTabBarSupported,
liquidGlassNativeTabBarEnabled = liquidGlassNativeTabBarEnabled,
onLiquidGlassNativeTabBarToggle = ThemeSettingsRepository::setLiquidGlassNativeTabBar,
selectedAppLanguage = selectedAppLanguage, selectedAppLanguage = selectedAppLanguage,
onAppLanguageSelected = ThemeSettingsRepository::setAppLanguage, onAppLanguageSelected = ThemeSettingsRepository::setAppLanguage,
episodeReleaseNotificationsUiState = episodeReleaseNotificationsUiState, episodeReleaseNotificationsUiState = episodeReleaseNotificationsUiState,
@ -233,6 +242,9 @@ fun SettingsScreen(
onThemeSelected = ThemeSettingsRepository::setTheme, onThemeSelected = ThemeSettingsRepository::setTheme,
amoledEnabled = amoledEnabled, amoledEnabled = amoledEnabled,
onAmoledToggle = ThemeSettingsRepository::setAmoled, onAmoledToggle = ThemeSettingsRepository::setAmoled,
liquidGlassNativeTabBarSupported = liquidGlassNativeTabBarSupported,
liquidGlassNativeTabBarEnabled = liquidGlassNativeTabBarEnabled,
onLiquidGlassNativeTabBarToggle = ThemeSettingsRepository::setLiquidGlassNativeTabBar,
selectedAppLanguage = selectedAppLanguage, selectedAppLanguage = selectedAppLanguage,
onAppLanguageSelected = ThemeSettingsRepository::setAppLanguage, onAppLanguageSelected = ThemeSettingsRepository::setAppLanguage,
episodeReleaseNotificationsUiState = episodeReleaseNotificationsUiState, episodeReleaseNotificationsUiState = episodeReleaseNotificationsUiState,
@ -285,6 +297,9 @@ private fun MobileSettingsScreen(
onThemeSelected: (AppTheme) -> Unit, onThemeSelected: (AppTheme) -> Unit,
amoledEnabled: Boolean, amoledEnabled: Boolean,
onAmoledToggle: (Boolean) -> Unit, onAmoledToggle: (Boolean) -> Unit,
liquidGlassNativeTabBarSupported: Boolean,
liquidGlassNativeTabBarEnabled: Boolean,
onLiquidGlassNativeTabBarToggle: (Boolean) -> Unit,
selectedAppLanguage: AppLanguage, selectedAppLanguage: AppLanguage,
onAppLanguageSelected: (AppLanguage) -> Unit, onAppLanguageSelected: (AppLanguage) -> Unit,
episodeReleaseNotificationsUiState: EpisodeReleaseNotificationsUiState, episodeReleaseNotificationsUiState: EpisodeReleaseNotificationsUiState,
@ -366,6 +381,9 @@ private fun MobileSettingsScreen(
onThemeSelected = onThemeSelected, onThemeSelected = onThemeSelected,
amoledEnabled = amoledEnabled, amoledEnabled = amoledEnabled,
onAmoledToggle = onAmoledToggle, onAmoledToggle = onAmoledToggle,
liquidGlassNativeTabBarSupported = liquidGlassNativeTabBarSupported,
liquidGlassNativeTabBarEnabled = liquidGlassNativeTabBarEnabled,
onLiquidGlassNativeTabBarToggle = onLiquidGlassNativeTabBarToggle,
selectedAppLanguage = selectedAppLanguage, selectedAppLanguage = selectedAppLanguage,
onAppLanguageSelected = onAppLanguageSelected, onAppLanguageSelected = onAppLanguageSelected,
onContinueWatchingClick = onContinueWatchingClick, onContinueWatchingClick = onContinueWatchingClick,
@ -457,6 +475,9 @@ private fun TabletSettingsScreen(
onThemeSelected: (AppTheme) -> Unit, onThemeSelected: (AppTheme) -> Unit,
amoledEnabled: Boolean, amoledEnabled: Boolean,
onAmoledToggle: (Boolean) -> Unit, onAmoledToggle: (Boolean) -> Unit,
liquidGlassNativeTabBarSupported: Boolean,
liquidGlassNativeTabBarEnabled: Boolean,
onLiquidGlassNativeTabBarToggle: (Boolean) -> Unit,
selectedAppLanguage: AppLanguage, selectedAppLanguage: AppLanguage,
onAppLanguageSelected: (AppLanguage) -> Unit, onAppLanguageSelected: (AppLanguage) -> Unit,
episodeReleaseNotificationsUiState: EpisodeReleaseNotificationsUiState, episodeReleaseNotificationsUiState: EpisodeReleaseNotificationsUiState,
@ -539,6 +560,7 @@ private fun TabletSettingsScreen(
saveableStateHolder.SaveableStateProvider(page.name) { saveableStateHolder.SaveableStateProvider(page.name) {
val listState = rememberLazyListState() val listState = rememberLazyListState()
val bottomOverlayPadding = LocalNuvioBottomNavigationOverlayPadding.current
LazyColumn( LazyColumn(
state = listState, state = listState,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@ -546,7 +568,7 @@ private fun TabletSettingsScreen(
start = 40.dp, start = 40.dp,
top = topOffset, top = topOffset,
end = 40.dp, end = 40.dp,
bottom = 40.dp, bottom = 40.dp + bottomOverlayPadding,
), ),
verticalArrangement = Arrangement.spacedBy(18.dp), verticalArrangement = Arrangement.spacedBy(18.dp),
) { ) {
@ -609,6 +631,9 @@ private fun TabletSettingsScreen(
onThemeSelected = onThemeSelected, onThemeSelected = onThemeSelected,
amoledEnabled = amoledEnabled, amoledEnabled = amoledEnabled,
onAmoledToggle = onAmoledToggle, onAmoledToggle = onAmoledToggle,
liquidGlassNativeTabBarSupported = liquidGlassNativeTabBarSupported,
liquidGlassNativeTabBarEnabled = liquidGlassNativeTabBarEnabled,
onLiquidGlassNativeTabBarToggle = onLiquidGlassNativeTabBarToggle,
selectedAppLanguage = selectedAppLanguage, selectedAppLanguage = selectedAppLanguage,
onAppLanguageSelected = onAppLanguageSelected, onAppLanguageSelected = onAppLanguageSelected,
onContinueWatchingClick = { openInlinePage(SettingsPage.ContinueWatching) }, onContinueWatchingClick = { openInlinePage(SettingsPage.ContinueWatching) },

View file

@ -1,6 +1,7 @@
package com.nuvio.app.features.settings package com.nuvio.app.features.settings
import com.nuvio.app.core.ui.AppTheme import com.nuvio.app.core.ui.AppTheme
import com.nuvio.app.core.ui.NativeTabBridge
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@ -12,6 +13,9 @@ object ThemeSettingsRepository {
private val _amoledEnabled = MutableStateFlow(false) private val _amoledEnabled = MutableStateFlow(false)
val amoledEnabled: StateFlow<Boolean> = _amoledEnabled.asStateFlow() val amoledEnabled: StateFlow<Boolean> = _amoledEnabled.asStateFlow()
private val _liquidGlassNativeTabBarEnabled = MutableStateFlow(false)
val liquidGlassNativeTabBarEnabled: StateFlow<Boolean> = _liquidGlassNativeTabBarEnabled.asStateFlow()
private val _selectedAppLanguage = MutableStateFlow(AppLanguage.ENGLISH) private val _selectedAppLanguage = MutableStateFlow(AppLanguage.ENGLISH)
val selectedAppLanguage: StateFlow<AppLanguage> = _selectedAppLanguage.asStateFlow() val selectedAppLanguage: StateFlow<AppLanguage> = _selectedAppLanguage.asStateFlow()
@ -30,6 +34,9 @@ object ThemeSettingsRepository {
hasLoaded = false hasLoaded = false
_selectedTheme.value = AppTheme.WHITE _selectedTheme.value = AppTheme.WHITE
_amoledEnabled.value = false _amoledEnabled.value = false
_liquidGlassNativeTabBarEnabled.value = false
NativeTabBridge.publishAccentColor(AppTheme.WHITE.nativeTabAccentHex())
NativeTabBridge.publishLiquidGlassEnabled(false)
_selectedAppLanguage.value = AppLanguage.ENGLISH _selectedAppLanguage.value = AppLanguage.ENGLISH
} }
@ -46,7 +53,11 @@ object ThemeSettingsRepository {
AppTheme.WHITE AppTheme.WHITE
} }
_selectedTheme.value = theme _selectedTheme.value = theme
NativeTabBridge.publishAccentColor(theme.nativeTabAccentHex())
_amoledEnabled.value = ThemeSettingsStorage.loadAmoledEnabled() ?: false _amoledEnabled.value = ThemeSettingsStorage.loadAmoledEnabled() ?: false
val liquidGlassEnabled = ThemeSettingsStorage.loadLiquidGlassNativeTabBarEnabled() ?: false
_liquidGlassNativeTabBarEnabled.value = liquidGlassEnabled
NativeTabBridge.publishLiquidGlassEnabled(liquidGlassEnabled)
val appLanguage = AppLanguage.fromCode(ThemeSettingsStorage.loadSelectedAppLanguage()) val appLanguage = AppLanguage.fromCode(ThemeSettingsStorage.loadSelectedAppLanguage())
ThemeSettingsStorage.applySelectedAppLanguage(appLanguage.code) ThemeSettingsStorage.applySelectedAppLanguage(appLanguage.code)
_selectedAppLanguage.value = appLanguage _selectedAppLanguage.value = appLanguage
@ -57,6 +68,7 @@ object ThemeSettingsRepository {
if (_selectedTheme.value == theme) return if (_selectedTheme.value == theme) return
_selectedTheme.value = theme _selectedTheme.value = theme
ThemeSettingsStorage.saveSelectedTheme(theme.name) ThemeSettingsStorage.saveSelectedTheme(theme.name)
NativeTabBridge.publishAccentColor(theme.nativeTabAccentHex())
} }
fun setAmoled(enabled: Boolean) { fun setAmoled(enabled: Boolean) {
@ -66,6 +78,14 @@ object ThemeSettingsRepository {
ThemeSettingsStorage.saveAmoledEnabled(enabled) 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) { fun setAppLanguage(language: AppLanguage) {
ensureLoaded() ensureLoaded()
if (_selectedAppLanguage.value == language) return if (_selectedAppLanguage.value == language) return
@ -74,3 +94,13 @@ object ThemeSettingsRepository {
_selectedAppLanguage.value = language _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"
}

View file

@ -7,6 +7,8 @@ internal expect object ThemeSettingsStorage {
fun saveSelectedTheme(themeName: String) fun saveSelectedTheme(themeName: String)
fun loadAmoledEnabled(): Boolean? fun loadAmoledEnabled(): Boolean?
fun saveAmoledEnabled(enabled: Boolean) fun saveAmoledEnabled(enabled: Boolean)
fun loadLiquidGlassNativeTabBarEnabled(): Boolean?
fun saveLiquidGlassNativeTabBarEnabled(enabled: Boolean)
fun loadSelectedAppLanguage(): String? fun loadSelectedAppLanguage(): String?
fun saveSelectedAppLanguage(languageCode: String) fun saveSelectedAppLanguage(languageCode: String)
fun applySelectedAppLanguage(languageCode: String) fun applySelectedAppLanguage(languageCode: String)

View file

@ -0,0 +1,69 @@
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 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 {
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()
}
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)
}

View file

@ -13,8 +13,13 @@ import platform.Foundation.NSUserDefaults
actual object ThemeSettingsStorage { actual object ThemeSettingsStorage {
private const val selectedThemeKey = "selected_theme" private const val selectedThemeKey = "selected_theme"
private const val amoledEnabledKey = "amoled_enabled" private const val amoledEnabledKey = "amoled_enabled"
private const val liquidGlassNativeTabBarEnabledKey = "liquid_glass_native_tab_bar_enabled"
private const val selectedAppLanguageKey = "selected_app_language" private const val selectedAppLanguageKey = "selected_app_language"
private val profileScopedSyncKeys = listOf(selectedThemeKey, amoledEnabledKey) private val profileScopedSyncKeys = listOf(
selectedThemeKey,
amoledEnabledKey,
liquidGlassNativeTabBarEnabledKey,
)
private val globalSyncKeys = listOf(selectedAppLanguageKey) private val globalSyncKeys = listOf(selectedAppLanguageKey)
actual fun loadSelectedTheme(): String? = actual fun loadSelectedTheme(): String? =
@ -38,6 +43,23 @@ actual object ThemeSettingsStorage {
NSUserDefaults.standardUserDefaults.setBool(enabled, forKey = ProfileScopedKey.of(amoledEnabledKey)) 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? { actual fun loadSelectedAppLanguage(): String? {
val value = NSUserDefaults.standardUserDefaults.stringForKey(selectedAppLanguageKey) val value = NSUserDefaults.standardUserDefaults.stringForKey(selectedAppLanguageKey)
if (value != null) return value if (value != null) return value
@ -65,6 +87,7 @@ actual object ThemeSettingsStorage {
actual fun exportToSyncPayload(): JsonObject = buildJsonObject { actual fun exportToSyncPayload(): JsonObject = buildJsonObject {
loadSelectedTheme()?.let { put(selectedThemeKey, encodeSyncString(it)) } loadSelectedTheme()?.let { put(selectedThemeKey, encodeSyncString(it)) }
loadAmoledEnabled()?.let { put(amoledEnabledKey, encodeSyncBoolean(it)) } loadAmoledEnabled()?.let { put(amoledEnabledKey, encodeSyncBoolean(it)) }
loadLiquidGlassNativeTabBarEnabled()?.let { put(liquidGlassNativeTabBarEnabledKey, encodeSyncBoolean(it)) }
loadSelectedAppLanguage()?.let { put(selectedAppLanguageKey, encodeSyncString(it)) } loadSelectedAppLanguage()?.let { put(selectedAppLanguageKey, encodeSyncString(it)) }
} }
@ -78,6 +101,7 @@ actual object ThemeSettingsStorage {
payload.decodeSyncString(selectedThemeKey)?.let(::saveSelectedTheme) payload.decodeSyncString(selectedThemeKey)?.let(::saveSelectedTheme)
payload.decodeSyncBoolean(amoledEnabledKey)?.let(::saveAmoledEnabled) payload.decodeSyncBoolean(amoledEnabledKey)?.let(::saveAmoledEnabled)
payload.decodeSyncBoolean(liquidGlassNativeTabBarEnabledKey)?.let(::saveLiquidGlassNativeTabBarEnabled)
payload.decodeSyncString(selectedAppLanguageKey)?.let(::saveSelectedAppLanguage) payload.decodeSyncString(selectedAppLanguageKey)?.let(::saveSelectedAppLanguage)
applySelectedAppLanguage(loadSelectedAppLanguage() ?: AppLanguage.ENGLISH.code) applySelectedAppLanguage(loadSelectedAppLanguage() ?: AppLanguage.ENGLISH.code)
} }

View file

@ -1,3 +1,3 @@
CURRENT_PROJECT_VERSION=54 CURRENT_PROJECT_VERSION=54
MARKETING_VERSION=0.1.15 MARKETING_VERSION=0.1.0

View file

@ -2,8 +2,316 @@ import UIKit
import SwiftUI import SwiftUI
import ComposeApp import ComposeApp
final class RootComposeViewController: UIViewController { 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..<data.endIndex, in: data)
return regex.matches(in: data, range: range).compactMap { match in
guard let tokenRange = Range(match.range, in: data) else { return nil }
let token = String(data[tokenRange])
if token.count == 1, let character = token.first, character.isLetter {
return .command(character)
}
guard let value = Double(token) else { return nil }
return .number(CGFloat(value))
}
}
}
}
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 iconImage: UIImage {
switch self {
case .home: return NuvioNativeTabIcon.home
case .search: return NuvioNativeTabIcon.search
case .library: return NuvioNativeTabIcon.library
case .settings: return NuvioNativeTabIcon.profileFallback
}
}
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 nativeTabAccentColorKey = "NuvioNativeTabAccentColor"
private static let nativeProfileNameKey = "NuvioNativeProfileName"
private static let nativeProfileAvatarColorKey = "NuvioNativeProfileAvatarColor"
private static let nativeProfileAvatarURLKey = "NuvioNativeProfileAvatarURL"
private static let nativeProfileAvatarBackgroundColorKey = "NuvioNativeProfileAvatarBackgroundColor"
private static let nativeTabChromeDidChangeNotification = Notification.Name("NuvioNativeTabChromeDidChange")
private let contentController: UIViewController private let contentController: UIViewController
private let tabBar = UITabBar()
private var contentBottomToViewBottom: NSLayoutConstraint?
private var tabBarHeightConstraint: NSLayoutConstraint?
private var userDefaultsObserver: NSObjectProtocol?
private var tabChromeObserver: NSObjectProtocol?
private var profileAvatarImageURL: String?
private var profileAvatarImageTask: URLSessionDataTask?
private var profileAvatarImage: UIImage?
init(contentController: UIViewController) { init(contentController: UIViewController) {
self.contentController = contentController self.contentController = contentController
@ -20,17 +328,45 @@ final class RootComposeViewController: UIViewController {
view.backgroundColor = .black view.backgroundColor = .black
contentController.view.backgroundColor = .black contentController.view.backgroundColor = .black
UserDefaults.standard.set(false, forKey: Self.nativeTabBarVisibleKey)
addChild(contentController) addChild(contentController)
view.addSubview(contentController.view) view.addSubview(contentController.view)
contentController.view.translatesAutoresizingMaskIntoConstraints = false contentController.view.translatesAutoresizingMaskIntoConstraints = false
let bottomToViewBottom = contentController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor)
self.contentBottomToViewBottom = bottomToViewBottom
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
contentController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), contentController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
contentController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), contentController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
contentController.view.topAnchor.constraint(equalTo: view.topAnchor), contentController.view.topAnchor.constraint(equalTo: view.topAnchor),
contentController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), bottomToViewBottom,
]) ])
contentController.didMove(toParent: self) contentController.didMove(toParent: self)
configureNativeTabBar()
installNativeTabObservers()
syncNativeTabChrome(animated: false)
}
deinit {
if let userDefaultsObserver {
NotificationCenter.default.removeObserver(userDefaultsObserver)
}
if let tabChromeObserver {
NotificationCenter.default.removeObserver(tabChromeObserver)
}
profileAvatarImageTask?.cancel()
}
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? { override var childForHomeIndicatorAutoHidden: UIViewController? {
@ -88,6 +424,210 @@ final class RootComposeViewController: UIViewController {
return nil 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
let item = UITabBarItem(
title: tab.title,
image: tab.iconImage,
selectedImage: tab.iconImage
)
item.tag = tab.tag
return item
}
tabBar.selectedItem = tabBar.items?.first
applyNativeTabBarAppearance()
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()
applyNativeTabBarAppearance()
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 })
}
private func applyNativeTabBarAppearance() {
let accent = UIColor(hexString: UserDefaults.standard.string(forKey: Self.nativeTabAccentColorKey)) ??
UIColor(red: 0.96, green: 0.96, blue: 0.96, alpha: 1)
let unselected = UIColor(red: 150 / 255, green: 156 / 255, blue: 163 / 255, alpha: 1)
refreshProfileAvatarImageIfNeeded()
updateNativeTabImages(accent: accent)
tabBar.tintColor = accent
tabBar.unselectedItemTintColor = unselected
let appearance = tabBar.standardAppearance.copy() as! UITabBarAppearance
appearance.stackedLayoutAppearance.normal.iconColor = unselected
appearance.stackedLayoutAppearance.normal.titleTextAttributes = [.foregroundColor: unselected]
appearance.stackedLayoutAppearance.selected.iconColor = accent
appearance.stackedLayoutAppearance.selected.titleTextAttributes = [.foregroundColor: accent]
appearance.inlineLayoutAppearance.normal.iconColor = unselected
appearance.inlineLayoutAppearance.normal.titleTextAttributes = [.foregroundColor: unselected]
appearance.inlineLayoutAppearance.selected.iconColor = accent
appearance.inlineLayoutAppearance.selected.titleTextAttributes = [.foregroundColor: accent]
appearance.compactInlineLayoutAppearance.normal.iconColor = unselected
appearance.compactInlineLayoutAppearance.normal.titleTextAttributes = [.foregroundColor: unselected]
appearance.compactInlineLayoutAppearance.selected.iconColor = accent
appearance.compactInlineLayoutAppearance.selected.titleTextAttributes = [.foregroundColor: accent]
tabBar.standardAppearance = appearance
tabBar.scrollEdgeAppearance = appearance
}
private func updateNativeTabImages(accent: UIColor) {
tabBar.items?.forEach { item in
guard let tab = NativeTab(tag: item.tag) else { return }
item.image = nativeTabImage(for: tab, selected: false, accent: accent)
item.selectedImage = nativeTabImage(for: tab, selected: true, accent: accent)
}
}
private func nativeTabImage(for tab: NativeTab, selected: Bool, accent: UIColor) -> 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 { struct ComposeView: UIViewControllerRepresentable {