feat(history): tappable challenge/capsule cards, deep-link to capsule detail, emoji per game type, dedicated error card; feat(nav): MEMORY_LANE_CAPSULE route

This commit is contained in:
null 2026-06-23 11:19:14 -05:00
parent 658ead38cd
commit b854c0b391
5 changed files with 94 additions and 5 deletions

View File

@ -415,6 +415,15 @@ fun AppNavigation(
) {
MemoryLaneScreen(onNavigate = navigateRoute)
}
composable(
route = AppRoute.MEMORY_LANE_CAPSULE,
arguments = listOf(navArgument("capsuleId") { type = NavType.StringType })
) {
MemoryLaneScreen(
onNavigate = navigateRoute,
startingCapsuleId = it.arguments?.getString("capsuleId")
)
}
composable(route = AppRoute.WAITING_FOR_PARTNER) {
WaitingForPartnerScreen(
onNavigate = navigateRoute

View File

@ -50,6 +50,7 @@ object AppRoute {
const val DESIRE_SYNC = "desire_sync"
const val CONNECTION_CHALLENGES = "connection_challenges"
const val MEMORY_LANE = "memory_lane"
const val MEMORY_LANE_CAPSULE = "memory_lane_capsule/{capsuleId}"
const val WAITING_FOR_PARTNER = "waiting_for_partner"
const val RECOVERY = "recovery"
const val ENCRYPTION_UPGRADE = "encryption_upgrade"
@ -58,6 +59,8 @@ object AppRoute {
fun pairingSuccess(coupleId: String) = "pairing_success/$coupleId"
fun memorylaneDetail(capsuleId: String): String = "memory_lane_capsule/${capsuleId.asRouteArg()}"
// Question thread: coupleId and questionId are required; prevId and nextId are optional.
const val QUESTION_THREAD =
"question_thread/{coupleId}/{questionId}?prevId={prevId}&nextId={nextId}"
@ -116,6 +119,7 @@ object AppRoute {
Definition(DESIRE_SYNC, "Desire Sync", "play"),
Definition(CONNECTION_CHALLENGES, "Connection Challenges", "play"),
Definition(MEMORY_LANE, "Memory Lane", "play"),
Definition(MEMORY_LANE_CAPSULE, "Memory Lane", "play"),
Definition(WAITING_FOR_PARTNER, "Waiting for Partner", "play"),
Definition(RECOVERY, "Unlock Answers", "security"),
Definition(ENCRYPTION_UPGRADE, "Secure Answers", "security"),
@ -175,6 +179,7 @@ object AppRoute {
SECURITY,
CONNECTION_CHALLENGES,
MEMORY_LANE,
MEMORY_LANE_CAPSULE,
WAITING_FOR_PARTNER,
YOUR_PROGRESS
)

View File

@ -748,6 +748,24 @@ private fun ChallengesActiveScreen(
}
}
}
item {
Button(
onClick = onViewHistory,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 54.dp),
shape = RoundedCornerShape(18.dp),
colors = ButtonDefaults.buttonColors(
containerColor = CloserPalette.PurpleDeep
)
) {
Text(
text = "View game history",
style = MaterialTheme.typography.labelLarge,
color = Color.White
)
}
}
}
if (!isComplete) {

View File

@ -58,6 +58,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -328,11 +329,25 @@ class MemoryLaneViewModel @Inject constructor(
@Composable
fun MemoryLaneScreen(
onNavigate: (String) -> Unit = {},
startingCapsuleId: String? = null,
viewModel: MemoryLaneViewModel = hiltViewModel()
) {
val state by viewModel.uiState.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
// Deep-link: open a specific capsule once the list is loaded. The flag prevents
// re-opening if the user navigates back to the list and capsules update again.
val deepLinkHandled = remember { mutableStateOf(false) }
LaunchedEffect(startingCapsuleId, state.capsules) {
if (startingCapsuleId == null || deepLinkHandled.value) return@LaunchedEffect
if (state.capsules.isEmpty()) return@LaunchedEffect
val capsule = state.capsules.firstOrNull { it.id == startingCapsuleId }
if (capsule != null) {
deepLinkHandled.value = true
viewModel.openDetail(capsule)
}
}
LaunchedEffect(state.navigateTo) {
state.navigateTo?.let { onNavigate(it); viewModel.onNavigated() }
}

View File

@ -31,6 +31,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
@ -108,7 +109,7 @@ fun GameHistoryScreen(
GameHistoryLockedCard(onUnlock = { onNavigate(AppRoute.PAYWALL) })
}
state.isLoading -> item { LoadingState(message = "Loading your games…") }
state.error != null -> item {
!hasAny && state.error != null -> item {
ErrorState(
message = state.error!!,
onRetry = viewModel::load
@ -131,17 +132,45 @@ fun GameHistoryScreen(
onClick = sessionReplayRoute(session)?.let { route -> { onNavigate(route) } }
)
}
} else if (state.error != null) {
item {
Card(
modifier = Modifier.fillMaxWidth(),
shape = androidx.compose.foundation.shape.RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.5f)
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 10.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Couldn't load game sessions",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.weight(1f)
)
TextButton(onClick = viewModel::load) {
Text("Retry", color = MaterialTheme.colorScheme.error)
}
}
}
}
}
if (state.completedChallenges.isNotEmpty()) {
item { HistorySectionHeader("Completed Challenges") }
items(state.completedChallenges, key = { it.challengeId }) { entry ->
ChallengeHistoryCard(entry)
ChallengeHistoryCard(entry, onClick = { onNavigate(AppRoute.CONNECTION_CHALLENGES) })
}
}
if (state.unlockedCapsules.isNotEmpty()) {
item { HistorySectionHeader("Memory Lane") }
items(state.unlockedCapsules, key = { it.id }) { capsule ->
CapsuleHistoryCard(capsule)
CapsuleHistoryCard(capsule, onClick = { onNavigate(AppRoute.memorylaneDetail(capsule.id)) })
}
}
}
@ -167,6 +196,7 @@ private fun WheelSessionCard(session: QuestionSession, onClick: (() -> Unit)?) {
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(sessionEmoji(session), style = MaterialTheme.typography.headlineSmall)
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = sessionTitle(session),
@ -203,8 +233,10 @@ private fun HistorySectionHeader(title: String) {
}
@Composable
private fun ChallengeHistoryCard(entry: CompletedChallengeEntry) {
private fun ChallengeHistoryCard(entry: CompletedChallengeEntry, onClick: (() -> Unit)? = null) {
Card(
onClick = onClick ?: {},
enabled = onClick != null,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = closerCardColor(alpha = 0.88f)),
@ -241,8 +273,10 @@ private fun ChallengeHistoryCard(entry: CompletedChallengeEntry) {
}
@Composable
private fun CapsuleHistoryCard(capsule: TimeCapsule) {
private fun CapsuleHistoryCard(capsule: TimeCapsule, onClick: (() -> Unit)? = null) {
Card(
onClick = onClick ?: {},
enabled = onClick != null,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = closerCardColor(alpha = 0.88f)),
@ -288,6 +322,14 @@ private fun sessionTitle(session: QuestionSession): String = when (session.gameT
else -> "Game Session"
}
private fun sessionEmoji(session: QuestionSession): String = when (session.gameType) {
GameType.THIS_OR_THAT -> "🔀"
GameType.HOW_WELL -> "🎯"
GameType.DESIRE_SYNC -> "💋"
GameType.WHEEL -> "🎡"
else -> "🎮"
}
private fun sessionReplayRoute(session: QuestionSession): String? = when (session.gameType) {
GameType.THIS_OR_THAT -> AppRoute.thisOrThatReplay(session.id)
GameType.HOW_WELL -> AppRoute.howWellReplay(session.id)