fix(onboarding,challenges): funnel + gating batch C2 from P3/UX review
- DOB picker (sign-up + profile): swap the View-based android.app.DatePickerDialog
(platform teal) for a shared Compose M3 DobPickerDialog that renders in the app
palette in both themes; future dates unselectable. Verified live dark = purple.
- SignupHandoff: back the pending DOB with the app's Preferences DataStore keyed
per-uid, instead of an in-memory singleton that died on process death and made
CreateProfile re-ask for the birth date after any restart mid-onboarding
(verified: am kill mid-profile → relaunch → no DOB re-ask). Local-only write,
so the auth-token/Firestore-rules race the old comment guarded against doesn't
apply; per-uid key prevents cross-account leakage.
- Connection Challenges: expose statusDay (calendar-actionable day) on
ChallengeState; the day card shows a 'Day N unlocks tomorrow 🌙' teaser instead
of spoiling the next prompt the moment today's step is marked done. +2
ChallengeStateMachine tests.
Unit + functions suites green; verified live on the emulator pair.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
4ab79d34c4
commit
9d0a0c7fdf
|
|
@ -133,7 +133,8 @@ object ChallengeStateMachine {
|
||||||
cta = cta,
|
cta = cta,
|
||||||
badge = null,
|
badge = null,
|
||||||
canAdvance = canAdvance,
|
canAdvance = canAdvance,
|
||||||
missedDate = missedDate
|
missedDate = missedDate,
|
||||||
|
statusDay = statusDay
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,40 @@
|
||||||
package app.closer.domain
|
package app.closer.domain
|
||||||
|
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.longPreferencesKey
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* In-memory handoff for the email sign-up flow (O-AGE-001). The date of birth is validated at sign-up
|
* Handoff for the email sign-up flow (O-AGE-001). The date of birth is validated at sign-up for
|
||||||
* for the 18+ gate, but PERSISTED at profile creation: a Firestore write issued immediately after
|
* the 18+ gate but PERSISTED at profile creation: a Firestore write issued immediately after
|
||||||
* `signUpWithEmail` races the auth-token attachment and is unreliable (it silently fails rules). So
|
* `signUpWithEmail` races the auth-token attachment and is unreliable (it silently fails rules).
|
||||||
* sign-up stashes the validated DOB here and [app.closer.ui.onboarding.CreateProfileViewModel] writes it
|
* Sign-up stashes the validated DOB here and [app.closer.ui.onboarding.CreateProfileViewModel]
|
||||||
* once the session has settled — and skips re-asking. Process death clears this; CreateProfile then
|
* writes it once the session has settled — and skips re-asking.
|
||||||
* re-asks via its own DOB step (the gate still holds).
|
*
|
||||||
|
* Backed by the app's Preferences DataStore (not memory) so it survives process death between
|
||||||
|
* sign-up and profile completion — the in-memory version re-asked for the DOB after any restart
|
||||||
|
* mid-onboarding. Keyed per-uid so one account's DOB can never leak into another's profile.
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class SignupHandoff @Inject constructor() {
|
class SignupHandoff @Inject constructor(
|
||||||
var pendingBirthDate: Long? = null
|
private val dataStore: DataStore<Preferences>
|
||||||
|
) {
|
||||||
|
private fun keyFor(uid: String) = longPreferencesKey("pending_birth_date_" + uid)
|
||||||
|
|
||||||
|
suspend fun setPendingBirthDate(uid: String, birthDate: Long) {
|
||||||
|
dataStore.edit { it[keyFor(uid)] = birthDate }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The stashed DOB for this account, or null if none / already consumed. */
|
||||||
|
suspend fun pendingBirthDate(uid: String): Long? =
|
||||||
|
dataStore.data.map { it[keyFor(uid)] }.first()
|
||||||
|
|
||||||
|
suspend fun clear(uid: String) {
|
||||||
|
dataStore.edit { it.remove(keyFor(uid)) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,8 @@ import java.time.LocalDate
|
||||||
* @property badge small celebratory indicator shown when the challenge is complete, or null
|
* @property badge small celebratory indicator shown when the challenge is complete, or null
|
||||||
* @property canAdvance true when the user can mark today's step complete
|
* @property canAdvance true when the user can mark today's step complete
|
||||||
* @property missedDate the local date of a detected missed day, if any
|
* @property missedDate the local date of a detected missed day, if any
|
||||||
|
* @property statusDay the calendar-actionable challenge day (days since start, capped) — when
|
||||||
|
* [currentDay] is ahead of it the user has finished today and the next day hasn't unlocked yet
|
||||||
*/
|
*/
|
||||||
data class ChallengeState(
|
data class ChallengeState(
|
||||||
val state: ChallengeStatus = ChallengeStatus.NOT_STARTED,
|
val state: ChallengeStatus = ChallengeStatus.NOT_STARTED,
|
||||||
|
|
@ -40,7 +42,8 @@ data class ChallengeState(
|
||||||
val cta: String? = null,
|
val cta: String? = null,
|
||||||
val badge: String? = null,
|
val badge: String? = null,
|
||||||
val canAdvance: Boolean = false,
|
val canAdvance: Boolean = false,
|
||||||
val missedDate: LocalDate? = null
|
val missedDate: LocalDate? = null,
|
||||||
|
val statusDay: Int = 1
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
|
|
@ -201,22 +202,17 @@ fun SignUpScreen(
|
||||||
val dobLabel = state.birthDate?.let {
|
val dobLabel = state.birthDate?.let {
|
||||||
java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM).format(java.util.Date(it))
|
java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM).format(java.util.Date(it))
|
||||||
} ?: ""
|
} ?: ""
|
||||||
|
var showDobPicker by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) }
|
||||||
val openDobPicker = {
|
val openDobPicker = {
|
||||||
focusManager.clearFocus()
|
focusManager.clearFocus()
|
||||||
val initMillis = state.birthDate
|
showDobPicker = true
|
||||||
?: java.util.Calendar.getInstance().apply { add(java.util.Calendar.YEAR, -18) }.timeInMillis
|
}
|
||||||
val c = java.util.Calendar.getInstance().apply { timeInMillis = initMillis }
|
if (showDobPicker) {
|
||||||
android.app.DatePickerDialog(
|
app.closer.ui.components.DobPickerDialog(
|
||||||
context,
|
initialSelectedDateMillis = state.birthDate,
|
||||||
{ _, y, m, d ->
|
onConfirm = viewModel::updateBirthDate,
|
||||||
viewModel.updateBirthDate(
|
onDismiss = { showDobPicker = false }
|
||||||
java.util.Calendar.getInstance().apply { clear(); set(y, m, d) }.timeInMillis
|
|
||||||
)
|
)
|
||||||
},
|
|
||||||
c.get(java.util.Calendar.YEAR),
|
|
||||||
c.get(java.util.Calendar.MONTH),
|
|
||||||
c.get(java.util.Calendar.DAY_OF_MONTH)
|
|
||||||
).apply { datePicker.maxDate = System.currentTimeMillis() }.show()
|
|
||||||
}
|
}
|
||||||
Box(modifier = Modifier.fillMaxWidth()) {
|
Box(modifier = Modifier.fillMaxWidth()) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,9 @@ class SignUpViewModel @Inject constructor(
|
||||||
// Firestore write issued right after signUpWithEmail races the auth-token attachment
|
// Firestore write issued right after signUpWithEmail races the auth-token attachment
|
||||||
// and silently fails rules. CreateProfile writes it once the session has settled and
|
// and silently fails rules. CreateProfile writes it once the session has settled and
|
||||||
// skips re-asking. (dob is non-null — the guard above returns otherwise.)
|
// skips re-asking. (dob is non-null — the guard above returns otherwise.)
|
||||||
signupHandoff.pendingBirthDate = dob
|
authRepository.currentUserId?.let { uid ->
|
||||||
|
signupHandoff.setPendingBirthDate(uid, dob)
|
||||||
|
}
|
||||||
_uiState.update { it.copy(isLoading = false, success = true) }
|
_uiState.update { it.copy(isLoading = false, success = true) }
|
||||||
}
|
}
|
||||||
.onFailure { e ->
|
.onFailure { e ->
|
||||||
|
|
|
||||||
|
|
@ -530,6 +530,10 @@ private fun ChallengesActiveScreen(
|
||||||
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
|
||||||
|
// currentDay advances to the NEXT day the moment today's step is done, but that day only
|
||||||
|
// becomes actionable tomorrow (statusDay = calendar day). Don't spoil its prompt early —
|
||||||
|
// the daily unlock is the ritual.
|
||||||
|
val dayUnlocked = cs == null || currentDay <= cs.statusDay
|
||||||
|
|
||||||
// Route each CTA to its correct action.
|
// Route each CTA to its correct action.
|
||||||
// BOTH_COMPLETED_TODAY: next day content is already visible — button is a no-op acknowledgement.
|
// BOTH_COMPLETED_TODAY: next day content is already visible — button is a no-op acknowledgement.
|
||||||
|
|
@ -657,12 +661,14 @@ private fun ChallengesActiveScreen(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
text = dayPrompt.prompt,
|
text = if (dayUnlocked) dayPrompt.prompt
|
||||||
|
else "Day $currentDay unlocks tomorrow \uD83C\uDF19",
|
||||||
style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Medium),
|
style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Medium),
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = if (dayUnlocked) MaterialTheme.colorScheme.onSurface
|
||||||
|
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
lineHeight = MaterialTheme.typography.bodyLarge.lineHeight
|
lineHeight = MaterialTheme.typography.bodyLarge.lineHeight
|
||||||
)
|
)
|
||||||
if (dayPrompt.hint.isNotBlank()) {
|
if (dayUnlocked && dayPrompt.hint.isNotBlank()) {
|
||||||
Text(
|
Text(
|
||||||
text = dayPrompt.hint,
|
text = dayPrompt.hint,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
package app.closer.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.material3.DatePicker
|
||||||
|
import androidx.compose.material3.DatePickerDialog
|
||||||
|
import androidx.compose.material3.DisplayMode
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.SelectableDates
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.rememberDatePickerState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import java.util.Calendar
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Date-of-birth picker used by sign-up and profile creation. Compose M3 so it renders in the
|
||||||
|
* app's palette in both themes — the previous View-based android.app.DatePickerDialog fell back
|
||||||
|
* to the platform's default teal because no dialog theme overlay was defined.
|
||||||
|
* Dates after today are not selectable (a birth date is always in the past).
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun DobPickerDialog(
|
||||||
|
initialSelectedDateMillis: Long?,
|
||||||
|
onConfirm: (Long) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val currentYear = Calendar.getInstance().get(Calendar.YEAR)
|
||||||
|
val state = rememberDatePickerState(
|
||||||
|
initialSelectedDateMillis = initialSelectedDateMillis
|
||||||
|
?: Calendar.getInstance().apply { add(Calendar.YEAR, -18) }.timeInMillis,
|
||||||
|
initialDisplayMode = DisplayMode.Picker,
|
||||||
|
selectableDates = object : SelectableDates {
|
||||||
|
override fun isSelectableDate(utcTimeMillis: Long): Boolean = utcTimeMillis <= now
|
||||||
|
override fun isSelectableYear(year: Int): Boolean = year <= currentYear
|
||||||
|
}
|
||||||
|
)
|
||||||
|
DatePickerDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
state.selectedDateMillis?.let(onConfirm)
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
) { Text("OK") }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
DatePicker(state = state, showModeToggle = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -48,6 +48,7 @@ import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
|
|
@ -173,22 +174,17 @@ fun CreateProfileScreen(
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
|
|
||||||
|
var showDobPicker by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) }
|
||||||
val openDobPicker = {
|
val openDobPicker = {
|
||||||
focusManager.clearFocus()
|
focusManager.clearFocus()
|
||||||
val initMillis = state.birthDate
|
showDobPicker = true
|
||||||
?: java.util.Calendar.getInstance().apply { add(java.util.Calendar.YEAR, -18) }.timeInMillis
|
}
|
||||||
val c = java.util.Calendar.getInstance().apply { timeInMillis = initMillis }
|
if (showDobPicker) {
|
||||||
android.app.DatePickerDialog(
|
app.closer.ui.components.DobPickerDialog(
|
||||||
context,
|
initialSelectedDateMillis = state.birthDate,
|
||||||
{ _, y, m, d ->
|
onConfirm = viewModel::selectBirthDate,
|
||||||
viewModel.selectBirthDate(
|
onDismiss = { showDobPicker = false }
|
||||||
java.util.Calendar.getInstance().apply { clear(); set(y, m, d) }.timeInMillis
|
|
||||||
)
|
)
|
||||||
},
|
|
||||||
c.get(java.util.Calendar.YEAR),
|
|
||||||
c.get(java.util.Calendar.MONTH),
|
|
||||||
c.get(java.util.Calendar.DAY_OF_MONTH)
|
|
||||||
).apply { datePicker.maxDate = System.currentTimeMillis() }.show()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
when (state.currentStep) {
|
when (state.currentStep) {
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ class CreateProfileViewModel @Inject constructor(
|
||||||
// Prefer the on-file DOB; else the one just validated at email sign-up (handed off in memory,
|
// Prefer the on-file DOB; else the one just validated at email sign-up (handed off in memory,
|
||||||
// since the post-signup write is unreliable). Require the DOB step only when we have neither
|
// since the post-signup write is unreliable). Require the DOB step only when we have neither
|
||||||
// (Google/legacy, or a failed read) — fail closed so the gate is never silently skipped.
|
// (Google/legacy, or a failed read) — fail closed so the gate is never silently skipped.
|
||||||
val effectiveDob = user?.birthDate ?: signupHandoff.pendingBirthDate
|
val effectiveDob = user?.birthDate ?: signupHandoff.pendingBirthDate(uid)
|
||||||
val needsDob = effectiveDob == null
|
val needsDob = effectiveDob == null
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
|
|
@ -189,7 +189,7 @@ class CreateProfileViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
}.onSuccess {
|
}.onSuccess {
|
||||||
// DOB is now on the doc — drop the in-memory handoff so a later sign-up can't inherit it.
|
// DOB is now on the doc — drop the in-memory handoff so a later sign-up can't inherit it.
|
||||||
signupHandoff.pendingBirthDate = null
|
signupHandoff.clear(uid)
|
||||||
_uiState.update { it.copy(isLoading = false, success = true) }
|
_uiState.update { it.copy(isLoading = false, success = true) }
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
_uiState.update { it.copy(isLoading = false, error = e.message ?: "Couldn't save your profile. Please try again.") }
|
_uiState.update { it.copy(isLoading = false, error = e.message ?: "Couldn't save your profile. Please try again.") }
|
||||||
|
|
|
||||||
|
|
@ -274,6 +274,43 @@ class ChallengeStateMachineTest {
|
||||||
assertEquals(3, state.currentDay)
|
assertEquals(3, state.currentDay)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// statusDay exposure (day-unlock gating for the UI)
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `statusDay stays on the calendar day when user races ahead same-day`() {
|
||||||
|
// Started today, completed day 1 already: currentDay advances to 2 (next incomplete)
|
||||||
|
// but statusDay stays 1 — the UI uses currentDay > statusDay to hide day 2's prompt.
|
||||||
|
val startedAt = today.atStartOfDay(java.time.ZoneId.systemDefault()).toInstant().toEpochMilli()
|
||||||
|
val input = ChallengeStateInput(
|
||||||
|
challenge = challenge(),
|
||||||
|
progress = progress(startedAt = startedAt, mine = listOf(1)),
|
||||||
|
today = today
|
||||||
|
)
|
||||||
|
|
||||||
|
val state = ChallengeStateMachine.compute(input)
|
||||||
|
|
||||||
|
assertEquals(2, state.currentDay)
|
||||||
|
assertEquals(1, state.statusDay)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `statusDay advances with the calendar`() {
|
||||||
|
val startedAt = today.minusDays(2).atStartOfDay(java.time.ZoneId.systemDefault()).toInstant().toEpochMilli()
|
||||||
|
val input = ChallengeStateInput(
|
||||||
|
challenge = challenge(),
|
||||||
|
progress = progress(startedAt = startedAt, mine = listOf(1, 2, 3)),
|
||||||
|
today = today
|
||||||
|
)
|
||||||
|
|
||||||
|
val state = ChallengeStateMachine.compute(input)
|
||||||
|
|
||||||
|
// Day 3 of the calendar; user already did day 3 → next (4) is tomorrow's.
|
||||||
|
assertEquals(4, state.currentDay)
|
||||||
|
assertEquals(3, state.statusDay)
|
||||||
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue