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
closer-app-22014-firebase-adminsdk-fbsvc-ed20bf6003.json
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 |
| 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**

View File

@ -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),