mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-18 07:51:46 +00:00
Merge branch 'ios' into cmp-rewrite
This commit is contained in:
commit
96c04c86e8
16 changed files with 976 additions and 60 deletions
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -449,6 +449,8 @@
|
|||
<string name="settings_appearance_app_language">App 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_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_section_display">DISPLAY</string>
|
||||
<string name="settings_appearance_section_home">HOME</string>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -122,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
|
||||
|
|
@ -260,6 +267,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,
|
||||
|
|
@ -293,13 +314,38 @@ fun App() {
|
|||
LaunchedEffect(Unit) {
|
||||
NetworkStatusRepository.ensureStarted()
|
||||
ProfileRepository.loadCachedProfiles()
|
||||
AvatarRepository.fetchAvatars()
|
||||
}
|
||||
|
||||
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<NuvioProfile?>(null) }
|
||||
var isNewProfile by remember { mutableStateOf(false) }
|
||||
|
|
@ -466,6 +512,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<MetaPreview?>(null) }
|
||||
var selectedContinueWatchingForActions by remember { mutableStateOf<ContinueWatchingItem?>(null) }
|
||||
|
|
@ -515,6 +566,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 +947,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 +956,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 +970,7 @@ private fun MainAppContent(
|
|||
containerColor = Color.Transparent,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
bottomBar = {
|
||||
if (!isTabletLayout) {
|
||||
if (!isTabletLayout && !useNativeBottomTabs) {
|
||||
NuvioNavigationBar {
|
||||
NavItem(
|
||||
selected = selectedTab == AppScreenTab.Home,
|
||||
|
|
@ -936,58 +1006,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 },
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
|
|
|||
|
|
@ -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?,
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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) },
|
||||
|
|
|
|||
|
|
@ -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<Boolean> = _amoledEnabled.asStateFlow()
|
||||
|
||||
private val _liquidGlassNativeTabBarEnabled = MutableStateFlow(false)
|
||||
val liquidGlassNativeTabBarEnabled: StateFlow<Boolean> = _liquidGlassNativeTabBarEnabled.asStateFlow()
|
||||
|
||||
private val _selectedAppLanguage = MutableStateFlow(AppLanguage.ENGLISH)
|
||||
val selectedAppLanguage: StateFlow<AppLanguage> = _selectedAppLanguage.asStateFlow()
|
||||
|
||||
|
|
@ -30,6 +34,9 @@ object ThemeSettingsRepository {
|
|||
hasLoaded = false
|
||||
_selectedTheme.value = AppTheme.WHITE
|
||||
_amoledEnabled.value = false
|
||||
_liquidGlassNativeTabBarEnabled.value = false
|
||||
NativeTabBridge.publishAccentColor(AppTheme.WHITE.nativeTabAccentHex())
|
||||
NativeTabBridge.publishLiquidGlassEnabled(false)
|
||||
_selectedAppLanguage.value = AppLanguage.ENGLISH
|
||||
}
|
||||
|
||||
|
|
@ -46,7 +53,11 @@ object ThemeSettingsRepository {
|
|||
AppTheme.WHITE
|
||||
}
|
||||
_selectedTheme.value = theme
|
||||
NativeTabBridge.publishAccentColor(theme.nativeTabAccentHex())
|
||||
_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
|
||||
|
|
@ -57,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) {
|
||||
|
|
@ -66,6 +78,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
|
||||
|
|
@ -74,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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
CURRENT_PROJECT_VERSION=54
|
||||
MARKETING_VERSION=0.1.15
|
||||
MARKETING_VERSION=0.1.0
|
||||
|
||||
|
|
|
|||
|
|
@ -2,8 +2,316 @@ import UIKit
|
|||
import SwiftUI
|
||||
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 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) {
|
||||
self.contentController = contentController
|
||||
|
|
@ -20,17 +328,45 @@ 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)
|
||||
}
|
||||
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? {
|
||||
|
|
@ -88,6 +424,210 @@ 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
|
||||
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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue