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:
null 2026-07-07 21:24:27 -05:00
parent 4ab79d34c4
commit 9d0a0c7fdf
10 changed files with 162 additions and 44 deletions

View File

@ -133,7 +133,8 @@ object ChallengeStateMachine {
cta = cta, cta = cta,
badge = null, badge = null,
canAdvance = canAdvance, canAdvance = canAdvance,
missedDate = missedDate missedDate = missedDate,
statusDay = statusDay
) )
} }

View File

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

View File

@ -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
) )
/** /**

View File

@ -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(

View File

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

View File

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

View File

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

View File

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

View File

@ -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.") }

View File

@ -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
// ----------------------------------------------------------------- // -----------------------------------------------------------------