diff --git a/app/src/main/java/app/closer/core/navigation/AppNavigation.kt b/app/src/main/java/app/closer/core/navigation/AppNavigation.kt index c7626edc..5b62747e 100644 --- a/app/src/main/java/app/closer/core/navigation/AppNavigation.kt +++ b/app/src/main/java/app/closer/core/navigation/AppNavigation.kt @@ -95,22 +95,32 @@ fun AppNavigation( val shellTitle = currentRoute ?.takeIf { it in shellBackRoutes } ?.let(AppRoute::titleFor) + // Tab-switch semantics: pop to the graph start, keep a single instance, and + // save/restore each tab's own back stack. Every navigation to a top-level + // route must go through this so a tab is never pushed on top of another tab. + val selectTab: (String) -> Unit = { route -> + navController.navigate(route) { + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + } val navigateBackOrHome: () -> Unit = { if (!navController.popBackStack()) { - navController.navigate(AppRoute.HOME) { - popUpTo(navController.graph.findStartDestination().id) { - saveState = true - } - launchSingleTop = true - restoreState = true - } + selectTab(AppRoute.HOME) } } val navigateRoute: (String) -> Unit = { route -> - if (route == "back") { - navigateBackOrHome() - } else { - navController.navigate(route) + when { + route == "back" -> navigateBackOrHome() + // Top-level tabs must use tab-switch semantics. Plain-navigating to a + // tab (e.g. a "Game waiting" card → PLAY) would stack it on the current + // tab; the bottom bar then saves that substack and `restoreState` later + // lands the user on the wrong tab (Home → Play was the symptom). + route in AppRoute.topLevelRoutes -> selectTab(route) + else -> navController.navigate(route) } } @@ -143,15 +153,7 @@ fun AppNavigation( if (currentRoute in bottomRoutes) { AppBottomNavigation( currentRoute = currentRoute, - onRouteSelected = { route -> - navController.navigate(route) { - popUpTo(navController.graph.findStartDestination().id) { - saveState = true - } - launchSingleTop = true - restoreState = true - } - } + onRouteSelected = selectTab ) } } diff --git a/app/src/main/java/app/closer/domain/usecase/GameSessionManager.kt b/app/src/main/java/app/closer/domain/usecase/GameSessionManager.kt index 5851537b..1bb0e887 100644 --- a/app/src/main/java/app/closer/domain/usecase/GameSessionManager.kt +++ b/app/src/main/java/app/closer/domain/usecase/GameSessionManager.kt @@ -84,7 +84,9 @@ class GameSessionManager @Inject constructor( val activeSession = sessionRepository.getActiveSessionForCouple(couple.id) if (activeSession != null) { val partnerId = couple.userIds.firstOrNull { it != userId } - val partnerName = partnerId?.let { userRepository.getUser(it) }?.displayName ?: "Partner" + val partnerName = partnerId + ?.let { runCatching { userRepository.getUser(it) }.getOrNull() } + ?.displayName ?: "Partner" return Result.failure( Exception("partner_active_session|$partnerName|${gameTypeLabel(gameType)}") ) diff --git a/app/src/main/java/app/closer/ui/desiresync/DesireSyncScreen.kt b/app/src/main/java/app/closer/ui/desiresync/DesireSyncScreen.kt index bb3342cc..5185f399 100644 --- a/app/src/main/java/app/closer/ui/desiresync/DesireSyncScreen.kt +++ b/app/src/main/java/app/closer/ui/desiresync/DesireSyncScreen.kt @@ -1008,8 +1008,9 @@ class DSReplayViewModel @Inject constructor( return@launch } val partnerId = couple.userIds.firstOrNull { it != uid } - val partnerName = partnerId?.let { gameSessionManager.getUser(it) }?.displayName - ?: "Your partner" + val partnerName = partnerId + ?.let { runCatching { gameSessionManager.getUser(it) }.getOrNull() } + ?.displayName ?: "Your partner" _uiState.update { it.copy(partnerName = partnerName) } val session = sessionRepository.getSessionById(couple.id, sessionId) ?: run { diff --git a/app/src/main/java/app/closer/ui/games/WaitingForPartnerScreen.kt b/app/src/main/java/app/closer/ui/games/WaitingForPartnerScreen.kt index 94fd69b1..3f3db6a4 100644 --- a/app/src/main/java/app/closer/ui/games/WaitingForPartnerScreen.kt +++ b/app/src/main/java/app/closer/ui/games/WaitingForPartnerScreen.kt @@ -62,11 +62,16 @@ class WaitingForPartnerViewModel @Inject constructor( init { viewModelScope.launch { val userId = gameSessionManager.currentUserId ?: return@launch - val couple = gameSessionManager.getCoupleForUser(userId) ?: return@launch + val couple = runCatching { gameSessionManager.getCoupleForUser(userId) }.getOrNull() + ?: return@launch coupleId = couple.id val partnerId = couple.userIds.firstOrNull { it != userId } - val partnerName = partnerId?.let { gameSessionManager.getUser(it) }?.displayName ?: "Partner" + // getUser() throws on Firestore failure (e.g. PERMISSION_DENIED); never let + // that escape this launch or the whole app crashes on the waiting screen. + val partnerName = partnerId + ?.let { runCatching { gameSessionManager.getUser(it) }.getOrNull() } + ?.displayName ?: "Partner" gameSessionManager.observeActiveSession(couple.id).collect { session -> if (session == null) { diff --git a/app/src/main/java/app/closer/ui/howwell/HowWellScreen.kt b/app/src/main/java/app/closer/ui/howwell/HowWellScreen.kt index 4908086b..5023f1f9 100644 --- a/app/src/main/java/app/closer/ui/howwell/HowWellScreen.kt +++ b/app/src/main/java/app/closer/ui/howwell/HowWellScreen.kt @@ -1179,8 +1179,9 @@ class HowWellReplayViewModel @Inject constructor( return@launch } val partnerId = couple.userIds.firstOrNull { it != uid } - val partnerName = partnerId?.let { gameSessionManager.getUser(it) }?.displayName - ?: "Your partner" + val partnerName = partnerId + ?.let { runCatching { gameSessionManager.getUser(it) }.getOrNull() } + ?.displayName ?: "Your partner" _uiState.update { it.copy(partnerName = partnerName) } val session = sessionRepository.getSessionById(couple.id, sessionId) ?: run { diff --git a/app/src/main/java/app/closer/ui/pairing/PairingSuccessScreen.kt b/app/src/main/java/app/closer/ui/pairing/PairingSuccessScreen.kt index 9a167a7d..584d1b41 100644 --- a/app/src/main/java/app/closer/ui/pairing/PairingSuccessScreen.kt +++ b/app/src/main/java/app/closer/ui/pairing/PairingSuccessScreen.kt @@ -93,7 +93,7 @@ class PairingSuccessViewModel @Inject constructor( init { viewModelScope.launch { val myId = authRepository.currentUserId ?: return@launch - val me = userRepository.getUser(myId) + val me = runCatching { userRepository.getUser(myId) }.getOrNull() val couple = coupleRepository.getCoupleForUser(myId) val partnerId = couple?.userIds?.firstOrNull { it != myId } val partner = partnerId?.let { runCatching { userRepository.getUser(it) }.getOrNull() } diff --git a/app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt b/app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt index 403cab9b..7f5e0aa1 100644 --- a/app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt +++ b/app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt @@ -1249,8 +1249,9 @@ class ThisOrThatReplayViewModel @Inject constructor( return@launch } val partnerId = couple.userIds.firstOrNull { it != uid } - val partnerName = partnerId?.let { gameSessionManager.getUser(it) }?.displayName - ?: "Your partner" + val partnerName = partnerId + ?.let { runCatching { gameSessionManager.getUser(it) }.getOrNull() } + ?.displayName ?: "Your partner" _uiState.update { it.copy(partnerName = partnerName) } val session = sessionRepository.getSessionById(couple.id, sessionId) ?: run {