feat(challenges): abandon challenge flow; fix(play): premium lock on history; fix(memory-lane): null-safe detail state
This commit is contained in:
parent
58be8ed021
commit
015ac8eefe
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue