From 9d0a0c7fdf831bded1b1648c10ee8eb4e839ce38 Mon Sep 17 00:00:00 2001 From: null Date: Tue, 7 Jul 2026 21:24:27 -0500 Subject: [PATCH] fix(onboarding,challenges): funnel + gating batch C2 from P3/UX review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../closer/domain/ChallengeStateMachine.kt | 3 +- .../java/app/closer/domain/SignupHandoff.kt | 39 +++++++++++--- .../app/closer/domain/model/ChallengeState.kt | 5 +- .../java/app/closer/ui/auth/SignUpScreen.kt | 24 ++++----- .../app/closer/ui/auth/SignUpViewModel.kt | 4 +- .../challenges/ConnectionChallengesScreen.kt | 12 +++-- .../closer/ui/components/DobPickerDialog.kt | 54 +++++++++++++++++++ .../ui/onboarding/CreateProfileScreen.kt | 24 ++++----- .../ui/onboarding/CreateProfileViewModel.kt | 4 +- .../domain/ChallengeStateMachineTest.kt | 37 +++++++++++++ 10 files changed, 162 insertions(+), 44 deletions(-) create mode 100644 app/src/main/java/app/closer/ui/components/DobPickerDialog.kt diff --git a/app/src/main/java/app/closer/domain/ChallengeStateMachine.kt b/app/src/main/java/app/closer/domain/ChallengeStateMachine.kt index e40bd917..59ca6c46 100644 --- a/app/src/main/java/app/closer/domain/ChallengeStateMachine.kt +++ b/app/src/main/java/app/closer/domain/ChallengeStateMachine.kt @@ -133,7 +133,8 @@ object ChallengeStateMachine { cta = cta, badge = null, canAdvance = canAdvance, - missedDate = missedDate + missedDate = missedDate, + statusDay = statusDay ) } diff --git a/app/src/main/java/app/closer/domain/SignupHandoff.kt b/app/src/main/java/app/closer/domain/SignupHandoff.kt index f08c9842..c2cd92b5 100644 --- a/app/src/main/java/app/closer/domain/SignupHandoff.kt +++ b/app/src/main/java/app/closer/domain/SignupHandoff.kt @@ -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 +) { + 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)) } + } } diff --git a/app/src/main/java/app/closer/domain/model/ChallengeState.kt b/app/src/main/java/app/closer/domain/model/ChallengeState.kt index 5d0ebeb6..7fca90c8 100644 --- a/app/src/main/java/app/closer/domain/model/ChallengeState.kt +++ b/app/src/main/java/app/closer/domain/model/ChallengeState.kt @@ -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 ) /** diff --git a/app/src/main/java/app/closer/ui/auth/SignUpScreen.kt b/app/src/main/java/app/closer/ui/auth/SignUpScreen.kt index fe7e74b5..f6d835f8 100644 --- a/app/src/main/java/app/closer/ui/auth/SignUpScreen.kt +++ b/app/src/main/java/app/closer/ui/auth/SignUpScreen.kt @@ -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( diff --git a/app/src/main/java/app/closer/ui/auth/SignUpViewModel.kt b/app/src/main/java/app/closer/ui/auth/SignUpViewModel.kt index 76ee92db..c525a527 100644 --- a/app/src/main/java/app/closer/ui/auth/SignUpViewModel.kt +++ b/app/src/main/java/app/closer/ui/auth/SignUpViewModel.kt @@ -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 -> diff --git a/app/src/main/java/app/closer/ui/challenges/ConnectionChallengesScreen.kt b/app/src/main/java/app/closer/ui/challenges/ConnectionChallengesScreen.kt index cd11219b..1211fa35 100644 --- a/app/src/main/java/app/closer/ui/challenges/ConnectionChallengesScreen.kt +++ b/app/src/main/java/app/closer/ui/challenges/ConnectionChallengesScreen.kt @@ -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, diff --git a/app/src/main/java/app/closer/ui/components/DobPickerDialog.kt b/app/src/main/java/app/closer/ui/components/DobPickerDialog.kt new file mode 100644 index 00000000..764773c6 --- /dev/null +++ b/app/src/main/java/app/closer/ui/components/DobPickerDialog.kt @@ -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) + } +} diff --git a/app/src/main/java/app/closer/ui/onboarding/CreateProfileScreen.kt b/app/src/main/java/app/closer/ui/onboarding/CreateProfileScreen.kt index a1a74d2b..8b9e6ea2 100644 --- a/app/src/main/java/app/closer/ui/onboarding/CreateProfileScreen.kt +++ b/app/src/main/java/app/closer/ui/onboarding/CreateProfileScreen.kt @@ -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) { diff --git a/app/src/main/java/app/closer/ui/onboarding/CreateProfileViewModel.kt b/app/src/main/java/app/closer/ui/onboarding/CreateProfileViewModel.kt index dbb95d36..bd223104 100644 --- a/app/src/main/java/app/closer/ui/onboarding/CreateProfileViewModel.kt +++ b/app/src/main/java/app/closer/ui/onboarding/CreateProfileViewModel.kt @@ -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.") } diff --git a/app/src/test/java/app/closer/domain/ChallengeStateMachineTest.kt b/app/src/test/java/app/closer/domain/ChallengeStateMachineTest.kt index d389176c..81401ee5 100644 --- a/app/src/test/java/app/closer/domain/ChallengeStateMachineTest.kt +++ b/app/src/test/java/app/closer/domain/ChallengeStateMachineTest.kt @@ -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 // -----------------------------------------------------------------