From 9710bbc43895158a6b1a6eb2cc6a9749d4f12623 Mon Sep 17 00:00:00 2001 From: null Date: Tue, 23 Jun 2026 10:04:53 -0500 Subject: [PATCH] fix(challenges): error state snackbar, CTA routing for BOTH_COMPLETED/CHALLENGE_COMPLETE, README prem tiers --- .gitignore | 1 + README.md | 4 +- .../challenges/ConnectionChallengesScreen.kt | 83 +++++++++++++------ 3 files changed, 61 insertions(+), 27 deletions(-) diff --git a/.gitignore b/.gitignore index 547d3744..6f87a777 100644 --- a/.gitignore +++ b/.gitignore @@ -66,3 +66,4 @@ docs/SUBSCRIPTION_GO_LIVE.md ios_encrypt.md closer-app-22014-firebase-adminsdk-fbsvc-ed20bf6003.json DAILY_FUN_IMPLEMENTATION_BATCH_PLAN.md +gitleaks-after.json diff --git a/README.md b/README.md index 8ce32c10..8dddcf07 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ Closer is optimized for **short, meaningful sessions** rather than endless engag | Questions | Local-bundled content so the app stays fast and usable without waiting on the network | | Partner Sync | Firebase Auth + Firestore isolate user, couple, invite, thread, and entitlement data per couple | | Reminders | FCM + local notification preferences and quiet-hour controls | -| Premium | Unlocks deeper packs, full history, Desire Sync, Connection Challenges, Memory Lane, and analytics — **one subscription for both partners** | +| Premium | Unlocks deeper packs, full history, Desire Sync, select Connection Challenges, Memory Lane, and analytics — **one subscription for both partners** | --- @@ -119,7 +119,7 @@ One purchase unlocks premium for both partners. No separate subscriptions. - **Full answer history** — search, filter, export the complete timeline - **Saved spin wheel session history** — revisit what landed - **Desire Sync** — preferences alignment exercise for tough conversations -- **Connection Challenges** — multi-day guided programs (communication, trust, intimacy) +- **Select Connection Challenges** — premium multi-day programs; free challenges available to all users - **Memory Lane** — time capsules with locked prompts that open on future dates - **Advanced analytics and relationship insights** - **Priority support** 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 b6ec4171..00aa68af 100644 --- a/app/src/main/java/app/closer/ui/challenges/ConnectionChallengesScreen.kt +++ b/app/src/main/java/app/closer/ui/challenges/ConnectionChallengesScreen.kt @@ -33,12 +33,16 @@ import app.closer.ui.components.CloserHeartLoader import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -214,7 +218,10 @@ class ConnectionChallengesViewModel @Inject constructor( if (day > challenge.durationDays) return viewModelScope.launch { runCatching { challengeDataSource.markDayComplete(coupleId, challenge.id, userId, day) } - .onFailure { Log.w(TAG, "Could not mark day $day complete", it) } + .onFailure { e -> + Log.w(TAG, "Could not mark day $day complete", e) + _uiState.update { s -> s.copy(error = "Couldn't save your progress. Check your connection and try again.") } + } } } @@ -235,31 +242,45 @@ fun ConnectionChallengesScreen( viewModel: ConnectionChallengesViewModel = hiltViewModel() ) { val state by viewModel.uiState.collectAsState() + val snackbarHostState = remember { SnackbarHostState() } LaunchedEffect(state.navigateTo) { state.navigateTo?.let { onNavigate(it); viewModel.onNavigated() } } + LaunchedEffect(state.error) { + state.error?.let { + snackbarHostState.showSnackbar(it) + viewModel.dismissError() + } + } - Box( - modifier = Modifier - .fillMaxSize() - .background(closerBackgroundBrush()) - ) { - when (state.phase) { - ChallengesPhase.LOADING -> ChallengesLoadingScreen() - ChallengesPhase.PICK -> ChallengesPickScreen( - hasPremium = state.hasPremium, - onBack = { onNavigate(AppRoute.PLAY) }, - onPick = { viewModel.startChallenge(it) }, - onPaywall = { onNavigate(AppRoute.PAYWALL) } - ) - ChallengesPhase.ACTIVE -> ChallengesActiveScreen( - challenge = state.activeChallenge!!, - progress = state.progress ?: ChallengeProgressState(), - challengeState = state.challengeState, - onBack = { onNavigate(AppRoute.PLAY) }, - onMarkComplete = { viewModel.markTodayComplete() } - ) + Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) }, + containerColor = Color.Transparent + ) { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .background(closerBackgroundBrush()) + ) { + when (state.phase) { + ChallengesPhase.LOADING -> ChallengesLoadingScreen() + ChallengesPhase.PICK -> ChallengesPickScreen( + hasPremium = state.hasPremium, + onBack = { onNavigate(AppRoute.PLAY) }, + onPick = { viewModel.startChallenge(it) }, + onPaywall = { onNavigate(AppRoute.PAYWALL) } + ) + ChallengesPhase.ACTIVE -> ChallengesActiveScreen( + challenge = state.activeChallenge!!, + progress = state.progress ?: ChallengeProgressState(), + challengeState = state.challengeState, + onBack = { onNavigate(AppRoute.PLAY) }, + onMarkComplete = { viewModel.markTodayComplete() }, + onViewHistory = { onNavigate(AppRoute.GAME_HISTORY) } + ) + } } } } @@ -429,16 +450,28 @@ private fun ChallengesActiveScreen( progress: ChallengeProgressState, challengeState: ChallengeState?, onBack: () -> Unit, - onMarkComplete: () -> Unit + onMarkComplete: () -> Unit, + onViewHistory: () -> Unit ) { val cs = challengeState val isComplete = cs?.isComplete == true || cs?.state == ChallengeStatus.CHALLENGE_COMPLETE val currentDay = cs?.currentDay ?: progress.myNextDay.coerceAtMost(challenge.durationDays) - val canAdvance = cs?.canAdvance ?: true val stateCopy = cs?.copy ?: "" val ctaLabel: String? = cs?.cta val missedDay = cs?.missedDate + // Route each CTA to its correct action. + // BOTH_COMPLETED_TODAY: next day content is already visible — button is a no-op acknowledgement. + // CHALLENGE_COMPLETE: cta is null so button never shows, but handled for safety. + // Everything else (NOT_STARTED, STARTED_TODAY, DAY_MISSED): mark today complete. + val ctaEnabled: Boolean + val ctaAction: () -> Unit + when (cs?.state) { + ChallengeStatus.BOTH_COMPLETED_TODAY -> { ctaEnabled = true; ctaAction = {} } + ChallengeStatus.CHALLENGE_COMPLETE -> { ctaEnabled = true; ctaAction = onViewHistory } + else -> { ctaEnabled = cs?.canAdvance ?: true; ctaAction = onMarkComplete } + } + LazyColumn( modifier = Modifier .fillMaxSize() @@ -612,8 +645,8 @@ private fun ChallengesActiveScreen( if (ctaLabel != null) { item { Button( - onClick = onMarkComplete, - enabled = canAdvance, + onClick = ctaAction, + enabled = ctaEnabled, modifier = Modifier .fillMaxWidth() .heightIn(min = 54.dp),