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()
|
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,
|
||||||
|
|
|
||||||
|
|
@ -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)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue