fix(challenges): error state snackbar, CTA routing for BOTH_COMPLETED/CHALLENGE_COMPLETE, README prem tiers

This commit is contained in:
null 2026-06-23 10:04:53 -05:00
parent c97371a12e
commit 9710bbc438
3 changed files with 61 additions and 27 deletions

1
.gitignore vendored
View File

@ -66,3 +66,4 @@ docs/SUBSCRIPTION_GO_LIVE.md
ios_encrypt.md ios_encrypt.md
closer-app-22014-firebase-adminsdk-fbsvc-ed20bf6003.json closer-app-22014-firebase-adminsdk-fbsvc-ed20bf6003.json
DAILY_FUN_IMPLEMENTATION_BATCH_PLAN.md DAILY_FUN_IMPLEMENTATION_BATCH_PLAN.md
gitleaks-after.json

View File

@ -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 | | 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 | | Partner Sync | Firebase Auth + Firestore isolate user, couple, invite, thread, and entitlement data per couple |
| Reminders | FCM + local notification preferences and quiet-hour controls | | 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 - **Full answer history** — search, filter, export the complete timeline
- **Saved spin wheel session history** — revisit what landed - **Saved spin wheel session history** — revisit what landed
- **Desire Sync** — preferences alignment exercise for tough conversations - **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 - **Memory Lane** — time capsules with locked prompts that open on future dates
- **Advanced analytics and relationship insights** - **Advanced analytics and relationship insights**
- **Priority support** - **Priority support**

View File

@ -33,12 +33,16 @@ import app.closer.ui.components.CloserHeartLoader
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme 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.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@ -214,7 +218,10 @@ class ConnectionChallengesViewModel @Inject constructor(
if (day > challenge.durationDays) return if (day > challenge.durationDays) return
viewModelScope.launch { viewModelScope.launch {
runCatching { challengeDataSource.markDayComplete(coupleId, challenge.id, userId, day) } 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() viewModel: ConnectionChallengesViewModel = hiltViewModel()
) { ) {
val state by viewModel.uiState.collectAsState() val state by viewModel.uiState.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(state.navigateTo) { LaunchedEffect(state.navigateTo) {
state.navigateTo?.let { onNavigate(it); viewModel.onNavigated() } state.navigateTo?.let { onNavigate(it); viewModel.onNavigated() }
} }
LaunchedEffect(state.error) {
state.error?.let {
snackbarHostState.showSnackbar(it)
viewModel.dismissError()
}
}
Box( Scaffold(
modifier = Modifier snackbarHost = { SnackbarHost(snackbarHostState) },
.fillMaxSize() containerColor = Color.Transparent
.background(closerBackgroundBrush()) ) { padding ->
) { Box(
when (state.phase) { modifier = Modifier
ChallengesPhase.LOADING -> ChallengesLoadingScreen() .fillMaxSize()
ChallengesPhase.PICK -> ChallengesPickScreen( .padding(padding)
hasPremium = state.hasPremium, .background(closerBackgroundBrush())
onBack = { onNavigate(AppRoute.PLAY) }, ) {
onPick = { viewModel.startChallenge(it) }, when (state.phase) {
onPaywall = { onNavigate(AppRoute.PAYWALL) } ChallengesPhase.LOADING -> ChallengesLoadingScreen()
) ChallengesPhase.PICK -> ChallengesPickScreen(
ChallengesPhase.ACTIVE -> ChallengesActiveScreen( hasPremium = state.hasPremium,
challenge = state.activeChallenge!!, onBack = { onNavigate(AppRoute.PLAY) },
progress = state.progress ?: ChallengeProgressState(), onPick = { viewModel.startChallenge(it) },
challengeState = state.challengeState, onPaywall = { onNavigate(AppRoute.PAYWALL) }
onBack = { onNavigate(AppRoute.PLAY) }, )
onMarkComplete = { viewModel.markTodayComplete() } 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, progress: ChallengeProgressState,
challengeState: ChallengeState?, challengeState: ChallengeState?,
onBack: () -> Unit, onBack: () -> Unit,
onMarkComplete: () -> Unit onMarkComplete: () -> Unit,
onViewHistory: () -> Unit
) { ) {
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)
val canAdvance = cs?.canAdvance ?: true
val stateCopy = cs?.copy ?: "" val stateCopy = cs?.copy ?: ""
val ctaLabel: String? = cs?.cta val ctaLabel: String? = cs?.cta
val missedDay = cs?.missedDate 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( LazyColumn(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@ -612,8 +645,8 @@ private fun ChallengesActiveScreen(
if (ctaLabel != null) { if (ctaLabel != null) {
item { item {
Button( Button(
onClick = onMarkComplete, onClick = ctaAction,
enabled = canAdvance, enabled = ctaEnabled,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.heightIn(min = 54.dp), .heightIn(min = 54.dp),