diff --git a/app/src/main/java/app/closer/data/remote/FirestoreChallengeDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreChallengeDataSource.kt index a94dbba9..2ada4f68 100644 --- a/app/src/main/java/app/closer/data/remote/FirestoreChallengeDataSource.kt +++ b/app/src/main/java/app/closer/data/remote/FirestoreChallengeDataSource.kt @@ -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, diff --git a/app/src/main/java/app/closer/ui/challenges/ConnectionChallengesScreen.kt b/app/src/main/java/app/closer/ui/challenges/ConnectionChallengesScreen.kt index 00aa68af..90e20f36 100644 --- a/app/src/main/java/app/closer/ui/challenges/ConnectionChallengesScreen.kt +++ b/app/src/main/java/app/closer/ui/challenges/ConnectionChallengesScreen.kt @@ -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)) } } } diff --git a/app/src/main/java/app/closer/ui/memorylane/MemoryLaneScreen.kt b/app/src/main/java/app/closer/ui/memorylane/MemoryLaneScreen.kt index e21fbd20..89b6cd7a 100644 --- a/app/src/main/java/app/closer/ui/memorylane/MemoryLaneScreen.kt +++ b/app/src/main/java/app/closer/ui/memorylane/MemoryLaneScreen.kt @@ -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.", diff --git a/app/src/main/java/app/closer/ui/play/PlayHubScreen.kt b/app/src/main/java/app/closer/ui/play/PlayHubScreen.kt index cb52f663..671f85b5 100644 --- a/app/src/main/java/app/closer/ui/play/PlayHubScreen.kt +++ b/app/src/main/java/app/closer/ui/play/PlayHubScreen.kt @@ -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,