diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.android.kt index c7c556c5..8e860fa3 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.android.kt @@ -10,6 +10,13 @@ internal actual fun publishNativeSelectedTab(tabName: String) = Unit internal actual fun publishNativeTabAccentColor(hexColor: String) = Unit +internal actual fun publishNativeTabTitles( + home: String, + search: String, + library: String, + profile: String, +) = Unit + internal actual fun publishNativeProfileTabIcon( name: String?, avatarColorHex: String?, diff --git a/composeApp/src/commonMain/composeResources/values-cs/strings.xml b/composeApp/src/commonMain/composeResources/values-cs/strings.xml index 5e186e5f..298527b0 100644 --- a/composeApp/src/commonMain/composeResources/values-cs/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-cs/strings.xml @@ -453,7 +453,7 @@ Vyberte jazyk Nastavení pro sekci Pokračovat ve sledování. Tekuté sklo (Liquid Glass) - Použít nativní lištu panelů na iPhonu v iOS 26 a novějším. Okamžité přepínání profilů z lišty panelů není při zapnutí dostupné. + Použít nativní lištu panelů na iPhonu v iOS 26 a novějším. Vyladit šířku karty a poloměr rohů. ZOBRAZENÍ DOMŮ diff --git a/composeApp/src/commonMain/composeResources/values-fr/strings.xml b/composeApp/src/commonMain/composeResources/values-fr/strings.xml index 54479812..0fc34298 100644 --- a/composeApp/src/commonMain/composeResources/values-fr/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-fr/strings.xml @@ -530,7 +530,7 @@ Choisir la langue Afficher, masquer et ajuster le bandeau Continuer à regarder. Liquid Glass - Utilise la barre d’onglets native iPhone sur iOS 26 et versions ultérieures. Le changement instantané de profil depuis la barre d’onglets n’est pas disponible quand cette option est activée. + Utilise la barre d’onglets native iPhone sur iOS 26 et versions ultérieures. Ajuste la largeur des cartes et le rayon des coins. AFFICHAGE ACCUEIL diff --git a/composeApp/src/commonMain/composeResources/values-id/strings.xml b/composeApp/src/commonMain/composeResources/values-id/strings.xml index e442e971..35a923d9 100644 --- a/composeApp/src/commonMain/composeResources/values-id/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-id/strings.xml @@ -508,7 +508,7 @@ Pilih Bahasa Pengaturan untuk bagian Lanjutkan Menonton. Liquid Glass - Gunakan tab bar iPhone asli di iOS 26 dan yang lebih baru. Pergantian profil instan dari tab bar tidak tersedia saat ini aktif. + Gunakan tab bar iPhone asli di iOS 26 dan yang lebih baru. Sesuaikan lebar kartu dan radius sudut. TAMPILAN BERANDA diff --git a/composeApp/src/commonMain/composeResources/values-it/strings.xml b/composeApp/src/commonMain/composeResources/values-it/strings.xml index c7273958..d96e8195 100644 --- a/composeApp/src/commonMain/composeResources/values-it/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-it/strings.xml @@ -1240,7 +1240,7 @@ Utilizzato per la riproduzione sulle build Android. Rilasciato sotto la licenza Apache License, Versione 2.0. Liquid Glass - Usa la barra dei pannelli nativa dell'iPhone su iOS 26 e versioni successive. Il cambio rapido del profilo dalla barra dei pannelli non è disponibile quando questa opzione è attiva. + Usa la barra dei pannelli nativa dell'iPhone su iOS 26 e versioni successive. Nascondi contenuti non rilasciati Nascondi i film e le serie TV che non sono ancora stati rilasciati. Nascondi sottolineatura catalogo diff --git a/composeApp/src/commonMain/composeResources/values-nb/strings.xml b/composeApp/src/commonMain/composeResources/values-nb/strings.xml index 253a32cc..ad7cea8c 100644 --- a/composeApp/src/commonMain/composeResources/values-nb/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-nb/strings.xml @@ -492,7 +492,7 @@ Velg språk Innstillinger for Fortsett å se-seksjonen. Liquid Glass - Bruk den innebygde iPhone-fanen i iOS 26 og nyere. Umiddelbar profilbytte fra fanelinjen er ikke tilgjengelig mens dette er på. + Bruk den innebygde iPhone-fanen i iOS 26 og nyere. Juster kortbredde og hjørneradius. VISNING HJEM diff --git a/composeApp/src/commonMain/composeResources/values-pl/strings.xml b/composeApp/src/commonMain/composeResources/values-pl/strings.xml index e8091e03..89b367d0 100644 --- a/composeApp/src/commonMain/composeResources/values-pl/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-pl/strings.xml @@ -508,7 +508,7 @@ Wybierz język Pokaż, ukryj i stylizuj półkę Kontynuuj oglądanie. Liquid Glass - Użyj natywnego paska kart iPhone na iOS 26 i nowszych. Szybkie przełączanie profili z paska kart jest niedostępne, gdy ta opcja jest włączona. + Użyj natywnego paska kart iPhone na iOS 26 i nowszych. Dostosuj szerokość i zaokrąglenie rogów kart plakatów. WYŚWIETLANIE EKRAN GŁÓWNY diff --git a/composeApp/src/commonMain/composeResources/values-tr/strings.xml b/composeApp/src/commonMain/composeResources/values-tr/strings.xml index 9d54c44b..1340bde9 100644 --- a/composeApp/src/commonMain/composeResources/values-tr/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-tr/strings.xml @@ -498,7 +498,7 @@ Dil seç İzlemeye Devam Et rafını göster, gizle ve stilini ayarla. Liquid Glass - iOS 26 ve sonrasında yerel iPhone sekme çubuğunu kullan. Bu özellik açıkken sekme çubuğundan anlık profil geçişi yapılamaz. + iOS 26 ve sonrasında yerel iPhone sekme çubuğunu kullan. Uygulama genelindeki poster kartlarının ortak genişliğini ve köşe yuvarlaklığını ayarla. EKRAN ANA SAYFA diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 58cf290a..e86bea0b 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -540,7 +540,7 @@ Choose Language Settings for the Continue Watching section. Liquid Glass - Use the native iPhone tab bar on iOS 26 and later. Instant profile switching from the tab bar is unavailable while this is on. + Use the native iPhone tab bar on iOS 26 and later. Tune card width and corner radius. DISPLAY HOME diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 42fb5aaf..6c95ee56 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -106,6 +106,7 @@ 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.nuvio +import com.nuvio.app.core.ui.nuvioBottomNavigationBarInsets import com.nuvio.app.features.auth.AuthScreen import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.catalog.CatalogRepository @@ -159,6 +160,7 @@ 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.NativeProfileSwitcherPopup import com.nuvio.app.features.profiles.ProfileEditScreen import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.profiles.ProfileSelectionScreen @@ -733,6 +735,7 @@ private fun MainAppContent( val searchScrollToTopRequests = remember { MutableSharedFlow(extraBufferCapacity = 1) } val libraryScrollToTopRequests = remember { MutableSharedFlow(extraBufferCapacity = 1) } val settingsRootActionRequests = remember { MutableSharedFlow(extraBufferCapacity = 1) } + var nativeProfileSwitcherVisible by remember { mutableStateOf(false) } val currentBackStackEntry by navController.currentBackStackEntryAsState() val liquidGlassNativeTabBarEnabled by remember { ThemeSettingsRepository.liquidGlassNativeTabBarEnabled @@ -785,6 +788,10 @@ private fun MainAppContent( val cloudLibraryPlayFailedText = stringResource(Res.string.cloud_library_play_failed) val cloudLibraryPlayDisabledText = stringResource(Res.string.cloud_library_play_disabled) val cloudLibraryPlayNotConnectedText = stringResource(Res.string.cloud_library_play_not_connected) + val nativeTabHomeTitle = stringResource(Res.string.compose_nav_home) + val nativeTabSearchTitle = stringResource(Res.string.compose_nav_search) + val nativeTabLibraryTitle = stringResource(Res.string.compose_nav_library) + val nativeTabProfileTitle = stringResource(Res.string.compose_nav_profile) val isTraktLibrarySource = libraryUiState.sourceMode == LibrarySourceMode.TRAKT var initialHomeReady by rememberSaveable { mutableStateOf(false) } var offlineLaunchRouteHandled by rememberSaveable { mutableStateOf(false) } @@ -816,6 +823,28 @@ private fun MainAppContent( } } + LaunchedEffect(liquidGlassNativeTabBarSupported, liquidGlassNativeTabBarEnabled) { + NativeTabBridge.profileTabLongPresses.collectLatest { + if (liquidGlassNativeTabBarSupported && liquidGlassNativeTabBarEnabled) { + nativeProfileSwitcherVisible = true + } + } + } + + LaunchedEffect( + nativeTabHomeTitle, + nativeTabSearchTitle, + nativeTabLibraryTitle, + nativeTabProfileTitle, + ) { + NativeTabBridge.publishTabTitles( + home = nativeTabHomeTitle, + search = nativeTabSearchTitle, + library = nativeTabLibraryTitle, + profile = nativeTabProfileTitle, + ) + } + LaunchedEffect(selectedTab) { NativeTabBridge.publishSelectedTab(selectedTab.toNativeNavigationTab()) if (selectedTab != AppScreenTab.Search) { @@ -823,16 +852,20 @@ private fun MainAppContent( } } + var profileSwitchLoading by remember { mutableStateOf(false) } + DisposableEffect( navController, liquidGlassNativeTabBarSupported, liquidGlassNativeTabBarEnabled, initialHomeReady, + profileSwitchLoading, ) { fun publishNativeTabVisibilityForCurrentRoute() { val visible = liquidGlassNativeTabBarSupported && liquidGlassNativeTabBarEnabled && initialHomeReady && + !profileSwitchLoading && navController.currentDestination?.hasRoute() == true NativeTabBridge.publishTabBarVisible(visible) } @@ -942,7 +975,6 @@ private fun MainAppContent( SyncManager.requestForegroundPull(activeProfileId, force = true) } } - var profileSwitchLoading by remember { mutableStateOf(false) } var resumePromptItem by remember { mutableStateOf(null) } var lastExternalPlayerLaunch by remember { mutableStateOf(null) } val activePlaybackProfileId = profileState.activeProfile?.profileIndex ?: ProfileRepository.activeProfileId @@ -1434,9 +1466,16 @@ private fun MainAppContent( val isTabletLayout = maxWidth >= 768.dp val useNativeBottomTabs = liquidGlassNativeTabBarSupported && liquidGlassNativeTabBarEnabled && initialHomeReady + val nativeTabSafeBottomPadding = nuvioBottomNavigationBarInsets() + .asPaddingValues() + .calculateBottomPadding() + val nativeProfileTabAnchorBottomPadding = + nativeTabSafeBottomPadding + NuvioTokens.Space.s10 val tabsRouteActive = currentBackStackEntry?.destination?.hasRoute() == true val onProfileSelected: (NuvioProfile) -> Unit = { profile -> + nativeProfileSwitcherVisible = false profileSwitchLoading = true + NativeTabBridge.publishTabBarVisible(false) selectedTab = AppScreenTab.Home ProfileRepository.selectProfile(profile.profileIndex) com.nuvio.app.core.sync.SyncManager.pullAllForProfile(profile.profileIndex) @@ -1589,7 +1628,26 @@ private fun MainAppContent( selectedTab = selectedTab, onTabSelected = ::handleRootTabClick, onProfileSelected = onProfileSelected, - onAddProfileRequested = onSwitchProfile, + onAddProfileRequested = { + nativeProfileSwitcherVisible = false + onSwitchProfile() + }, + ) + } + + if (!isTabletLayout && useNativeBottomTabs && tabsRouteActive) { + NativeProfileSwitcherPopup( + visible = nativeProfileSwitcherVisible, + isSwitchingProfile = profileSwitchLoading, + onDismissRequest = { nativeProfileSwitcherVisible = false }, + onProfileSelected = onProfileSelected, + onAddProfileRequested = { + nativeProfileSwitcherVisible = false + onSwitchProfile() + }, + modifier = Modifier + .fillMaxSize() + .padding(bottom = nativeProfileTabAnchorBottomPadding), ) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.kt index aa426d02..2b9d4816 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.kt @@ -20,11 +20,17 @@ internal enum class NativeNavigationTab { internal object NativeTabBridge { private val _requestedTabs = MutableSharedFlow(extraBufferCapacity = 1) val requestedTabs: SharedFlow = _requestedTabs.asSharedFlow() + private val _profileTabLongPresses = MutableSharedFlow(extraBufferCapacity = 1) + val profileTabLongPresses: SharedFlow = _profileTabLongPresses.asSharedFlow() fun requestTab(tabName: String) { _requestedTabs.tryEmit(NativeNavigationTab.fromName(tabName)) } + fun requestProfileTabLongPress() { + _profileTabLongPresses.tryEmit(Unit) + } + fun publishSelectedTab(tab: NativeNavigationTab) { publishNativeSelectedTab(tab.name) } @@ -41,6 +47,15 @@ internal object NativeTabBridge { publishNativeTabAccentColor(hexColor) } + fun publishTabTitles( + home: String, + search: String, + library: String, + profile: String, + ) { + publishNativeTabTitles(home, search, library, profile) + } + fun publishProfileTabIcon( name: String?, avatarColorHex: String?, @@ -60,6 +75,10 @@ fun nativeTabSelect(tabName: String) { NativeTabBridge.requestTab(tabName) } +fun nativeProfileTabLongPress() { + NativeTabBridge.requestProfileTabLongPress() +} + internal expect fun isLiquidGlassNativeTabBarSupported(): Boolean internal expect fun publishLiquidGlassNativeTabBarEnabled(enabled: Boolean) @@ -70,6 +89,13 @@ internal expect fun publishNativeSelectedTab(tabName: String) internal expect fun publishNativeTabAccentColor(hexColor: String) +internal expect fun publishNativeTabTitles( + home: String, + search: String, + library: String, + profile: String, +) + internal expect fun publishNativeProfileTabIcon( name: String?, avatarColorHex: String?, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt index 5e812b28..5e9b4795 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt @@ -18,8 +18,10 @@ import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -140,8 +142,8 @@ fun ProfileSwitcherTab( if (profile.pinEnabled) { pinProfile = profile } else { - onProfileSelected(profile) showPopup = false + onProfileSelected(profile) } } @@ -336,6 +338,186 @@ fun ProfileSwitcherTab( } } +@Composable +fun NativeProfileSwitcherPopup( + visible: Boolean, + isSwitchingProfile: Boolean, + onDismissRequest: () -> Unit, + onProfileSelected: (NuvioProfile) -> Unit, + onAddProfileRequested: () -> Unit, + modifier: Modifier = Modifier, +) { + val profileState by ProfileRepository.state.collectAsStateWithLifecycle() + val activeProfile = profileState.activeProfile + val profiles = profileState.profiles + val avatars by AvatarRepository.avatars.collectAsStateWithLifecycle() + val tokens = MaterialTheme.nuvio + val haptic = LocalHapticFeedback.current + val density = LocalDensity.current + + var showPopup by remember { mutableStateOf(false) } + var popupVisible by remember { mutableStateOf(false) } + var pinProfile by remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { + AvatarRepository.fetchAvatars() + AvatarRepository.refreshAvatars() + } + + LaunchedEffect(visible, isSwitchingProfile, profiles.isNotEmpty()) { + if (visible && !isSwitchingProfile) { + if (profiles.isNotEmpty()) { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + showPopup = true + } else { + onDismissRequest() + showPopup = false + } + } else { + showPopup = false + } + } + + fun chooseProfile(profile: NuvioProfile) { + if (profile.pinEnabled) { + pinProfile = profile + } else { + showPopup = false + onDismissRequest() + onProfileSelected(profile) + } + } + + val popupAlpha = remember { Animatable(0f) } + val popupScale = remember { Animatable(0.5f) } + val popupTranslateY = remember { Animatable(40f) } + + LaunchedEffect(showPopup) { + if (showPopup) { + popupVisible = true + launch { popupAlpha.animateTo(1f, tween(220, easing = FastOutSlowInEasing)) } + launch { + popupScale.animateTo( + 1f, + spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium, + ), + ) + } + launch { + popupTranslateY.animateTo( + 0f, + spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium, + ), + ) + } + } else if (popupVisible) { + launch { popupAlpha.animateTo(0f, tween(180, easing = FastOutSlowInEasing)) } + launch { popupScale.animateTo(0.85f, tween(200, easing = FastOutSlowInEasing)) } + launch { + popupTranslateY.animateTo(30f, tween(200, easing = FastOutSlowInEasing)) + popupVisible = false + pinProfile = null + } + } + } + + BoxWithConstraints(modifier = modifier) { + val anchorWidth = maxWidth / 4 + Box( + modifier = Modifier + .align(Alignment.BottomEnd) + .fillMaxHeight() + .width(anchorWidth), + ) { + if (popupVisible && profiles.isNotEmpty() && !isSwitchingProfile) { + Popup( + alignment = Alignment.BottomCenter, + offset = IntOffset(0, with(density) { -NuvioTokens.Space.s64.roundToPx() }), + properties = PopupProperties(focusable = true), + onDismissRequest = onDismissRequest, + ) { + Box( + modifier = Modifier + .imePadding() + .graphicsLayer { + alpha = popupAlpha.value + scaleX = popupScale.value + scaleY = popupScale.value + translationY = popupTranslateY.value + } + .shadow(tokens.elevation.overlay, tokens.shapes.sheet) + .background( + tokens.colors.surfaceSheet, + tokens.shapes.sheet, + ) + .padding(tokens.spacing.sheetPadding), + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Row( + horizontalArrangement = Arrangement.spacedBy(tokens.spacing.cardPadding), + verticalAlignment = Alignment.Top, + ) { + profiles.forEachIndexed { index, profile -> + PopupProfileBubble( + profile = profile, + avatars = avatars, + isActive = profile.profileIndex == activeProfile?.profileIndex, + isSelected = pinProfile?.profileIndex == profile.profileIndex, + delayMs = index * 50, + onBoundsChanged = {}, + onClick = { chooseProfile(profile) }, + ) + } + + if (profiles.size < 4) { + PopupAddProfileBubble( + delayMs = profiles.size * 50, + onClick = { + showPopup = false + onDismissRequest() + onAddProfileRequested() + }, + ) + } + } + + AnimatedVisibility( + visible = pinProfile != null, + enter = expandVertically( + animationSpec = spring( + dampingRatio = Spring.DampingRatioLowBouncy, + stiffness = Spring.StiffnessMediumLow, + ), + ) + fadeIn(tween(200)), + exit = shrinkVertically(tween(150)) + fadeOut(tween(100)), + ) { + pinProfile?.let { profile -> + InlinePinEntry( + profileName = profile.name, + onVerified = { + showPopup = false + onDismissRequest() + onProfileSelected(profile) + }, + onCancel = { pinProfile = null }, + verifyPin = { pin -> + ProfileRepository.verifyPin(profile.profileIndex, pin) + }, + ) + } + } + } + } + } + } + } + } +} + @Composable private fun PopupAddProfileBubble( delayMs: Int, diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.ios.kt index 1b72da7c..6a3c7e7f 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.ios.kt @@ -9,6 +9,10 @@ private const val liquidGlassNativeTabBarEnabledKey = "NuvioLiquidGlassNativeTab private const val nativeTabBarVisibleKey = "NuvioNativeTabBarVisible" private const val nativeSelectedTabKey = "NuvioNativeSelectedTab" private const val nativeTabAccentColorKey = "NuvioNativeTabAccentColor" +private const val nativeTabTitleHomeKey = "NuvioNativeTabTitleHome" +private const val nativeTabTitleSearchKey = "NuvioNativeTabTitleSearch" +private const val nativeTabTitleLibraryKey = "NuvioNativeTabTitleLibrary" +private const val nativeTabTitleProfileKey = "NuvioNativeTabTitleProfile" private const val nativeProfileNameKey = "NuvioNativeProfileName" private const val nativeProfileAvatarColorKey = "NuvioNativeProfileAvatarColor" private const val nativeProfileAvatarUrlKey = "NuvioNativeProfileAvatarURL" @@ -38,6 +42,19 @@ internal actual fun publishNativeTabAccentColor(hexColor: String) { notifyNativeTabChromeChanged() } +internal actual fun publishNativeTabTitles( + home: String, + search: String, + library: String, + profile: String, +) { + publishString(nativeTabTitleHomeKey, home) + publishString(nativeTabTitleSearchKey, search) + publishString(nativeTabTitleLibraryKey, library) + publishString(nativeTabTitleProfileKey, profile) + notifyNativeTabChromeChanged() +} + internal actual fun publishNativeProfileTabIcon( name: String?, avatarColorHex: String?, diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift index 14f5664a..c8fb0338 100644 --- a/iosApp/iosApp/ContentView.swift +++ b/iosApp/iosApp/ContentView.swift @@ -269,7 +269,16 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate { } } - var title: String { + var titleKey: String { + switch self { + case .home: return "NuvioNativeTabTitleHome" + case .search: return "NuvioNativeTabTitleSearch" + case .library: return "NuvioNativeTabTitleLibrary" + case .settings: return "NuvioNativeTabTitleProfile" + } + } + + var fallbackTitle: String { switch self { case .home: return "Home" case .search: return "Search" @@ -278,6 +287,10 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate { } } + func localizedTitle(defaults: UserDefaults = .standard) -> String { + defaults.string(forKey: titleKey)?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? fallbackTitle + } + var iconImage: UIImage { switch self { case .home: return NuvioNativeTabIcon.home @@ -305,10 +318,13 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate { private let contentController: UIViewController private let tabBar = UITabBar() + private let profileTabTouchOverlay = UIControl() private var contentBottomToViewBottom: NSLayoutConstraint? private var tabBarHeightConstraint: NSLayoutConstraint? private var userDefaultsObserver: NSObjectProtocol? private var tabChromeObserver: NSObjectProtocol? + private var profileTouchRestoreTab: NativeTab? + private var profileLongPressHandled = false private var profileAvatarImageURL: String? private var profileAvatarImageTask: URLSessionDataTask? private var profileAvatarImage: UIImage? @@ -363,10 +379,14 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate { updateTabBarHeight() } + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + updateProfileTabTouchOverlayFrame() + } + 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) + selectNativeTab(tab) } override var childForHomeIndicatorAutoHidden: UIViewController? { @@ -441,7 +461,7 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate { tabBar.translatesAutoresizingMaskIntoConstraints = false tabBar.items = NativeTab.allCases.map { tab in let item = UITabBarItem( - title: tab.title, + title: tab.localizedTitle(), image: tab.iconImage, selectedImage: tab.iconImage ) @@ -454,6 +474,7 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate { tabBar.isHidden = true view.addSubview(tabBar) + configureProfileTabTouchOverlay() let heightConstraint = tabBar.heightAnchor.constraint(equalToConstant: tabBarHeight) tabBarHeightConstraint = heightConstraint NSLayoutConstraint.activate([ @@ -488,6 +509,7 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate { private func updateTabBarHeight() { tabBarHeightConstraint?.constant = tabBarHeight + updateProfileTabTouchOverlayFrame() } private func syncNativeTabChrome(animated: Bool) { @@ -499,15 +521,18 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate { contentBottomToViewBottom?.isActive = true if visible { tabBar.isHidden = false + profileTabTouchOverlay.isHidden = false } let changes = { self.tabBar.alpha = visible ? 1 : 0 + self.profileTabTouchOverlay.alpha = visible ? 1 : 0 self.view.layoutIfNeeded() } let completion: (Bool) -> Void = { _ in self.tabBar.isHidden = !visible + self.profileTabTouchOverlay.isHidden = !visible } if animated && view.window != nil { @@ -525,9 +550,118 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate { } private func syncSelectedNativeTab() { + tabBar.selectedItem = tabBar.items?.first(where: { $0.tag == currentNativeSelectedTab.tag }) + } + + @objc private func handleNativeProfileTabLongPress(_ recognizer: UILongPressGestureRecognizer) { + guard recognizer.state == .began else { return } + + profileLongPressHandled = true + DispatchQueue.main.async { + NativeTabBridgeKt.nativeProfileTabLongPress() + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.08) { [weak self] in + self?.restoreProfileTabTouchIfNeeded() + } + } + + @objc private func handleNativeProfileTabTouchDown() { + profileTouchRestoreTab = currentNativeSelectedTab + profileLongPressHandled = false + } + + @objc private func handleNativeProfileTabTap() { + if profileLongPressHandled { + profileLongPressHandled = false + restoreProfileTabTouchIfNeeded() + return + } + profileTouchRestoreTab = nil + selectNativeTab(.settings) + } + + @objc private func handleNativeProfileTabTouchCancel() { + profileLongPressHandled = false + restoreProfileTabTouchIfNeeded() + } + + private var currentNativeSelectedTab: NativeTab { 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 }) + return NativeTab(rawValue: rawValue) ?? .home + } + + private func selectNativeTab(_ tab: NativeTab) { + tabBar.selectedItem = tabBar.items?.first(where: { $0.tag == tab.tag }) + UserDefaults.standard.set(tab.rawValue, forKey: Self.nativeSelectedTabKey) + NativeTabBridgeKt.nativeTabSelect(tabName: tab.rawValue) + } + + private func configureProfileTabTouchOverlay() { + profileTabTouchOverlay.backgroundColor = .clear + profileTabTouchOverlay.isOpaque = false + profileTabTouchOverlay.isExclusiveTouch = true + profileTabTouchOverlay.accessibilityLabel = NativeTab.settings.localizedTitle() + profileTabTouchOverlay.accessibilityTraits = .button + profileTabTouchOverlay.addTarget( + self, + action: #selector(handleNativeProfileTabTouchDown), + for: .touchDown + ) + profileTabTouchOverlay.addTarget( + self, + action: #selector(handleNativeProfileTabTap), + for: .touchUpInside + ) + profileTabTouchOverlay.addTarget( + self, + action: #selector(handleNativeProfileTabTouchCancel), + for: [.touchCancel, .touchUpOutside] + ) + + let longPressRecognizer = UILongPressGestureRecognizer( + target: self, + action: #selector(handleNativeProfileTabLongPress(_:)) + ) + longPressRecognizer.minimumPressDuration = 0.45 + longPressRecognizer.cancelsTouchesInView = true + profileTabTouchOverlay.addGestureRecognizer(longPressRecognizer) + + profileTabTouchOverlay.alpha = 0 + profileTabTouchOverlay.isHidden = true + view.addSubview(profileTabTouchOverlay) + updateProfileTabTouchOverlayFrame() + } + + private func restoreProfileTabTouchIfNeeded() { + let tab = profileTouchRestoreTab ?? currentNativeSelectedTab + tabBar.selectedItem = tabBar.items?.first(where: { $0.tag == tab.tag }) + profileTouchRestoreTab = nil + } + + private func updateProfileTabTouchOverlayFrame() { + let tabCount = CGFloat(NativeTab.allCases.count) + guard tabCount > 0, tabBar.bounds.width > 0 else { + profileTabTouchOverlay.frame = .zero + return + } + + let itemWidth = tabBar.bounds.width / tabCount + let settingsIndex = CGFloat(NativeTab.settings.tag) + let visualIndex: CGFloat + if tabBar.effectiveUserInterfaceLayoutDirection == .rightToLeft { + visualIndex = tabCount - 1 - settingsIndex + } else { + visualIndex = settingsIndex + } + let overlayFrameInTabBar = CGRect( + x: itemWidth * visualIndex, + y: 0, + width: itemWidth, + height: tabBar.bounds.height + ) + profileTabTouchOverlay.frame = tabBar.convert(overlayFrameInTabBar, to: view) + profileTabTouchOverlay.alpha = tabBar.alpha + view.bringSubviewToFront(profileTabTouchOverlay) } private func applyNativeTabBarAppearance() { @@ -535,6 +669,7 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate { 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) + updateNativeTabTitles() refreshProfileAvatarImageIfNeeded() updateNativeTabImages(accent: accent) @@ -566,6 +701,14 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate { } } + private func updateNativeTabTitles() { + tabBar.items?.forEach { item in + guard let tab = NativeTab(tag: item.tag) else { return } + item.title = tab.localizedTitle() + } + profileTabTouchOverlay.accessibilityLabel = NativeTab.settings.localizedTitle() + } + private func nativeTabImage(for tab: NativeTab, selected: Bool, accent: UIColor) -> UIImage { guard tab == .settings else { return tab.iconImage @@ -630,6 +773,12 @@ private extension UIColor { } } +private extension String { + var nonEmpty: String? { + isEmpty ? nil : self + } +} + struct ComposeView: UIViewControllerRepresentable { func makeUIViewController(context: Context) -> UIViewController { // Register MPV player bridge before Compose initializes