feat(challenges): abandon challenge flow; fix(play): premium lock on history; fix(memory-lane): null-safe detail state

This commit is contained in:
null 2026-06-23 10:51:14 -05:00
parent 58be8ed021
commit 015ac8eefe
4 changed files with 118 additions and 28 deletions

View File

@ -67,6 +67,10 @@ class FirestoreChallengeDataSource @Inject constructor(private val db: FirebaseF
challengeRef(coupleId, challengeId).update("status", "completed").await() challengeRef(coupleId, challengeId).update("status", "completed").await()
} }
suspend fun abandonChallenge(coupleId: String, challengeId: String) {
challengeRef(coupleId, challengeId).update("status", "abandoned").await()
}
fun observeProgress( fun observeProgress(
coupleId: String, coupleId: String,
challengeId: String, challengeId: String,

View File

@ -25,8 +25,10 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Lock
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.TextButton
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import app.closer.ui.components.CloserHeartLoader import app.closer.ui.components.CloserHeartLoader
@ -94,7 +96,8 @@ data class ChallengesUiState(
val partnerId: String? = null, val partnerId: String? = null,
val hasPremium: Boolean = false, val hasPremium: Boolean = false,
val error: String? = null, val error: String? = null,
val navigateTo: String? = null val navigateTo: String? = null,
val showAbandonConfirm: Boolean = false
) )
@HiltViewModel @HiltViewModel
@ -227,6 +230,32 @@ class ConnectionChallengesViewModel @Inject constructor(
fun dismissError() = _uiState.update { it.copy(error = null) } fun dismissError() = _uiState.update { it.copy(error = null) }
fun showAbandonConfirm() = _uiState.update { it.copy(showAbandonConfirm = true) }
fun dismissAbandonConfirm() = _uiState.update { it.copy(showAbandonConfirm = false) }
fun abandonChallenge() {
val state = _uiState.value
val coupleId = state.coupleId ?: return
val challengeId = state.activeChallenge?.id ?: return
_uiState.update { it.copy(showAbandonConfirm = false) }
progressJob?.cancel()
viewModelScope.launch {
runCatching { challengeDataSource.abandonChallenge(coupleId, challengeId) }
.onSuccess {
_uiState.update { it.copy(
phase = ChallengesPhase.PICK,
activeChallenge = null,
progress = null,
challengeState = null
) }
}
.onFailure { e ->
Log.w(TAG, "Could not abandon challenge", e)
_uiState.update { s -> s.copy(error = "Could not stop the challenge. Try again.") }
}
}
}
fun onNavigated() = _uiState.update { it.copy(navigateTo = null) } fun onNavigated() = _uiState.update { it.copy(navigateTo = null) }
companion object { companion object {
@ -278,7 +307,11 @@ fun ConnectionChallengesScreen(
challengeState = state.challengeState, challengeState = state.challengeState,
onBack = { onNavigate(AppRoute.PLAY) }, onBack = { onNavigate(AppRoute.PLAY) },
onMarkComplete = { viewModel.markTodayComplete() }, onMarkComplete = { viewModel.markTodayComplete() },
onViewHistory = { onNavigate(AppRoute.GAME_HISTORY) } onViewHistory = { onNavigate(AppRoute.GAME_HISTORY) },
showAbandonConfirm = state.showAbandonConfirm,
onRequestAbandon = { viewModel.showAbandonConfirm() },
onConfirmAbandon = { viewModel.abandonChallenge() },
onDismissAbandon = { viewModel.dismissAbandonConfirm() }
) )
} }
} }
@ -451,8 +484,27 @@ private fun ChallengesActiveScreen(
challengeState: ChallengeState?, challengeState: ChallengeState?,
onBack: () -> Unit, onBack: () -> Unit,
onMarkComplete: () -> Unit, onMarkComplete: () -> Unit,
onViewHistory: () -> Unit onViewHistory: () -> Unit,
showAbandonConfirm: Boolean,
onRequestAbandon: () -> Unit,
onConfirmAbandon: () -> Unit,
onDismissAbandon: () -> Unit
) { ) {
if (showAbandonConfirm) {
AlertDialog(
onDismissRequest = onDismissAbandon,
title = { Text("Stop this challenge?") },
text = { Text("Your progress will be lost and you can start a new challenge.") },
confirmButton = {
TextButton(onClick = onConfirmAbandon) {
Text("Stop it", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = onDismissAbandon) { Text("Keep going") }
}
)
}
val cs = challengeState val cs = challengeState
val isComplete = cs?.isComplete == true || cs?.state == ChallengeStatus.CHALLENGE_COMPLETE val isComplete = cs?.isComplete == true || cs?.state == ChallengeStatus.CHALLENGE_COMPLETE
val currentDay = cs?.currentDay ?: progress.myNextDay.coerceAtMost(challenge.durationDays) val currentDay = cs?.currentDay ?: progress.myNextDay.coerceAtMost(challenge.durationDays)
@ -642,7 +694,7 @@ private fun ChallengesActiveScreen(
} }
} }
if (ctaLabel != null) { if (ctaLabel != null && cs?.state != ChallengeStatus.BOTH_COMPLETED_TODAY) {
item { item {
Button( Button(
onClick = ctaAction, onClick = ctaAction,
@ -698,6 +750,21 @@ private fun ChallengesActiveScreen(
} }
} }
if (!isComplete) {
item {
TextButton(
onClick = onRequestAbandon,
modifier = Modifier.fillMaxWidth()
) {
Text(
"Stop challenge",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.error.copy(alpha = 0.55f)
)
}
}
}
item { Spacer(Modifier.height(8.dp)) } item { Spacer(Modifier.height(8.dp)) }
} }
} }

View File

@ -399,22 +399,26 @@ fun MemoryLaneScreen(
onSave = { viewModel.saveEdit() } onSave = { viewModel.saveEdit() }
) )
MemoryLanePhase.DETAIL -> { MemoryLanePhase.DETAIL -> {
val capsule = state.selectedCapsule!! val capsule = state.selectedCapsule
CapsuleDetailScreen( if (capsule != null) {
capsule = capsule, CapsuleDetailScreen(
userId = state.userId ?: "", capsule = capsule,
onBack = { viewModel.backToList() }, userId = state.userId ?: "",
onEdit = if (!capsule.isUnlocked && capsule.authorId == state.userId) { onBack = { viewModel.backToList() },
{ viewModel.openEdit(capsule) } onEdit = if (!capsule.isUnlocked && capsule.authorId == state.userId) {
} else null, { viewModel.openEdit(capsule) }
onDelete = if (capsule.authorId == state.userId) { } else null,
{ viewModel.showDeleteConfirm() } onDelete = if (capsule.authorId == state.userId) {
} else null, { viewModel.showDeleteConfirm() }
showDeleteConfirm = state.showDeleteConfirm, } else null,
isDeleting = state.isDeleting, showDeleteConfirm = state.showDeleteConfirm,
onConfirmDelete = { viewModel.deleteCapsule() }, isDeleting = state.isDeleting,
onDismissDelete = { viewModel.dismissDeleteConfirm() } onConfirmDelete = { viewModel.deleteCapsule() },
) onDismissDelete = { viewModel.dismissDeleteConfirm() }
)
} else {
LaunchedEffect(Unit) { viewModel.backToList() }
}
} }
MemoryLanePhase.ERROR -> MemoryLaneErrorScreen( MemoryLanePhase.ERROR -> MemoryLaneErrorScreen(
message = state.error ?: "Something went wrong.", message = state.error ?: "Something went wrong.",

View File

@ -180,6 +180,7 @@ private fun PlayHubContent(
subtitle = "All results", subtitle = "All results",
icon = Icons.Filled.Home, icon = Icons.Filled.Home,
tint = CloserPalette.PurpleDeep, tint = CloserPalette.PurpleDeep,
locked = !hasPremium,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
onClick = { onNavigate(AppRoute.GAME_HISTORY) } onClick = { onNavigate(AppRoute.GAME_HISTORY) }
) )
@ -652,6 +653,7 @@ private fun CompactPlayCard(
subtitle: String, subtitle: String,
icon: ImageVector, icon: ImageVector,
tint: Color, tint: Color,
locked: Boolean = false,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onClick: () -> Unit onClick: () -> Unit
) { ) {
@ -695,14 +697,27 @@ private fun CompactPlayCard(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text( Row(
text = subtitle, verticalAlignment = Alignment.CenterVertically,
style = MaterialTheme.typography.bodySmall, horizontalArrangement = Arrangement.spacedBy(3.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f)
modifier = Modifier.weight(1f), ) {
maxLines = 1, if (locked) {
overflow = TextOverflow.Ellipsis Icon(
) imageVector = Icons.Filled.Lock,
contentDescription = null,
tint = CloserPalette.Gold,
modifier = Modifier.size(11.dp)
)
}
Text(
text = if (locked) "Premium" else subtitle,
style = MaterialTheme.typography.bodySmall,
color = if (locked) CloserPalette.Gold else MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Icon( Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowForward, imageVector = Icons.AutoMirrored.Filled.ArrowForward,
contentDescription = null, contentDescription = null,