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()
}
suspend fun abandonChallenge(coupleId: String, challengeId: String) {
challengeRef(coupleId, challengeId).update("status", "abandoned").await()
}
fun observeProgress(
coupleId: 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.filled.Check
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.TextButton
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import app.closer.ui.components.CloserHeartLoader
@ -94,7 +96,8 @@ data class ChallengesUiState(
val partnerId: String? = null,
val hasPremium: Boolean = false,
val error: String? = null,
val navigateTo: String? = null
val navigateTo: String? = null,
val showAbandonConfirm: Boolean = false
)
@HiltViewModel
@ -227,6 +230,32 @@ class ConnectionChallengesViewModel @Inject constructor(
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) }
companion object {
@ -278,7 +307,11 @@ fun ConnectionChallengesScreen(
challengeState = state.challengeState,
onBack = { onNavigate(AppRoute.PLAY) },
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?,
onBack: () -> 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 isComplete = cs?.isComplete == true || cs?.state == ChallengeStatus.CHALLENGE_COMPLETE
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 {
Button(
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)) }
}
}

View File

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

View File

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