feat: onboarding, and the Surface that dark mode was missing

Seven screens, §19 and §56 verbatim: welcome, last period, period end, previous
history, the privacy promise, notification privacy, first forecast. Verified end
to end on a device — the flow produces a forecast, the record persists, and a
relaunch goes straight to Today.

Three decisions with tests behind them:

  - Nothing is written until the final step. Somebody who abandons onboarding
    halfway has not asked this app to remember anything about them.
  - Notification privacy is Discreet before the user touches anything (§28), and
    Direct is last and never pre-selected. Checked on the device, not only in a
    unit test.
  - "Still going" and "I'm not sure" both mean no end date. §24: never invent
    one. The date picker refuses future dates by not offering them rather than
    by rejecting a tap it allowed.

DARK MODE WAS BROKEN FOR ALL OF BATCH 01

PeriodTheme never wrapped its content in a Surface, so every Text without an
explicit colour inherited Material's default — black — and the app background
never painted. In light mode that looked correct by accident, because dark text
on cream is what was wanted anyway. In dark mode the onboarding headings
rendered near-black on charcoal.

No test caught it and no test easily would have. It was found by opening the
app on a device and looking at it.

The Surface now lives in the theme, so a screen without a Scaffold cannot
forget, and every illustration has a light/dark preview pair. A preview is not
a test, but it is the cheapest thing that puts the failure in front of whoever
is editing the screen.

closes #16
This commit is contained in:
null 2026-08-18 03:44:46 -05:00
parent e15c49752d
commit 2479a1ddf4
10 changed files with 970 additions and 4 deletions

View File

@ -82,6 +82,9 @@ dependencies {
ksp(libs.hilt.compiler) ksp(libs.hilt.compiler)
testImplementation(libs.junit) testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.robolectric)
testImplementation(libs.androidx.test.core)
androidTestImplementation(libs.androidx.test.junit) androidTestImplementation(libs.androidx.test.junit)
androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.core)
} }

View File

@ -6,7 +6,7 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import dev.privacyllc.period.designsystem.PeriodTheme import dev.privacyllc.period.designsystem.PeriodTheme
import dev.privacyllc.period.navigation.PeriodApp import dev.privacyllc.period.navigation.PeriodRoot
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@ -15,7 +15,7 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContent { setContent {
PeriodTheme { PeriodTheme {
PeriodApp() PeriodRoot()
} }
} }
} }

View File

@ -0,0 +1,72 @@
package dev.privacyllc.period.feature.onboarding
import android.content.res.Configuration
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import dev.privacyllc.period.designsystem.PeriodTheme
import dev.privacyllc.period.designsystem.art.EmptyStateIllustration
import dev.privacyllc.period.designsystem.art.LearningIllustration
import dev.privacyllc.period.designsystem.art.PrivacyIllustration
import dev.privacyllc.period.designsystem.art.WelcomeIllustration
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/**
* Every illustration, in both themes.
*
* **These exist because dark mode was broken and nothing said so.** The theme
* was not wrapping its content in a `Surface`, so any `Text` without an explicit
* colour inherited Material's default black and the app's background never
* painted. In light mode that looked right by accident. In dark mode the
* onboarding headings were near-black on charcoal, and it was found by opening
* the app rather than by any test.
*
* A preview pair is not a test and does not fail a build. What it does is put
* both themes in front of whoever is editing the screen, which is the cheapest
* thing that would have caught it.
*/
@Preview(name = "Illustrations · light", showBackground = true, heightDp = 620)
@Preview(
name = "Illustrations · dark",
showBackground = true,
heightDp = 620,
uiMode = Configuration.UI_MODE_NIGHT_YES,
)
@Composable
private fun IllustrationPreviews() {
PeriodTheme {
Column(
Modifier.fillMaxWidth().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text("Welcome", style = MaterialTheme.typography.labelLarge)
WelcomeIllustration(
primary = MaterialTheme.colorScheme.primary,
accent = MaterialTheme.colorScheme.tertiary,
size = 120.dp,
)
Text("Learning", style = MaterialTheme.typography.labelLarge)
LearningIllustration(
color = MaterialTheme.colorScheme.primary,
accent = MaterialTheme.colorScheme.tertiary,
size = 110.dp,
)
Text("Privacy", style = MaterialTheme.typography.labelLarge)
PrivacyIllustration(
color = MaterialTheme.colorScheme.primary,
accent = MaterialTheme.colorScheme.tertiary,
size = 110.dp,
)
Text("Empty state", style = MaterialTheme.typography.labelLarge)
EmptyStateIllustration(color = MaterialTheme.colorScheme.outline, size = 110.dp)
}
}
}

View File

@ -0,0 +1,397 @@
package dev.privacyllc.period.feature.onboarding
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.RadioButton
import androidx.compose.material3.SelectableDates
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.privacyllc.period.core.datastore.NotificationPrivacy
import dev.privacyllc.period.designsystem.art.LearningIllustration
import dev.privacyllc.period.designsystem.art.PrivacyIllustration
import dev.privacyllc.period.designsystem.art.WelcomeIllustration
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
/**
* PRODUCT_PLAN.md §19 and §56, screen for screen.
*
* Two rules shape the whole flow:
*
* **The forecast comes before anything is asked of the user.** §19: do not force
* registration before showing the forecast. There is no account in V1 at all, so
* this is really about not gating the one thing they came for behind anything
* including a permission prompt, which is why none appears here.
*
* **Nothing is written until the last step.** Somebody who abandons onboarding
* halfway has not asked this app to remember anything about them.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun OnboardingScreen(
onFinished: () -> Unit,
viewModel: OnboardingViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
Column(Modifier.fillMaxSize()) {
LinearProgressIndicator(
progress = { (Step.entries.indexOf(state.step) + 1f) / Step.entries.size },
modifier = Modifier.fillMaxWidth(),
)
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
when (state.step) {
Step.WELCOME -> Welcome(viewModel::toWelcomeNext)
Step.LAST_PERIOD -> LastPeriod(state, viewModel)
Step.PERIOD_END -> PeriodEnd(state, viewModel)
Step.PREVIOUS_HISTORY -> PreviousHistory(state, viewModel)
Step.PRIVACY_PROMISE -> PrivacyPromise(viewModel::donePrivacyPromise)
Step.NOTIFICATION_PRIVACY -> NotificationPrivacyStep(state, viewModel)
Step.FIRST_FORECAST -> FirstForecast(state) { viewModel.complete(onFinished) }
}
state.message?.let {
Spacer(Modifier.height(16.dp))
Card { Text(it, Modifier.padding(12.dp), style = MaterialTheme.typography.bodyMedium) }
TextButton(onClick = viewModel::messageShown) { Text("OK") }
}
if (state.step != Step.WELCOME && state.step != Step.FIRST_FORECAST) {
Spacer(Modifier.height(8.dp))
TextButton(onClick = viewModel::back) { Text("Back") }
}
}
}
}
@Composable
private fun Heading(title: String, body: String? = null) {
Text(title, style = MaterialTheme.typography.headlineMedium, textAlign = TextAlign.Center)
if (body != null) {
Spacer(Modifier.height(12.dp))
Text(
body,
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.height(28.dp))
}
@Composable
private fun Welcome(onNext: () -> Unit) {
Spacer(Modifier.height(32.dp))
WelcomeIllustration(
primary = MaterialTheme.colorScheme.primary,
accent = MaterialTheme.colorScheme.tertiary,
)
Spacer(Modifier.height(32.dp))
Heading("Know what's coming.", "Track your period and get predictions that learn your cycle.")
Button(onClick = onNext, modifier = Modifier.fillMaxWidth()) { Text("Get Started") }
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun LastPeriod(state: OnboardingUiState, viewModel: OnboardingViewModel) {
Spacer(Modifier.height(32.dp))
Heading("When did your last period start?")
DateField(
label = state.lastPeriodStart?.pretty() ?: "Choose a date",
selected = state.lastPeriodStart,
latest = state.today,
onSelected = viewModel::setLastPeriodStart,
)
Spacer(Modifier.height(24.dp))
Button(
onClick = viewModel::confirmLastPeriod,
enabled = state.canContinueFromLastPeriod,
modifier = Modifier.fillMaxWidth(),
) { Text("Continue") }
}
@Composable
private fun PeriodEnd(state: OnboardingUiState, viewModel: OnboardingViewModel) {
Spacer(Modifier.height(32.dp))
Heading("When did it end?")
var picked by remember { mutableStateOf<LocalDate?>(null) }
DateField(
label = picked?.pretty() ?: "Choose a date",
selected = picked,
earliest = state.lastPeriodStart,
latest = state.today,
onSelected = { picked = it },
)
Spacer(Modifier.height(24.dp))
Button(
onClick = { viewModel.setPeriodEnd(picked) },
enabled = picked != null,
modifier = Modifier.fillMaxWidth(),
) { Text("Continue") }
Spacer(Modifier.height(8.dp))
// Both of these mean "no end date". §24: never invent one.
OutlinedButton(onClick = { viewModel.setPeriodEnd(null) }, Modifier.fillMaxWidth()) { Text("Still going") }
Spacer(Modifier.height(8.dp))
OutlinedButton(onClick = { viewModel.setPeriodEnd(null) }, Modifier.fillMaxWidth()) { Text("I'm not sure") }
}
@Composable
private fun PreviousHistory(state: OnboardingUiState, viewModel: OnboardingViewModel) {
Spacer(Modifier.height(24.dp))
LearningIllustration(
color = MaterialTheme.colorScheme.primary,
accent = MaterialTheme.colorScheme.tertiary,
size = 120.dp,
)
Spacer(Modifier.height(24.dp))
Heading(
"Remember any earlier periods?",
"Adding previous dates helps us learn your cycle faster.",
)
state.previousStarts.forEach { date ->
Row(
Modifier.fillMaxWidth().padding(vertical = 2.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(date.pretty(), style = MaterialTheme.typography.bodyLarge)
TextButton(onClick = { viewModel.removePreviousStart(date) }) { Text("Remove") }
}
}
Spacer(Modifier.height(8.dp))
DateField(
label = "Add Previous Period",
selected = null,
latest = state.lastPeriodStart?.minusDays(1) ?: state.today,
onSelected = viewModel::addPreviousStart,
)
Spacer(Modifier.height(24.dp))
Button(onClick = viewModel::donePreviousHistory, modifier = Modifier.fillMaxWidth()) {
Text(if (state.previousStarts.isEmpty()) "Skip" else "Continue")
}
}
@Composable
private fun PrivacyPromise(onNext: () -> Unit) {
Spacer(Modifier.height(24.dp))
PrivacyIllustration(
color = MaterialTheme.colorScheme.primary,
accent = MaterialTheme.colorScheme.tertiary,
size = 140.dp,
)
Spacer(Modifier.height(24.dp))
// §4 requires this promise here, in Settings, and on the public privacy
// page. The wording is deliberate: we never SELL your data. It does not
// claim no third party ever processes anything, because Play Billing and an
// ad SDK will, and a promise the implementation cannot keep is worse than a
// narrower one it can.
Heading(
"Your cycle belongs to you.",
"We will never sell your personal or health data.\n\n" +
"Your period history and fertility information are private. We don't sell them " +
"to advertisers, data brokers, or third parties.",
)
Button(onClick = onNext, modifier = Modifier.fillMaxWidth()) { Text("Continue") }
}
@Composable
private fun NotificationPrivacyStep(state: OnboardingUiState, viewModel: OnboardingViewModel) {
Spacer(Modifier.height(32.dp))
Heading("How should reminders appear?")
Column(Modifier.selectableGroup().fillMaxWidth()) {
PrivacyOption(
title = "Discreet",
example = "\"Quick check-in\"",
selected = state.notificationPrivacy == NotificationPrivacy.DISCREET,
) { viewModel.setNotificationPrivacy(NotificationPrivacy.DISCREET) }
PrivacyOption(
title = "Maximum privacy",
example = "\"Reminder\"",
selected = state.notificationPrivacy == NotificationPrivacy.MAXIMUM_PRIVACY,
) { viewModel.setNotificationPrivacy(NotificationPrivacy.MAXIMUM_PRIVACY) }
// §28: the user must explicitly choose Direct. It is last and it is
// never the default, because a lock screen is read by whoever is nearby.
PrivacyOption(
title = "Direct",
example = "\"Your period may start soon\"",
selected = state.notificationPrivacy == NotificationPrivacy.DIRECT,
) { viewModel.setNotificationPrivacy(NotificationPrivacy.DIRECT) }
}
Spacer(Modifier.height(24.dp))
Button(
onClick = viewModel::finishSetup,
enabled = !state.saving,
modifier = Modifier.fillMaxWidth(),
) { Text(if (state.saving) "Saving…" else "Continue") }
}
@Composable
private fun PrivacyOption(title: String, example: String, selected: Boolean, onSelect: () -> Unit) {
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Row(
Modifier.fillMaxWidth().padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(selected = selected, onClick = onSelect)
Spacer(Modifier.size(8.dp))
Column {
Text(title, style = MaterialTheme.typography.titleMedium)
Text(
example,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Composable
private fun FirstForecast(state: OnboardingUiState, onDone: () -> Unit) {
Spacer(Modifier.height(32.dp))
Heading("Your first forecast")
val forecast = state.forecast
if (forecast == null) {
Text(
"We'll start predicting once there's a little history to learn from.",
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
)
} else {
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Label("Next period")
Text(forecast.mostLikelyStartDate.pretty(), style = MaterialTheme.typography.headlineSmall)
Spacer(Modifier.height(8.dp))
Label("Expected window")
Text("${forecast.windowStart.pretty()} ${forecast.windowEnd.pretty()}")
Spacer(Modifier.height(8.dp))
Label("Prediction confidence")
Text(forecast.confidenceLabel.readable())
}
}
Spacer(Modifier.height(12.dp))
Text(
"These are estimates from your cycle history, and they get better as you log more.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
Spacer(Modifier.height(28.dp))
Button(onClick = onDone, modifier = Modifier.fillMaxWidth()) { Text("Go to Today") }
}
@Composable
private fun Label(text: String) = Text(
text,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
/** A date button that opens a Material 3 picker, bounded rather than validated. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun DateField(
label: String,
selected: LocalDate?,
onSelected: (LocalDate) -> Unit,
earliest: LocalDate? = null,
latest: LocalDate? = null,
) {
var open by remember { mutableStateOf(false) }
OutlinedButton(onClick = { open = true }, modifier = Modifier.fillMaxWidth()) { Text(label) }
if (open) {
val pickerState = androidx.compose.material3.rememberDatePickerState(
initialSelectedDateMillis = selected?.toEpochMillis(),
selectableDates = object : SelectableDates {
// A future period start is not offered rather than rejected.
// Refusing a tap the UI allowed is a worse experience than not
// allowing it, and there is no honest meaning for the value.
override fun isSelectableDate(utcTimeMillis: Long): Boolean {
val d = Instant.ofEpochMilli(utcTimeMillis).atZone(ZoneOffset.UTC).toLocalDate()
if (earliest != null && d < earliest) return false
if (latest != null && d > latest) return false
return true
}
},
)
DatePickerDialog(
onDismissRequest = { open = false },
confirmButton = {
TextButton(onClick = {
pickerState.selectedDateMillis?.let {
onSelected(Instant.ofEpochMilli(it).atZone(ZoneOffset.UTC).toLocalDate())
}
open = false
}) { Text("OK") }
},
dismissButton = { TextButton(onClick = { open = false }) { Text("Cancel") } },
) { DatePicker(state = pickerState) }
}
}
private fun LocalDate.toEpochMillis(): Long = atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
private val dateFormat = DateTimeFormatter.ofPattern("d MMMM yyyy")
internal fun LocalDate.pretty(): String = format(dateFormat)
internal fun dev.privacyllc.period.domain.prediction.ConfidenceLabel.readable(): String = when (this) {
dev.privacyllc.period.domain.prediction.ConfidenceLabel.LOW -> "Low"
dev.privacyllc.period.domain.prediction.ConfidenceLabel.MEDIUM -> "Medium"
dev.privacyllc.period.domain.prediction.ConfidenceLabel.HIGH -> "High"
}

View File

@ -0,0 +1,168 @@
package dev.privacyllc.period.feature.onboarding
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dev.privacyllc.period.core.data.CycleRepository
import dev.privacyllc.period.core.datastore.NotificationPrivacy
import dev.privacyllc.period.core.datastore.UserPreferencesRepository
import dev.privacyllc.period.domain.cycle.PeriodRecordSource
import dev.privacyllc.period.domain.prediction.Prediction
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import java.time.Clock
import java.time.LocalDate
import javax.inject.Inject
/**
* The seven steps of PRODUCT_PLAN.md §19.
*
* An enum rather than a page index, so a step can be skipped or reordered
* without arithmetic [Step.PREVIOUS_HISTORY] is skippable by design.
*/
enum class Step {
WELCOME,
LAST_PERIOD,
PERIOD_END,
PREVIOUS_HISTORY,
PRIVACY_PROMISE,
NOTIFICATION_PRIVACY,
FIRST_FORECAST,
}
data class OnboardingUiState(
val step: Step = Step.WELCOME,
val today: LocalDate = LocalDate.ofEpochDay(0),
val lastPeriodStart: LocalDate? = null,
val lastPeriodEnd: LocalDate? = null,
/** Earlier starts the user chose to add. Newest first. */
val previousStarts: List<LocalDate> = emptyList(),
val notificationPrivacy: NotificationPrivacy = NotificationPrivacy.DISCREET,
val forecast: Prediction? = null,
val saving: Boolean = false,
val message: String? = null,
) {
val canContinueFromLastPeriod: Boolean get() = lastPeriodStart != null
}
@HiltViewModel
class OnboardingViewModel @Inject constructor(
private val repository: CycleRepository,
private val preferences: UserPreferencesRepository,
private val clock: Clock,
) : ViewModel() {
private val _state = MutableStateFlow(OnboardingUiState(today = LocalDate.now(clock)))
val state: StateFlow<OnboardingUiState> = _state.asStateFlow()
/** Nothing here may take the app down before the user has even used it. */
private val handler = CoroutineExceptionHandler { _, e ->
_state.value = _state.value.copy(
saving = false,
message = "Could not save that (${e::class.simpleName}). Nothing was changed.",
)
}
fun back() {
val steps = Step.entries
val i = steps.indexOf(_state.value.step)
if (i > 0) _state.value = _state.value.copy(step = steps[i - 1], message = null)
}
fun messageShown() { _state.value = _state.value.copy(message = null) }
fun toWelcomeNext() = go(Step.LAST_PERIOD)
fun setLastPeriodStart(date: LocalDate) {
// A start in the future is not something to correct silently; it is
// simply not offered — the picker is bounded rather than validated.
_state.value = _state.value.copy(lastPeriodStart = date)
}
fun confirmLastPeriod() = go(Step.PERIOD_END)
/** "Still going" and "I'm not sure" both mean no end date — never an invented one. */
fun setPeriodEnd(date: LocalDate?) {
_state.value = _state.value.copy(lastPeriodEnd = date)
go(Step.PREVIOUS_HISTORY)
}
fun addPreviousStart(date: LocalDate) {
val current = _state.value
if (date in current.previousStarts || date == current.lastPeriodStart) {
_state.value = current.copy(message = "That day is already added.")
return
}
_state.value = current.copy(
previousStarts = (current.previousStarts + date).sortedDescending(),
message = null,
)
}
fun removePreviousStart(date: LocalDate) {
_state.value = _state.value.copy(previousStarts = _state.value.previousStarts - date)
}
fun donePreviousHistory() = go(Step.PRIVACY_PROMISE)
fun donePrivacyPromise() = go(Step.NOTIFICATION_PRIVACY)
fun setNotificationPrivacy(value: NotificationPrivacy) {
_state.value = _state.value.copy(notificationPrivacy = value)
}
/**
* Everything the user entered, written at once, then the forecast.
*
* Deliberately the last moment rather than as they go: somebody who
* abandons onboarding halfway has not asked this app to remember anything
* about them yet.
*/
fun finishSetup() {
val current = _state.value
val start = current.lastPeriodStart ?: return
_state.value = current.copy(saving = true)
viewModelScope.launch(handler) {
// Oldest first, so each confirmation scores against a history that
// already contains everything before it.
current.previousStarts.sorted().forEach {
repository.confirmPeriodStart(it, PeriodRecordSource.HISTORICAL_ENTRY)
}
repository.confirmPeriodStart(start, PeriodRecordSource.MANUAL)
current.lastPeriodEnd?.let { end ->
repository.confirmedPeriods.first()
.firstOrNull { it.startDate == start }
?.let { repository.setPeriodEnd(it.id, end) }
}
preferences.setNotificationPrivacy(current.notificationPrivacy)
_state.value = _state.value.copy(
saving = false,
forecast = repository.forecast.first(),
step = Step.FIRST_FORECAST,
)
}
}
/**
* The flag that decides where the app starts, set only here.
*
* Written at the end rather than at the beginning so a half-finished
* onboarding is resumed rather than skipped the alternative loses whatever
* the user typed and drops them on an empty Today screen.
*/
fun complete(onDone: () -> Unit) {
viewModelScope.launch(handler) {
preferences.setOnboardingCompleted(true)
onDone()
}
}
private fun go(step: Step) { _state.value = _state.value.copy(step = step, message = null) }
}

View File

@ -30,9 +30,12 @@ import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import dev.privacyllc.period.R import dev.privacyllc.period.R
import dev.privacyllc.period.designsystem.PeriodTheme import dev.privacyllc.period.designsystem.PeriodTheme
import dev.privacyllc.period.feature.onboarding.OnboardingScreen
import dev.privacyllc.period.feature.today.TodayScreen import dev.privacyllc.period.feature.today.TodayScreen
/** /**
@ -52,6 +55,25 @@ enum class PeriodDestination(
SETTINGS("settings", R.string.tab_settings, Icons.Filled.Settings), SETTINGS("settings", R.string.tab_settings, Icons.Filled.Settings),
} }
/**
* The root: onboarding, then the app.
*
* The decision comes from `UserPreferences.onboardingCompleted` and from
* nowhere else. A second source "does any period exist?" would look
* equivalent and is not: somebody who deletes all their data has not asked to be
* onboarded again, and somebody who abandoned onboarding halfway has.
*/
@Composable
fun PeriodRoot(viewModel: RootViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsStateWithLifecycle()
when (state) {
RootState.Loading -> Unit // one frame; a spinner here flashes and reads as jank
RootState.Onboarding -> OnboardingScreen(onFinished = viewModel::onboardingFinished)
RootState.Ready -> PeriodApp()
}
}
@Composable @Composable
fun PeriodApp() { fun PeriodApp() {
val navController = rememberNavController() val navController = rememberNavController()

View File

@ -0,0 +1,39 @@
package dev.privacyllc.period.navigation
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dev.privacyllc.period.core.datastore.UserPreferencesRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject
enum class RootState { Loading, Onboarding, Ready }
@HiltViewModel
class RootViewModel @Inject constructor(
preferences: UserPreferencesRepository,
) : ViewModel() {
/**
* Set when onboarding finishes, so the switch happens immediately rather
* than waiting for the preference write to travel back through DataStore
* which is fast, but not instant, and a visible flicker on the last tap of
* onboarding is a poor first impression.
*/
private val finishedThisSession = MutableStateFlow(false)
val state: StateFlow<RootState> =
combine(
preferences.preferences.map { it.onboardingCompleted },
finishedThisSession,
) { completed, finished ->
if (completed || finished) RootState.Ready else RootState.Onboarding
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), RootState.Loading)
fun onboardingFinished() { finishedThisSession.value = true }
}

View File

@ -0,0 +1,228 @@
package dev.privacyllc.period.feature.onboarding
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.test.core.app.ApplicationProvider
import dev.privacyllc.period.core.data.CycleData
import dev.privacyllc.period.core.data.CycleRepository
import dev.privacyllc.period.core.datastore.NotificationPrivacy
import dev.privacyllc.period.core.datastore.UserPreferencesRepository
import dev.privacyllc.period.domain.cycle.PeriodRecordSource
import dev.privacyllc.period.domain.prediction.PersonalPredictionEngine
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import kotlinx.coroutines.withTimeout
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.time.Clock
import java.time.LocalDate
import java.time.ZoneOffset
/**
* The onboarding flow's decisions, as tests.
*
* Every one of these is something §19 states and something a later change could
* quietly undo a default flipped, a write moved earlier, a skip that stops
* skipping.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class OnboardingViewModelTest {
@get:Rule val tmp = TemporaryFolder()
/**
* Unconfined, so a `viewModelScope.launch` starts eagerly rather than
* waiting to be advanced.
*
* Virtual time is the wrong tool here and it cost a confusing red first:
* Room runs its queries on its own executor, which no test scheduler
* drives, so `advanceUntilIdle()` returned with the writes still in flight
* and the assertions read an empty database. Anything that crosses into
* Room is awaited on the real clock by [awaitUntil].
*/
private val dispatcher = UnconfinedTestDispatcher()
private lateinit var repo: CycleRepository
private lateinit var prefs: UserPreferencesRepository
private lateinit var vm: OnboardingViewModel
private val today = LocalDate.of(2026, 8, 18)
private val clock = Clock.fixed(today.atStartOfDay(ZoneOffset.UTC).toInstant(), ZoneOffset.UTC)
@Before fun setUp() {
Dispatchers.setMain(dispatcher)
// Built through the public factory, like the app does. The repository's
// constructor is internal to core:data on purpose, and reaching around
// that for a test would put Room on this module's classpath — the exact
// leak the boundary guard exists to prevent, waved through because it
// was "only a test".
repo = CycleData.repository(
ApplicationProvider.getApplicationContext(),
PersonalPredictionEngine(),
clock,
)
prefs = UserPreferencesRepository(
PreferenceDataStoreFactory.create(
scope = CoroutineScope(dispatcher),
produceFile = { tmp.newFile("prefs.preferences_pb") },
),
)
vm = OnboardingViewModel(repo, prefs, clock)
// Robolectric reuses the app's data directory across tests in a class,
// so the file-backed database survives between them. Cleared here
// rather than assumed empty.
runBlocking { repo.deleteAllHealthData() }
}
@After fun tearDown() {
Dispatchers.resetMain()
}
/** Wait on the real clock for something Room is doing on its own executor. */
private fun awaitUntil(what: String, predicate: suspend () -> Boolean) = runBlocking {
withTimeout(5_000) {
while (!predicate()) delay(10)
}
Unit
}.also { }
@Test fun `it starts at welcome and walks forward through the seven steps`() {
assertEquals(Step.WELCOME, vm.state.value.step)
vm.toWelcomeNext()
assertEquals(Step.LAST_PERIOD, vm.state.value.step)
vm.setLastPeriodStart(today.minusDays(3))
vm.confirmLastPeriod()
assertEquals(Step.PERIOD_END, vm.state.value.step)
vm.setPeriodEnd(today)
assertEquals(Step.PREVIOUS_HISTORY, vm.state.value.step)
vm.donePreviousHistory()
assertEquals(Step.PRIVACY_PROMISE, vm.state.value.step)
vm.donePrivacyPromise()
assertEquals(Step.NOTIFICATION_PRIVACY, vm.state.value.step)
}
@Test fun `continuing is refused until a last period date exists`() {
vm.toWelcomeNext()
assertFalse("nothing to continue from yet", vm.state.value.canContinueFromLastPeriod)
vm.setLastPeriodStart(today.minusDays(2))
assertTrue(vm.state.value.canContinueFromLastPeriod)
}
@Test fun `still going and not sure both leave the end date empty`() {
vm.toWelcomeNext()
vm.setLastPeriodStart(today.minusDays(2))
vm.confirmLastPeriod()
// §24: never invent an end date.
vm.setPeriodEnd(null)
assertEquals(null, vm.state.value.lastPeriodEnd)
}
@Test fun `notification privacy is discreet before the user touches anything`() {
// §28. A default of Direct would put menstrual detail on a lock screen
// before a single question has been asked.
assertEquals(NotificationPrivacy.DISCREET, vm.state.value.notificationPrivacy)
}
@Test fun `previous history is optional and de-duplicates`() {
vm.toWelcomeNext()
vm.setLastPeriodStart(today.minusDays(2))
vm.confirmLastPeriod()
vm.setPeriodEnd(null)
val earlier = today.minusDays(31)
vm.addPreviousStart(earlier)
vm.addPreviousStart(earlier)
assertEquals(1, vm.state.value.previousStarts.size)
assertNotNull("and says why rather than silently ignoring", vm.state.value.message)
vm.removePreviousStart(earlier)
assertTrue(vm.state.value.previousStarts.isEmpty())
}
@Test fun `nothing is written until the final step`() = runBlocking {
vm.toWelcomeNext()
vm.setLastPeriodStart(today.minusDays(2))
vm.confirmLastPeriod()
vm.setPeriodEnd(null)
vm.addPreviousStart(today.minusDays(31))
vm.donePreviousHistory()
vm.donePrivacyPromise()
// Somebody who abandons onboarding here has not asked this app to
// remember anything about them.
assertTrue("nothing stored before finishSetup", repo.confirmedPeriods.first().isEmpty())
assertFalse("and onboarding is not marked complete", prefs.preferences.first().onboardingCompleted)
}
@Test fun `finishing writes the history, the preference and produces a forecast`() = runBlocking {
vm.toWelcomeNext()
vm.setLastPeriodStart(today.minusDays(2))
vm.confirmLastPeriod()
vm.setPeriodEnd(null)
vm.addPreviousStart(today.minusDays(31))
vm.addPreviousStart(today.minusDays(60))
vm.donePreviousHistory()
vm.donePrivacyPromise()
vm.setNotificationPrivacy(NotificationPrivacy.MAXIMUM_PRIVACY)
vm.finishSetup()
awaitUntil("setup to finish") { vm.state.value.step == Step.FIRST_FORECAST }
val periods = repo.confirmedPeriods.first()
assertEquals(3, periods.size)
assertEquals(
"earlier entries are recorded as historical, not as things that just happened",
2,
periods.count { it.source == PeriodRecordSource.HISTORICAL_ENTRY },
)
assertEquals(NotificationPrivacy.MAXIMUM_PRIVACY, prefs.preferences.first().notificationPrivacy)
assertEquals(Step.FIRST_FORECAST, vm.state.value.step)
assertNotNull("§19: a forecast, before anything else is asked", vm.state.value.forecast)
}
@Test fun `the completion flag is set only at the very end`() = runBlocking {
vm.toWelcomeNext()
vm.setLastPeriodStart(today.minusDays(2))
vm.confirmLastPeriod()
vm.setPeriodEnd(null)
vm.donePreviousHistory()
vm.donePrivacyPromise()
vm.finishSetup()
awaitUntil("setup to finish") { vm.state.value.step == Step.FIRST_FORECAST }
// Still false: reaching the forecast is not finishing.
assertFalse(prefs.preferences.first().onboardingCompleted)
var called = false
vm.complete { called = true }
awaitUntil("completion to be written") { prefs.preferences.first().onboardingCompleted }
assertTrue(called)
}
@Test fun `back steps return without losing what was entered`() {
vm.toWelcomeNext()
vm.setLastPeriodStart(today.minusDays(5))
vm.confirmLastPeriod()
vm.back()
assertEquals(Step.LAST_PERIOD, vm.state.value.step)
assertEquals(today.minusDays(5), vm.state.value.lastPeriodStart)
}
}

View File

@ -1,7 +1,9 @@
package dev.privacyllc.period.designsystem package dev.privacyllc.period.designsystem
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Typography import androidx.compose.material3.Typography
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme import androidx.compose.material3.lightColorScheme
@ -9,6 +11,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@ -104,9 +107,27 @@ fun PeriodTheme(
MaterialTheme( MaterialTheme(
colorScheme = if (darkTheme) DarkColors else LightColors, colorScheme = if (darkTheme) DarkColors else LightColors,
typography = PeriodTypography, typography = PeriodTypography,
) {
// The Surface is not decoration, and leaving it out was a real bug.
//
// Material 3 sets `LocalContentColor` from a Surface. Without one,
// any Text that does not name a colour inherits the default —
// BLACK — and the app's own background never paints at all. In
// light mode that looked correct by accident, because dark text on
// cream is what was wanted anyway. In dark mode the onboarding
// headings rendered near-black on charcoal and were barely
// readable, which is how it was found: by opening it.
//
// Putting it here rather than in each screen means no screen can
// forget, including the ones without a Scaffold.
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
contentColor = MaterialTheme.colorScheme.onBackground,
content = content, content = content,
) )
} }
}
} }
object PeriodThemeDefaults { object PeriodThemeDefaults {

View File

@ -81,6 +81,22 @@ The palette is [§39](../planning/PRODUCT_PLAN.md); the constraints on it are:
`core/designsystem`. **No hard-coded colours in a Composable** — the guard is `core/designsystem`. **No hard-coded colours in a Composable** — the guard is
that a colour literal outside the token file is a review failure. that a colour literal outside the token file is a review failure.
## Both themes, every time
Dark mode was broken for the whole of Batch 01 and nothing said so. `PeriodTheme`
was not wrapping its content in a `Surface`, so any `Text` without an explicit
colour inherited Material's default — black — and the app's own background never
painted at all. **In light mode that looked correct by accident**, because dark
text on cream is what was wanted anyway; in dark mode the onboarding headings
were near-black on charcoal.
No test caught it and no test easily would. It was found by opening the app.
Two rules follow. Every screen gets a **preview pair, light and dark** — cheap,
and it puts the failure in front of whoever is editing. And the `Surface` lives
in the theme rather than in each screen, so a screen without a `Scaffold` cannot
forget it.
## The states most often left undesigned ## The states most often left undesigned
Designed here on purpose, because they are the two most people meet first: Designed here on purpose, because they are the two most people meet first: