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
|
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
|
||||||
|
|
|
||||||
|
|
@ -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**
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue