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:
parent
e15c49752d
commit
2479a1ddf4
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,9 +107,27 @@ fun PeriodTheme(
|
|||
MaterialTheme(
|
||||
colorScheme = if (darkTheme) DarkColors else LightColors,
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object PeriodThemeDefaults {
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in New Issue