diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 58f6ffa..d7b5c9f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -82,6 +82,9 @@ dependencies { ksp(libs.hilt.compiler) testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.robolectric) + testImplementation(libs.androidx.test.core) androidTestImplementation(libs.androidx.test.junit) androidTestImplementation(libs.androidx.espresso.core) } diff --git a/app/src/main/kotlin/dev/privacyllc/period/MainActivity.kt b/app/src/main/kotlin/dev/privacyllc/period/MainActivity.kt index de46ea9..8e7421a 100644 --- a/app/src/main/kotlin/dev/privacyllc/period/MainActivity.kt +++ b/app/src/main/kotlin/dev/privacyllc/period/MainActivity.kt @@ -6,7 +6,7 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import dagger.hilt.android.AndroidEntryPoint import dev.privacyllc.period.designsystem.PeriodTheme -import dev.privacyllc.period.navigation.PeriodApp +import dev.privacyllc.period.navigation.PeriodRoot @AndroidEntryPoint class MainActivity : ComponentActivity() { @@ -15,7 +15,7 @@ class MainActivity : ComponentActivity() { super.onCreate(savedInstanceState) setContent { PeriodTheme { - PeriodApp() + PeriodRoot() } } } diff --git a/app/src/main/kotlin/dev/privacyllc/period/feature/onboarding/OnboardingPreviews.kt b/app/src/main/kotlin/dev/privacyllc/period/feature/onboarding/OnboardingPreviews.kt new file mode 100644 index 0000000..b359cd8 --- /dev/null +++ b/app/src/main/kotlin/dev/privacyllc/period/feature/onboarding/OnboardingPreviews.kt @@ -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) + } + } +} diff --git a/app/src/main/kotlin/dev/privacyllc/period/feature/onboarding/OnboardingScreen.kt b/app/src/main/kotlin/dev/privacyllc/period/feature/onboarding/OnboardingScreen.kt new file mode 100644 index 0000000..a3ea06d --- /dev/null +++ b/app/src/main/kotlin/dev/privacyllc/period/feature/onboarding/OnboardingScreen.kt @@ -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(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" +} diff --git a/app/src/main/kotlin/dev/privacyllc/period/feature/onboarding/OnboardingViewModel.kt b/app/src/main/kotlin/dev/privacyllc/period/feature/onboarding/OnboardingViewModel.kt new file mode 100644 index 0000000..06dcec7 --- /dev/null +++ b/app/src/main/kotlin/dev/privacyllc/period/feature/onboarding/OnboardingViewModel.kt @@ -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 = 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 = _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) } +} diff --git a/app/src/main/kotlin/dev/privacyllc/period/navigation/PeriodApp.kt b/app/src/main/kotlin/dev/privacyllc/period/navigation/PeriodApp.kt index 16c067a..43053b8 100644 --- a/app/src/main/kotlin/dev/privacyllc/period/navigation/PeriodApp.kt +++ b/app/src/main/kotlin/dev/privacyllc/period/navigation/PeriodApp.kt @@ -30,9 +30,12 @@ import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.compose.rememberNavController import dev.privacyllc.period.R import dev.privacyllc.period.designsystem.PeriodTheme +import dev.privacyllc.period.feature.onboarding.OnboardingScreen import dev.privacyllc.period.feature.today.TodayScreen /** @@ -52,6 +55,25 @@ enum class PeriodDestination( 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 fun PeriodApp() { val navController = rememberNavController() diff --git a/app/src/main/kotlin/dev/privacyllc/period/navigation/RootViewModel.kt b/app/src/main/kotlin/dev/privacyllc/period/navigation/RootViewModel.kt new file mode 100644 index 0000000..ec50276 --- /dev/null +++ b/app/src/main/kotlin/dev/privacyllc/period/navigation/RootViewModel.kt @@ -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 = + 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 } +} diff --git a/app/src/test/kotlin/dev/privacyllc/period/feature/onboarding/OnboardingViewModelTest.kt b/app/src/test/kotlin/dev/privacyllc/period/feature/onboarding/OnboardingViewModelTest.kt new file mode 100644 index 0000000..60605f4 --- /dev/null +++ b/app/src/test/kotlin/dev/privacyllc/period/feature/onboarding/OnboardingViewModelTest.kt @@ -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) + } +} diff --git a/core/designsystem/src/main/kotlin/dev/privacyllc/period/designsystem/Theme.kt b/core/designsystem/src/main/kotlin/dev/privacyllc/period/designsystem/Theme.kt index 9b3fd53..cebb321 100644 --- a/core/designsystem/src/main/kotlin/dev/privacyllc/period/designsystem/Theme.kt +++ b/core/designsystem/src/main/kotlin/dev/privacyllc/period/designsystem/Theme.kt @@ -1,7 +1,9 @@ package dev.privacyllc.period.designsystem import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Typography import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme @@ -9,6 +11,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp @@ -104,8 +107,26 @@ fun PeriodTheme( MaterialTheme( colorScheme = if (darkTheme) DarkColors else LightColors, typography = PeriodTypography, - content = content, - ) + ) { + // 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, + ) + } } } diff --git a/docs/design/README.md b/docs/design/README.md index 5850e65..6d86c9e 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -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 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 Designed here on purpose, because they are the two most people meet first: