package com.craigvg.lichun_android.ui.navigation import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionLayout import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.ui.NavDisplay import com.craigvg.lichun_android.managers.BillingManager import com.craigvg.lichun_android.navigation.DeepLinkRoute import com.craigvg.lichun_android.ui.components.ModalOverlayHost import com.craigvg.lichun_android.ui.components.ToastHost import com.craigvg.lichun_android.ui.components.TooltipHost import com.craigvg.lichun_android.ui.components.feedback.FloatingDeltaOverlay import com.craigvg.lichun_android.ui.components.navigation.CozyTabBar import com.craigvg.lichun_android.ui.components.navigation.CozyTabItem import com.craigvg.lichun_android.ui.screens.activities.ActivitiesScreen import com.craigvg.lichun_android.ui.screens.activities.ActivityDetailScreen import com.craigvg.lichun_android.ui.screens.activities.PerformActivityScreen import com.craigvg.lichun_android.ui.screens.character.DeathScreen import com.craigvg.lichun_android.ui.screens.character.FamilyTreeScreen import com.craigvg.lichun_android.ui.screens.character.PersonDetailScreen import com.craigvg.lichun_android.ui.screens.character.PrestigeScreen import com.craigvg.lichun_android.ui.screens.dating.DateActivitySelectionScreen import com.craigvg.lichun_android.ui.screens.dating.DateMiniGameScreen import com.craigvg.lichun_android.ui.screens.dating.RelationshipDetailScreen import com.craigvg.lichun_android.ui.screens.dating.RelationshipsScreen import com.craigvg.lichun_android.ui.screens.dating.SwipeDatingScreen import com.craigvg.lichun_android.ui.screens.home.HomeScreen import com.craigvg.lichun_android.ui.screens.home.MoreScreen import com.craigvg.lichun_android.ui.screens.messaging.ChatScreen import com.craigvg.lichun_android.ui.screens.onboarding.OnboardingContainerScreen import com.craigvg.lichun_android.ui.screens.retention.AchievementCollectionScreen import com.craigvg.lichun_android.ui.screens.retention.AchievementDetailScreen import com.craigvg.lichun_android.ui.screens.retention.AchievementsScreen import com.craigvg.lichun_android.ui.screens.retention.DailyQuestsScreen import com.craigvg.lichun_android.ui.screens.retention.DailyRewardsScreen import com.craigvg.lichun_android.ui.screens.retention.LifeGoalsScreen import com.craigvg.lichun_android.ui.screens.settings.AccountDeletionScreen import com.craigvg.lichun_android.ui.screens.settings.DataExportScreen import com.craigvg.lichun_android.ui.screens.settings.DebugToolsScreen import com.craigvg.lichun_android.ui.screens.settings.SettingsScreen import com.craigvg.lichun_android.ui.screens.social.SocialScreen import com.craigvg.lichun_android.ui.screens.store.ItemsScreen import com.craigvg.lichun_android.ui.screens.store.StoreScreen import com.craigvg.lichun_android.ui.theme.AppColors import com.craigvg.lichun_android.ui.theme.AppSpacing import com.craigvg.lichun_android.ui.theme.AppTypography import com.craigvg.lichun_android.viewmodel.GameStateViewModel import com.craigvg.lichun_android.viewmodel.PlayerViewModel import kotlinx.coroutines.flow.SharedFlow /** Duration of forward/back/predictive-back screen transitions, in milliseconds. */ private const val NAV_TRANSITION_MS = 320 /** * Root navigation built on Jetpack Navigation 3. * * Destinations are type-safe [androidx.navigation3.runtime.NavKey]s (see * [Destinations.kt]). Navigation state lives in [TabbedBackStack], which keeps a * separate back stack per bottom-bar tab plus a full-screen override stack for * Onboarding/Death. The bottom bar's visibility and selection are derived from * the current key *type* rather than a route-string `startsWith` checklist. */ @OptIn(ExperimentalSharedTransitionApi::class) @Composable fun MainNavigation( gameStateViewModel: GameStateViewModel = hiltViewModel(), billingManager: BillingManager? = null, initialDeepLinkRoute: DeepLinkRoute? = null, deepLinkEvents: SharedFlow? = null ) { val nav = remember { TabbedBackStack(TOP_LEVEL_DESTINATIONS, HomeKey) } var socialInitialPage by remember { mutableStateOf(0) } val playerViewModel: PlayerViewModel = hiltViewModel() val player by playerViewModel.player.collectAsStateWithLifecycle() val person by playerViewModel.person.collectAsStateWithLifecycle() val appLoaded by gameStateViewModel.appLoaded.collectAsStateWithLifecycle() val isConnected by gameStateViewModel.isConnected.collectAsStateWithLifecycle() // Screen view analytics — keyed off the current destination's simple name. val currentKey = nav.currentKey LaunchedEffect(currentKey) { gameStateViewModel.analyticsManager.logScreenView( currentKey::class.simpleName ?: "unknown" ) } fun handleDeepLink(route: DeepLinkRoute) { when (route) { DeepLinkRoute.Home -> nav.switchTab(HomeKey) DeepLinkRoute.Activities -> nav.switchTab(ActivitiesKey) is DeepLinkRoute.Social -> { socialInitialPage = when (route.segment) { DeepLinkRoute.SocialSegment.MESSAGES -> 1 else -> 0 } nav.switchTab(SocialKey) } DeepLinkRoute.Store -> nav.switchTab(StoreKey) DeepLinkRoute.Achievements -> nav.push(AchievementsKey) DeepLinkRoute.Characters -> nav.push(RelationshipsKey) DeepLinkRoute.DailyRewards -> nav.push(DailyRewardsKey) DeepLinkRoute.DailyQuests -> nav.push(DailyQuestsKey) DeepLinkRoute.SkipOnboarding -> { gameStateViewModel.setupCharacter("E2E Tester", 22, "Female") } is DeepLinkRoute.SetAge -> { // Server support is required before this can mutate character age. } DeepLinkRoute.DebugDump -> { // State dump is currently implemented on iOS only. } is DeepLinkRoute.DebugPreset -> gameStateViewModel.sendDebugSetup(route.preset) is DeepLinkRoute.DebugConfig -> { // MainActivity persists config before the WebSocket manager connects. } } } LaunchedEffect(initialDeepLinkRoute) { initialDeepLinkRoute?.let { handleDeepLink(it) } } LaunchedEffect(deepLinkEvents) { deepLinkEvents?.collect { route -> handleDeepLink(route) } } // Auto-navigate to Death or Onboarding. // // Death is keyed off the CHARACTER's status (`person.status`, parsed from the // server's `c` blob), NOT the top-level `player.status`. The server keeps // `player.status == "playing"` even after the character dies — only `c.status` // (→ `person.status`) flips to "dead". Routing on `player.status` here would // never fire. `person.status` is set on reconnect (playerObject `c`) and on a // mid-session death (lifeSummaryEvent handler), so both paths reach DeathKey. // Onboarding stays keyed off `player.status` (game-existence signal, distinct // from character death). LaunchedEffect(person.status, player.status, appLoaded) { if (!appLoaded) return@LaunchedEffect when { person.status == "dead" -> nav.resetTo(DeathKey) player.status == "" || player.status == "new" -> nav.resetTo(OnboardingKey) } } // Auto-show daily rewards on launch when available. val dailyRewardState by gameStateViewModel.dailyRewardState.collectAsStateWithLifecycle() var dailyRewardAutoShown by remember { mutableStateOf(false) } LaunchedEffect(dailyRewardState, appLoaded) { if (!appLoaded || dailyRewardAutoShown) return@LaunchedEffect val state = dailyRewardState ?: return@LaunchedEffect if (state.canClaim && !state.todaysClaimed && person.status != "dead" && player.status != "" && player.status != "new" ) { dailyRewardAutoShown = true nav.push(DailyRewardsKey) } } // Bottom-bar visibility is derived from the key type (no route-string checklist). val showBottomBar = nav.isTopLevel ModalOverlayHost(gameStateViewModel = gameStateViewModel) { Scaffold( bottomBar = { if (showBottomBar) { val selectedIndex = TOP_LEVEL_DESTINATIONS .indexOf(nav.topLevelKey) .coerceAtLeast(0) CozyTabBar( items = TOP_LEVEL_DESTINATIONS.map { dest -> CozyTabItem(label = dest.title, icon = dest.icon) }, selectedIndex = selectedIndex, onItemSelected = { index -> nav.switchTab(TOP_LEVEL_DESTINATIONS[index]) } ) } } ) { innerPadding -> Box(modifier = Modifier.fillMaxSize().padding(innerPadding)) { Column(modifier = Modifier.fillMaxSize()) { // Offline banner AnimatedVisibility(visible = !isConnected) { Box( modifier = Modifier .fillMaxWidth() .background(AppColors.error) .padding(vertical = AppSpacing.xs), contentAlignment = Alignment.Center ) { Text( text = "Reconnecting...", style = AppTypography.caption, color = androidx.compose.ui.graphics.Color.White ) } } SharedTransitionLayout(modifier = Modifier.weight(1f)) { CompositionLocalProvider(LocalSharedTransitionScope provides this) { NavDisplay( backStack = nav.backStack, onBack = { nav.removeLast() }, entryDecorators = listOf( rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator() ), sharedTransitionScope = this@SharedTransitionLayout, // Forward navigation: slide the incoming screen in from the right, // push the current screen out to the left. transitionSpec = { slideInHorizontally( animationSpec = tween(NAV_TRANSITION_MS), initialOffsetX = { it } ) + fadeIn(tween(NAV_TRANSITION_MS)) togetherWith slideOutHorizontally( animationSpec = tween(NAV_TRANSITION_MS), targetOffsetX = { -it / 4 } ) + fadeOut(tween(NAV_TRANSITION_MS)) }, // Pop (back button / programmatic): mirror image of the forward spec. popTransitionSpec = { slideInHorizontally( animationSpec = tween(NAV_TRANSITION_MS), initialOffsetX = { -it / 4 } ) + fadeIn(tween(NAV_TRANSITION_MS)) togetherWith slideOutHorizontally( animationSpec = tween(NAV_TRANSITION_MS), targetOffsetX = { it } ) + fadeOut(tween(NAV_TRANSITION_MS)) }, // Predictive back: the same pop animation, driven by the system // back-gesture so the user sees a live preview of the destination. predictivePopTransitionSpec = { slideInHorizontally( animationSpec = tween(NAV_TRANSITION_MS), initialOffsetX = { -it / 4 } ) + fadeIn(tween(NAV_TRANSITION_MS)) togetherWith slideOutHorizontally( animationSpec = tween(NAV_TRANSITION_MS), targetOffsetX = { it } ) + fadeOut(tween(NAV_TRANSITION_MS)) }, entryProvider = entryProvider { // ---- Top-level tabs ---- entry { HomeScreen( gameStateViewModel = gameStateViewModel, onSettings = { nav.push(SettingsKey) }, onCharacters = { nav.push(RelationshipsKey) }, onMessages = { socialInitialPage = 1 nav.switchTab(SocialKey) }, onMore = { nav.push(MoreKey) }, onCharacterTap = { nav.push(MoreKey) }, onEnergyRefill = { gameStateViewModel.showEnergyRefillModal() }, onDailyQuests = { nav.push(DailyQuestsKey) }, onDailyRewards = { nav.push(DailyRewardsKey) }, onLifeGoals = { nav.push(LifeGoalsKey) }, onPerformActivity = { nav.push(PerformActivityKey) } ) } entry { ActivitiesScreen( onNavigateToActivityDetail = { activityId -> nav.push(ActivityDetailKey(activityId)) }, onPerformActivity = { nav.push(PerformActivityKey) } ) } entry { SocialScreen( initialPage = socialInitialPage, onConversationClick = { characterId -> nav.push(ChatKey(characterId)) }, onViewRelationship = { personId -> nav.push(RelationshipDetailKey(personId)) }, onStartChat = { characterId -> nav.push(ChatKey(characterId)) }, onGoOnDate = { personId -> nav.push(DateActivitySelectionKey(personId)) } ) } entry { StoreScreen(billingManager = billingManager) } // ---- Detail screens ---- entry { key -> ChatScreen( characterId = key.characterId, onBack = { nav.removeLast() } ) } entry { key -> PersonDetailScreen( personId = key.personId, onBack = { nav.removeLast() }, onStartChat = { characterId -> nav.push(ChatKey(characterId)) } ) } // ---- Settings ---- entry { val pvm: PlayerViewModel = hiltViewModel() val personId = pvm.person.collectAsStateWithLifecycle().value.id SettingsScreen( onBack = { nav.removeLast() }, onDataExport = { nav.push(DataExportKey) }, onAccountDeletion = { nav.push(AccountDeletionKey) }, onAchievements = { nav.push(AchievementsKey) }, onDailyRewards = { nav.push(DailyRewardsKey) }, onDailyQuests = { nav.push(DailyQuestsKey) }, onProfile = { if (personId.isNotEmpty()) { nav.push(PersonDetailKey(personId)) } }, onDebugTools = { nav.push(DebugToolsKey) } ) } entry { DebugToolsScreen(onBack = { nav.removeLast() }) } entry { DataExportScreen(onBack = { nav.removeLast() }) } entry { AccountDeletionScreen(onBack = { nav.removeLast() }) } // ---- Retention ---- entry { AchievementsScreen( onBack = { nav.removeLast() }, onAchievementClick = { achievement -> nav.push(AchievementDetailKey(achievement.id)) } ) } entry { key -> AchievementDetailScreen( achievementId = key.achievementId, onBack = { nav.removeLast() } ) } entry { DailyRewardsScreen(onBack = { nav.removeLast() }) } entry { DailyQuestsScreen(onBack = { nav.removeLast() }) } entry { LifeGoalsScreen(onBack = { nav.removeLast() }) } entry { AchievementCollectionScreen(onBack = { nav.removeLast() }) } entry { PerformActivityScreen(onBack = { nav.removeLast() }) } entry { PrestigeScreen(onBack = { nav.removeLast() }) } // ---- Dating ---- entry { SwipeDatingScreen( onBack = { nav.removeLast() }, onMatch = { person -> nav.push(RelationshipDetailKey(person.id)) } ) } entry { RelationshipsScreen( onBack = { nav.removeLast() }, onRelationshipClick = { person -> nav.push(RelationshipDetailKey(person.id)) } ) } entry { key -> RelationshipDetailScreen( personId = key.personId, onBack = { nav.removeLast() }, onStartChat = { characterId -> nav.push(ChatKey(characterId)) }, onGoOnDate = { nav.push(DateActivitySelectionKey(key.personId)) } ) } entry { key -> val pvm: PlayerViewModel = hiltViewModel() val person = pvm.findPersonById(key.personId) if (person != null) { DateActivitySelectionScreen( partner = person, onBack = { nav.removeLast() }, onActivitySelected = { activity -> if (activity.hasMiniGame) { nav.push(DateMiniGameKey(key.personId)) } else { nav.removeLast() } } ) } else { LaunchedEffect(Unit) { nav.removeLast() } } } entry { key -> val pvm: PlayerViewModel = hiltViewModel() val person = pvm.findPersonById(key.personId) if (person != null) { DateMiniGameScreen( partner = person, onComplete = { affinityGained -> pvm.updateAffinity(key.personId, affinityGained) nav.removeLast() }, onDismiss = { nav.removeLast() } ) } else { LaunchedEffect(Unit) { nav.removeLast() } } } // ---- Activities ---- entry { key -> ActivityDetailScreen( activityId = key.activityId, onBack = { nav.removeLast() }, onPersonClick = { personId -> nav.push(PersonDetailKey(personId)) } ) } // ---- More / Items ---- entry { MoreScreen( gameStateViewModel = gameStateViewModel, onBack = { nav.removeLast() }, onSettings = { nav.push(SettingsKey) }, onAchievements = { nav.push(AchievementsKey) }, onAchievementCollection = { nav.push(AchievementCollectionKey) }, onDailyRewards = { nav.push(DailyRewardsKey) }, onDailyQuests = { nav.push(DailyQuestsKey) }, onLifeGoals = { nav.push(LifeGoalsKey) }, onPrestige = { nav.push(PrestigeKey) }, onPerformActivity = { nav.push(PerformActivityKey) }, onStore = { nav.switchTab(StoreKey) }, onRelationships = { nav.push(RelationshipsKey) }, onItems = { nav.push(ItemsKey) }, onEnergyRefill = { gameStateViewModel.showEnergyRefillModal() } ) } entry { ItemsScreen(onBack = { nav.removeLast() }) } // ---- Full-screen flows ---- entry { OnboardingContainerScreen( onComplete = { nav.resetTo(HomeKey) } ) } entry { DeathScreen( gameStateViewModel = gameStateViewModel, onRestart = { nav.resetTo(OnboardingKey) }, onViewFamilyTree = { nav.push(FamilyTreeKey) } ) } entry { FamilyTreeScreen( gameStateViewModel = gameStateViewModel, onBack = { nav.removeLast() } ) } } ) // NavDisplay } // CompositionLocalProvider } // SharedTransitionLayout } // Column // Tier 3 polish: floating resource/stat deltas (rise + fade). FloatingDeltaOverlay(gameStateViewModel = gameStateViewModel) // Toast overlay ToastHost(gameStateViewModel = gameStateViewModel) // Tooltip overlay (hide during onboarding/death/detail) if (showBottomBar) { TooltipHost( gameStateViewModel = gameStateViewModel, modifier = Modifier.align(Alignment.BottomCenter) ) } } // Box } } // ModalOverlayHost }