fix(nav): tab-switch routing prevents stacking tabs; fix(crash): runCatching around getUser/getCoupleForUser across 6 screens

This commit is contained in:
null 2026-06-23 14:35:01 -05:00
parent fe1808b36c
commit 17c7ed60b9
7 changed files with 42 additions and 30 deletions

View File

@ -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
)
}
}

View File

@ -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)}")
)

View File

@ -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 {

View File

@ -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) {

View File

@ -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 {

View File

@ -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() }

View File

@ -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 {