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,
|
||||
badge = null,
|
||||
canAdvance = canAdvance,
|
||||
missedDate = missedDate
|
||||
missedDate = missedDate,
|
||||
statusDay = statusDay
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,17 +1,40 @@
|
|||
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.Singleton
|
||||
|
||||
/**
|
||||
* In-memory handoff for the email sign-up flow (O-AGE-001). The date of birth is validated at sign-up
|
||||
* for 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
|
||||
* sign-up stashes the validated DOB here and [app.closer.ui.onboarding.CreateProfileViewModel] writes it
|
||||
* once the session has settled — and skips re-asking. Process death clears this; CreateProfile then
|
||||
* re-asks via its own DOB step (the gate still holds).
|
||||
* Handoff for the email sign-up flow (O-AGE-001). The date of birth is validated at sign-up for
|
||||
* 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).
|
||||
* Sign-up stashes the validated DOB here and [app.closer.ui.onboarding.CreateProfileViewModel]
|
||||
* writes it once the session has settled — and skips re-asking.
|
||||
*
|
||||
* 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
|
||||
class SignupHandoff @Inject constructor() {
|
||||
var pendingBirthDate: Long? = null
|
||||
class SignupHandoff @Inject constructor(
|
||||
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 canAdvance true when the user can mark today's step complete
|
||||
* @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(
|
||||
val state: ChallengeStatus = ChallengeStatus.NOT_STARTED,
|
||||
|
|
@ -40,7 +42,8 @@ data class ChallengeState(
|
|||
val cta: String? = null,
|
||||
val badge: String? = null,
|
||||
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.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
|
|
@ -201,22 +202,17 @@ fun SignUpScreen(
|
|||
val dobLabel = state.birthDate?.let {
|
||||
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 = {
|
||||
focusManager.clearFocus()
|
||||
val initMillis = state.birthDate
|
||||
?: java.util.Calendar.getInstance().apply { add(java.util.Calendar.YEAR, -18) }.timeInMillis
|
||||
val c = java.util.Calendar.getInstance().apply { timeInMillis = initMillis }
|
||||
android.app.DatePickerDialog(
|
||||
context,
|
||||
{ _, y, m, d ->
|
||||
viewModel.updateBirthDate(
|
||||
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()
|
||||
showDobPicker = true
|
||||
}
|
||||
if (showDobPicker) {
|
||||
app.closer.ui.components.DobPickerDialog(
|
||||
initialSelectedDateMillis = state.birthDate,
|
||||
onConfirm = viewModel::updateBirthDate,
|
||||
onDismiss = { showDobPicker = false }
|
||||
)
|
||||
}
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
OutlinedTextField(
|
||||
|
|
|
|||
|
|
@ -71,7 +71,9 @@ class SignUpViewModel @Inject constructor(
|
|||
// Firestore write issued right after signUpWithEmail races the auth-token attachment
|
||||
// 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.)
|
||||
signupHandoff.pendingBirthDate = dob
|
||||
authRepository.currentUserId?.let { uid ->
|
||||
signupHandoff.setPendingBirthDate(uid, dob)
|
||||
}
|
||||
_uiState.update { it.copy(isLoading = false, success = true) }
|
||||
}
|
||||
.onFailure { e ->
|
||||
|
|
|
|||
|
|
@ -530,6 +530,10 @@ private fun ChallengesActiveScreen(
|
|||
val stateCopy = cs?.copy ?: ""
|
||||
val ctaLabel: String? = cs?.cta
|
||||
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.
|
||||
// BOTH_COMPLETED_TODAY: next day content is already visible — button is a no-op acknowledgement.
|
||||
|
|
@ -657,12 +661,14 @@ private fun ChallengesActiveScreen(
|
|||
)
|
||||
}
|
||||
Text(
|
||||
text = dayPrompt.prompt,
|
||||
text = if (dayUnlocked) dayPrompt.prompt
|
||||
else "Day $currentDay unlocks tomorrow \uD83C\uDF19",
|
||||
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
|
||||
)
|
||||
if (dayPrompt.hint.isNotBlank()) {
|
||||
if (dayUnlocked && dayPrompt.hint.isNotBlank()) {
|
||||
Text(
|
||||
text = dayPrompt.hint,
|
||||
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.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
|
|
@ -173,22 +174,17 @@ fun CreateProfileScreen(
|
|||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
var showDobPicker by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) }
|
||||
val openDobPicker = {
|
||||
focusManager.clearFocus()
|
||||
val initMillis = state.birthDate
|
||||
?: java.util.Calendar.getInstance().apply { add(java.util.Calendar.YEAR, -18) }.timeInMillis
|
||||
val c = java.util.Calendar.getInstance().apply { timeInMillis = initMillis }
|
||||
android.app.DatePickerDialog(
|
||||
context,
|
||||
{ _, y, m, d ->
|
||||
viewModel.selectBirthDate(
|
||||
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()
|
||||
showDobPicker = true
|
||||
}
|
||||
if (showDobPicker) {
|
||||
app.closer.ui.components.DobPickerDialog(
|
||||
initialSelectedDateMillis = state.birthDate,
|
||||
onConfirm = viewModel::selectBirthDate,
|
||||
onDismiss = { showDobPicker = false }
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
// 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.
|
||||
val effectiveDob = user?.birthDate ?: signupHandoff.pendingBirthDate
|
||||
val effectiveDob = user?.birthDate ?: signupHandoff.pendingBirthDate(uid)
|
||||
val needsDob = effectiveDob == null
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
|
|
@ -189,7 +189,7 @@ class CreateProfileViewModel @Inject constructor(
|
|||
}
|
||||
}.onSuccess {
|
||||
// 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) }
|
||||
}.onFailure { e ->
|
||||
_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)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// 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
|
||||
// -----------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Reference in New Issue