fix(challenges): error state snackbar, CTA routing for BOTH_COMPLETED/CHALLENGE_COMPLETE, README prem tiers
This commit is contained in:
parent
c97371a12e
commit
9710bbc438
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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**
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Reference in New Issue