diff --git a/app/src/main/kotlin/dev/privacyllc/period/feature/today/TodayScreen.kt b/app/src/main/kotlin/dev/privacyllc/period/feature/today/TodayScreen.kt index 810bbf3..ee7696a 100644 --- a/app/src/main/kotlin/dev/privacyllc/period/feature/today/TodayScreen.kt +++ b/app/src/main/kotlin/dev/privacyllc/period/feature/today/TodayScreen.kt @@ -1,16 +1,20 @@ package dev.privacyllc.period.feature.today +import android.content.res.Configuration import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box 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.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.Card -import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text @@ -21,215 +25,487 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import dev.privacyllc.period.designsystem.PeriodTheme +import dev.privacyllc.period.designsystem.PeriodThemeDefaults +import dev.privacyllc.period.designsystem.art.CycleProgressMark +import dev.privacyllc.period.designsystem.art.EmptyStateIllustration import dev.privacyllc.period.domain.cycle.PeriodRecord import dev.privacyllc.period.domain.prediction.ConfidenceLabel +import dev.privacyllc.period.domain.prediction.CycleStatus +import dev.privacyllc.period.domain.prediction.Prediction import java.time.LocalDate import java.time.format.DateTimeFormatter /** - * A working surface, and deliberately not the designed one. + * The hero screen — PRODUCT_PLAN.md §21 and §22. * - * PRODUCT_PLAN.md §21–§22 specify the Today screen: a hero number, six dynamic - * states, restrained motion. That is Batch 03. This exists to prove the loop in - * §58 end to end — enter starts, store them, recalculate, see it change — and - * it says "Batch 01 · working surface" at the top so nobody mistakes it for the - * real thing. A convincing mock is how a screen comes to be believed finished. + * **The forecast number dominates** (§38). Nothing on this screen competes with + * it, including anything of ours: the ad slot is at the very bottom, the history + * is not here at all, and the secondary lines are deliberately quiet. + * + * Six states, §22, each a different screen rather than a variant of one. Which + * one applies is decided by `CycleStatusRules`, in a pure module with twelve + * tests on its boundaries, because the boundaries are where this screen is most + * often read: the day a period is due, the day after one ends, the day a + * forecast slips. + * + * The state this screen exists to get right is the last one. Past the forecast, + * the app **never says late** — nothing is wrong with the user, the model was + * imprecise, and it says so and shows both forecasts rather than moving the + * goalposts quietly. */ @Composable fun TodayScreen(viewModel: TodayViewModel = hiltViewModel()) { val state by viewModel.state.collectAsStateWithLifecycle() val message by viewModel.message.collectAsStateWithLifecycle() - LazyColumn( - modifier = Modifier.fillMaxSize().padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), + TodayContent( + state = state, + message = message, + onStarted = { viewModel.confirmStart(state.today) }, + onNotYet = viewModel::notYet, + onEndedToday = { + state.periods.maxByOrNull { it.startDate }?.let { viewModel.setEnd(it.id, state.today) } + }, + onStillGoing = { + state.periods.maxByOrNull { it.startDate }?.let { viewModel.setEnd(it.id, null) } + }, + onMessageShown = viewModel::messageShown, + ) +} + +@Composable +private fun TodayContent( + state: TodayUiState, + message: String?, + onStarted: () -> Unit, + onNotYet: () -> Unit, + onEndedToday: () -> Unit, + onStillGoing: () -> Unit, + onMessageShown: () -> Unit, +) { + Column( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, ) { - item { - Text( - "Batch 01 · working surface — the designed Today screen is Batch 03", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + Column( + Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + when (val status = state.status) { + CycleStatus.NoData -> EmptyState() + is CycleStatus.DuringPeriod -> DuringPeriod(status, onEndedToday, onStillGoing) + is CycleStatus.BetweenPeriodAndFertile -> BetweenState(status, state, onStarted) + is CycleStatus.DuringFertileWindow -> FertileState(status, state, onStarted) + is CycleStatus.PeriodApproaching -> ApproachingState(status, state, onStarted) + is CycleStatus.PredictedDay -> PredictedDayState(status, state, onStarted, onNotYet) + is CycleStatus.BeyondForecast -> BeyondForecastState(status, onStarted, onNotYet) + } - item { ForecastCard(state, viewModel) } - - message?.let { text -> - item { + message?.let { + Spacer(Modifier.height(20.dp)) Card(Modifier.fillMaxWidth()) { Row( Modifier.fillMaxWidth().padding(12.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - Text(text, style = MaterialTheme.typography.bodyMedium) - TextButton(onClick = viewModel::messageShown) { Text("OK") } + Text(it, style = MaterialTheme.typography.bodyMedium) + TextButton(onClick = onMessageShown) { Text("OK") } } } } } - item { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button(onClick = { viewModel.confirmStart(state.today) }) { Text("Started today") } - OutlinedButton(onClick = { viewModel.notYet() }) { Text("Not yet") } - } - } + Spacer(Modifier.weight(1f, fill = false)) + BannerSlot() + } +} - item { - Text("History", style = MaterialTheme.typography.titleMedium) - if (state.periods.isEmpty() && !state.loading) { - Text( - "No periods logged yet. Log one above, or add an earlier one to " + - "help the app learn your cycle faster.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } +// --------------------------------------------------------------------------- +// The hero +// --------------------------------------------------------------------------- - items(state.periods, key = { it.id }) { record -> - PeriodRow( - record = record, - onShiftStart = { days -> viewModel.edit(record.id, record.startDate.plusDays(days), record.endDate) }, - onEndToday = { viewModel.setEnd(record.id, state.today) }, - onStillGoing = { viewModel.setEnd(record.id, null) }, - onDelete = { viewModel.delete(record.id) }, - ) - } +/** + * The number, and it is the whole point of the screen. + * + * `displayLarge` is 72sp bold in this theme for exactly this. §38: the number + * should command attention before secondary details. + * + * The unit is a separate, small, wide-tracked line rather than part of the + * number, so "4" reads instantly and "DAYS" is available if you look. + */ +@Composable +private fun HeroCount(label: String, count: Int, unit: String) { + Text( + label.uppercase(), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Text( + "$count", + style = MaterialTheme.typography.displayLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.clearAndSetSemantics { + contentDescription = "$label: $count $unit" + }, + ) + Text( + unit.uppercase(), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} - if (state.cycles.isNotEmpty()) { - item { - HorizontalDivider() - Text("Cycle lengths", style = MaterialTheme.typography.titleMedium) - Text( - state.cycles.joinToString(" · ") { "${it.cycleLengthDays}d" }, - style = MaterialTheme.typography.bodyMedium, - ) - } - } +@Composable +private fun ForecastDetail(forecast: Prediction) { + Spacer(Modifier.height(20.dp)) + Quiet("Most likely") + Text(forecast.mostLikelyStartDate.pretty(), style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(8.dp)) + Quiet("Expected") + Text("${forecast.windowStart.short()} – ${forecast.windowEnd.short()}") + Spacer(Modifier.height(8.dp)) + ConfidenceRow(forecast.confidenceLabel) +} - if (state.accuracy.hasEnoughToShow) { - item { AccuracyCard(state) } - } +/** + * Confidence as dots plus a word. + * + * Never dots alone: §43 forbids relying on a visual cue by itself, and "how + * sure is it" is precisely the thing a screen reader user needs. + */ +@Composable +private fun ConfidenceRow(label: ConfidenceLabel) { + val filled = when (label) { + ConfidenceLabel.LOW -> 1 + ConfidenceLabel.MEDIUM -> 2 + ConfidenceLabel.HIGH -> 3 + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.clearAndSetSemantics { + contentDescription = "Prediction confidence: ${label.readable()}" + }, + ) { + Text("●".repeat(filled) + "○".repeat(3 - filled), color = MaterialTheme.colorScheme.primary) + Text(label.readable(), style = MaterialTheme.typography.bodyMedium) } } @Composable -private fun ForecastCard(state: TodayUiState, viewModel: TodayViewModel) { - Card(Modifier.fillMaxWidth()) { - Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { - val forecast = state.forecast - if (forecast == null) { - Text("No forecast yet", style = MaterialTheme.typography.titleLarge) - Text( - "Log a period and the app will start learning your cycle.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - return@Column - } +private fun Quiet(text: String) = Text( + text, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, +) - state.cycleDay?.let { Text("Cycle day $it", style = MaterialTheme.typography.labelLarge) } +@Composable +private fun PrimaryAction(text: String, onClick: () -> Unit) { + Spacer(Modifier.height(28.dp)) + Button(onClick = onClick, modifier = Modifier.fillMaxWidth()) { Text(text) } +} - val days = state.daysUntil - // The window and the confidence travel with the date, always. - // §8 and §15: never a bare exact date presented as fact. - Text( - when { - days == null -> "Period expected" - days > 1L -> "Period likely in $days days" - days == 1L -> "Period likely tomorrow" - days == 0L -> "Your period may start today" - else -> "Not yet?" - }, - style = MaterialTheme.typography.titleLarge, - ) - Text("Most likely ${forecast.mostLikelyStartDate.pretty()}") - Text("Expected ${forecast.windowStart.pretty()} – ${forecast.windowEnd.pretty()}") - Text( - "Confidence ${forecast.confidenceLabel.label()}", - modifier = Modifier.clearAndSetSemantics { - contentDescription = "Prediction confidence: ${forecast.confidenceLabel.label()}" - }, - ) - } - } +// --------------------------------------------------------------------------- +// The six states +// --------------------------------------------------------------------------- + +@Composable +private fun EmptyState() { + Spacer(Modifier.height(24.dp)) + EmptyStateIllustration(color = MaterialTheme.colorScheme.outline, size = 140.dp) + Spacer(Modifier.height(24.dp)) + Text("No periods logged yet", style = MaterialTheme.typography.headlineSmall) + Spacer(Modifier.height(8.dp)) + Text( + "Log one and the app will start learning your cycle.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) } @Composable -private fun AccuracyCard(state: TodayUiState) { - val a = state.accuracy - Card(Modifier.fillMaxWidth()) { - Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text("Prediction accuracy", style = MaterialTheme.typography.titleMedium) - a.lastSignedErrorDays?.let { - Text( - when { - it == 0 -> "Last prediction: exact" - it < 0 -> "Last prediction: ${-it} day(s) early" - else -> "Last prediction: $it day(s) late" - }, - ) - } - a.meanAbsoluteErrorDays?.let { Text("Average error: ${"%.1f".format(it)} days") } - Text("${a.withinOneDay} of your last ${a.scoredCount} were within one day") - } - } -} - -@Composable -private fun PeriodRow( - record: PeriodRecord, - onShiftStart: (Long) -> Unit, - onEndToday: () -> Unit, +private fun DuringPeriod( + status: CycleStatus.DuringPeriod, + onEndedToday: () -> Unit, onStillGoing: () -> Unit, - onDelete: () -> Unit, ) { - Card(Modifier.fillMaxWidth()) { - Column(Modifier.padding(12.dp)) { - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column { - Text(record.startDate.pretty(), style = MaterialTheme.typography.titleSmall) - Text( - record.endDate?.let { "ended ${it.pretty()}" } ?: "still going", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Text( - record.source.name.lowercase(), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { - TextButton(onClick = { onShiftStart(-1) }) { Text("−1d") } - TextButton(onClick = { onShiftStart(1) }) { Text("+1d") } - if (record.endDate == null) { - TextButton(onClick = onEndToday) { Text("Ended") } - } else { - TextButton(onClick = onStillGoing) { Text("Still going") } - } - TextButton(onClick = onDelete) { Text("Delete") } - } - } + Text("Period", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(8.dp)) + HeroCount(label = "Day", count = status.dayOfPeriod, unit = "of your period") + Spacer(Modifier.height(20.dp)) + Quiet("Started") + Text(status.startedOn.pretty(), style = MaterialTheme.typography.titleMedium) + status.endedOn?.let { + Spacer(Modifier.height(8.dp)) + Quiet("Ended") + Text(it.pretty(), style = MaterialTheme.typography.titleMedium) + } + status.typicalDurationDays?.let { + Spacer(Modifier.height(8.dp)) + Quiet("Typical duration") + Text("${it.first}–${it.last} days") + } + + // Once an end date exists the only useful action is undoing it. Offering + // "Period ended" again would be a button that visibly does nothing, which + // is how the tap looked before this state carried the end date at all. + if (status.endedOn == null) { + PrimaryAction("Period ended", onEndedToday) + Spacer(Modifier.height(8.dp)) + OutlinedButton(onClick = onStillGoing, modifier = Modifier.fillMaxWidth()) { Text("Still going") } + } else { + PrimaryAction("Still going", onStillGoing) } } -/** "Low", not "LOW" — the user-facing labels in §15 are words, not enum names. */ -private fun ConfidenceLabel.label() = when (this) { +@Composable +private fun BetweenState( + status: CycleStatus.BetweenPeriodAndFertile, + state: TodayUiState, + onStarted: () -> Unit, +) { + CycleRing(state) + Text("Cycle day ${status.cycleDay}", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(16.dp)) + HeroCount(label = "Next period in", count = status.daysToNextPeriod, unit = "days") + // The fertile-window line from §22 appears when Batch 04 estimates it. + // A placeholder number here would be inventing a fertility estimate. + status.daysToFertileWindow?.let { + Spacer(Modifier.height(16.dp)) + Quiet("Estimated fertile window in") + Text("$it days") + } + state.forecast?.let { ForecastDetail(it) } + PrimaryAction("Started period", onStarted) +} + +@Composable +private fun FertileState( + status: CycleStatus.DuringFertileWindow, + state: TodayUiState, + onStarted: () -> Unit, +) { + Text("Estimated fertile window", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(16.dp)) + status.daysToOvulation?.let { HeroCount("Estimated ovulation in", it, "days") } + Spacer(Modifier.height(16.dp)) + Quiet("Next period") + Text("About ${status.daysToNextPeriod} days") + Spacer(Modifier.height(16.dp)) + Text( + "Fertility and ovulation dates are estimates based on cycle history and are not " + + "intended to be used as contraception or as a medical diagnosis.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + state.forecast?.let { ForecastDetail(it) } + PrimaryAction("Started period", onStarted) +} + +@Composable +private fun ApproachingState( + status: CycleStatus.PeriodApproaching, + state: TodayUiState, + onStarted: () -> Unit, +) { + CycleRing(state) + Text("Cycle day ${status.cycleDay}", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(16.dp)) + HeroCount(label = "Period likely in", count = status.daysUntil, unit = if (status.daysUntil == 1) "day" else "days") + ForecastDetail(status.forecast) + PrimaryAction(if (status.daysUntil <= 2) "Started early?" else "Started period", onStarted) +} + +@Composable +private fun PredictedDayState( + status: CycleStatus.PredictedDay, + state: TodayUiState, + onStarted: () -> Unit, + onNotYet: () -> Unit, +) { + CycleRing(state) + Spacer(Modifier.height(8.dp)) + Text( + "Your period may start today.", + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center, + ) + ForecastDetail(status.forecast) + PrimaryAction("Started", onStarted) + Spacer(Modifier.height(8.dp)) + OutlinedButton(onClick = onNotYet, modifier = Modifier.fillMaxWidth()) { Text("Not yet") } +} + +/** + * §22's last state, and the one the tone of the whole product rests on. + * + * "Your period is late!" is explicitly ruled out. Late implies a schedule the + * user failed to keep; the truth is that an estimate was imprecise. So the + * screen shows what it originally said, what it says now, and asks — which is + * also the only honest thing to do with a forecast that has moved. + */ +@Composable +private fun BeyondForecastState( + status: CycleStatus.BeyondForecast, + onStarted: () -> Unit, + onNotYet: () -> Unit, +) { + Spacer(Modifier.height(16.dp)) + Text("Not yet?", style = MaterialTheme.typography.headlineMedium) + Spacer(Modifier.height(20.dp)) + Quiet("Original forecast") + Text(status.originalForecast.pretty(), style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(12.dp)) + Quiet("Updated forecast") + Text( + "${status.updated.windowStart.short()} – ${status.updated.windowEnd.short()}", + style = MaterialTheme.typography.titleMedium, + ) + Spacer(Modifier.height(8.dp)) + ConfidenceRow(status.updated.confidenceLabel) + PrimaryAction("Started period", onStarted) + Spacer(Modifier.height(8.dp)) + OutlinedButton(onClick = onNotYet, modifier = Modifier.fillMaxWidth()) { Text("Not yet") } +} + +@Composable +private fun CycleRing(state: TodayUiState) { + CycleProgressMark( + color = MaterialTheme.colorScheme.primary, + accent = PeriodThemeDefaults.cycleColors.periodPredicted, + progress = state.cycleProgress, + size = 72.dp, + ) + Spacer(Modifier.height(16.dp)) +} + +/** + * The banner slot: reserved, and empty until Batch 07. + * + * Reserving it now is the entire point. §48 requires no layout jump when an ad + * loads and a graceful gap when one fails, and both are properties of the space + * existing whether or not anything fills it. Retrofitting the reservation later + * means shipping the jump first and discovering it in a QA round. + * + * §33 decides where it may go: never in onboarding, never over controls, never + * between the steps of a health workflow. Bottom of Today, below everything. + */ +@Composable +private fun BannerSlot() { + Box( + Modifier + .fillMaxWidth() + .height(BANNER_HEIGHT) + .padding(top = 8.dp), + contentAlignment = Alignment.Center, + ) { + // Nothing is drawn. An empty reserved space is correct and invisible; + // a "your ad here" box would be a placeholder for a thing the user is + // paying to remove. + } +} + +/** Standard AdMob banner height, so Batch 07 changes what is inside and not the layout. */ +private val BANNER_HEIGHT = 50.dp + +// --------------------------------------------------------------------------- + +internal fun ConfidenceLabel.readable(): String = when (this) { ConfidenceLabel.LOW -> "Low" ConfidenceLabel.MEDIUM -> "Medium" ConfidenceLabel.HIGH -> "High" } -private val formatter = DateTimeFormatter.ofPattern("d MMM yyyy") -private fun LocalDate.pretty(): String = format(formatter) +private val longFormat = DateTimeFormatter.ofPattern("d MMMM") +private val shortFormat = DateTimeFormatter.ofPattern("d MMM") +private fun LocalDate.pretty(): String = format(longFormat) +private fun LocalDate.short(): String = format(shortFormat) + +// --------------------------------------------------------------------------- +// Previews — every state, both themes. See docs/design/README.md on why both. +// --------------------------------------------------------------------------- + +private fun previewForecast(on: LocalDate, label: ConfidenceLabel = ConfidenceLabel.HIGH) = Prediction( + mostLikelyStartDate = on, + windowStart = on.minusDays(2), + windowEnd = on.plusDays(2), + confidenceScore = 0.7, + confidenceLabel = label, + modelVersion = "preview", +) + +private val previewToday: LocalDate = LocalDate.of(2026, 8, 18) + +private fun previewState( + startsAgo: Long?, + forecastIn: Long?, + ended: Long? = null, + original: LocalDate? = null, +) = TodayUiState( + loading = false, + periods = startsAgo?.let { + listOf( + PeriodRecord( + id = 1, + startDate = previewToday.minusDays(it), + endDate = ended?.let { e -> previewToday.minusDays(e) }, + ), + ) + } ?: emptyList(), + forecast = forecastIn?.let { previewForecast(previewToday.plusDays(it)) }, + today = previewToday, + originalForecastDate = original, +) + +@Preview(name = "Today · empty", showBackground = true) +@Preview(name = "Today · empty dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewEmpty() = PreviewHost(previewState(null, null)) + +@Preview(name = "Today · during period", showBackground = true) +@Composable +private fun PreviewDuring() = PreviewHost(previewState(startsAgo = 2, forecastIn = 27)) + +@Preview(name = "Today · between", showBackground = true) +@Composable +private fun PreviewBetween() = PreviewHost(previewState(startsAgo = 12, forecastIn = 17, ended = 8)) + +@Preview(name = "Today · approaching", showBackground = true) +@Preview(name = "Today · approaching dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewApproaching() = PreviewHost(previewState(startsAgo = 24, forecastIn = 4, ended = 20)) + +@Preview(name = "Today · predicted day", showBackground = true) +@Composable +private fun PreviewPredictedDay() = PreviewHost(previewState(startsAgo = 28, forecastIn = 0, ended = 24)) + +@Preview(name = "Today · beyond forecast", showBackground = true) +@Composable +private fun PreviewBeyond() = PreviewHost( + previewState(startsAgo = 33, forecastIn = 2, ended = 29, original = previewToday.minusDays(3)), +) + +@Composable +private fun PreviewHost(state: TodayUiState) { + PeriodTheme { + TodayContent( + state = state, + message = null, + onStarted = {}, onNotYet = {}, onEndedToday = {}, onStillGoing = {}, onMessageShown = {}, + ) + } +} diff --git a/app/src/main/kotlin/dev/privacyllc/period/feature/today/TodayViewModel.kt b/app/src/main/kotlin/dev/privacyllc/period/feature/today/TodayViewModel.kt index cb885e5..6d35b5d 100644 --- a/app/src/main/kotlin/dev/privacyllc/period/feature/today/TodayViewModel.kt +++ b/app/src/main/kotlin/dev/privacyllc/period/feature/today/TodayViewModel.kt @@ -8,6 +8,8 @@ import dev.privacyllc.period.core.data.PeriodWriteResult import dev.privacyllc.period.core.data.PredictionAccuracy import dev.privacyllc.period.domain.cycle.CycleRecord import dev.privacyllc.period.domain.cycle.PeriodRecord +import dev.privacyllc.period.domain.prediction.CycleStatus +import dev.privacyllc.period.domain.prediction.CycleStatusRules import dev.privacyllc.period.domain.prediction.Prediction import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.flow.MutableStateFlow @@ -35,6 +37,8 @@ data class TodayUiState( val forecast: Prediction? = null, val accuracy: PredictionAccuracy = PredictionAccuracy.Empty, val today: LocalDate = LocalDate.ofEpochDay(0), + /** What the forecast said before any "Not yet" moved it. §22's last state shows both. */ + val originalForecastDate: LocalDate? = null, ) { val daysUntil: Long? get() = forecast?.let { it.mostLikelyStartDate.toEpochDay() - today.toEpochDay() } @@ -43,6 +47,42 @@ data class TodayUiState( get() = periods.maxByOrNull { it.startDate } ?.let { today.toEpochDay() - it.startDate.toEpochDay() + 1 } ?.takeIf { it > 0 } + + /** + * Typical period length, from the records that actually have an end date. + * + * Null below three, for the same reason accuracy figures are: two closed + * periods is not a typical anything, and "typical duration 4 days" from a + * single observation is the app stating a pattern it has not seen. + */ + val typicalPeriodDuration: IntRange? + get() { + val lengths = periods.mapNotNull { p -> + p.endDate?.let { (it.toEpochDay() - p.startDate.toEpochDay()).toInt() + 1 } + }.filter { it in 1..14 } + if (lengths.size < 3) return null + val sorted = lengths.sorted() + return sorted[sorted.size / 4]..sorted[(sorted.size * 3) / 4].coerceAtLeast(sorted[sorted.size / 4]) + } + + val status: CycleStatus + get() = CycleStatusRules.statusFor( + periods = periods, + forecast = forecast, + today = today, + typicalPeriodDuration = typicalPeriodDuration, + originalForecastDate = originalForecastDate, + ) + + /** 0f at the last period start, 1f at the predicted next. Drives the ring on Today. */ + val cycleProgress: Float + get() { + val start = periods.maxByOrNull { it.startDate }?.startDate ?: return 0f + val target = forecast?.mostLikelyStartDate ?: return 0f + val total = (target.toEpochDay() - start.toEpochDay()).toFloat() + if (total <= 0f) return 1f + return ((today.toEpochDay() - start.toEpochDay()) / total).coerceIn(0f, 1f) + } } @HiltViewModel @@ -57,7 +97,8 @@ class TodayViewModel @Inject constructor( repository.cycles, repository.forecast, repository.accuracy, - ) { periods, cycles, forecast, accuracy -> + repository.notYetObservations, + ) { periods, cycles, forecast, accuracy, notYet -> TodayUiState( loading = false, periods = periods.sortedByDescending { it.startDate }, @@ -65,6 +106,11 @@ class TodayViewModel @Inject constructor( forecast = forecast, accuracy = accuracy, today = LocalDate.now(clock), + // The earliest day the user was asked and said "not yet" is the + // day the app originally expected. Keeping it is what lets §22's + // last state show both forecasts instead of quietly moving the + // goalposts. + originalForecastDate = notYet.minOfOrNull { it.date }, ) }.stateIn( scope = viewModelScope, @@ -121,7 +167,11 @@ class TodayViewModel @Inject constructor( } } - fun setEnd(id: Long, end: LocalDate?) = write { repository.setPeriodEnd(id, end) } + /** §24 shows "Updated ✓" on confirmation. A silent write reads as a failed tap. */ + fun setEnd(id: Long, end: LocalDate?) = write { + repository.setPeriodEnd(id, end) + _message.value = if (end == null) "Marked as still going." else "Updated ✓" + } fun delete(id: Long) = write { repository.deletePeriod(id) } diff --git a/docs/design/README.md b/docs/design/README.md index 6d86c9e..e98d392 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -62,6 +62,13 @@ marker — distinguishable in greyscale, because that is also what makes them distinguishable to a colourblind user and to a screenshot in a bug report. Predicted and confirmed days must never look identical. +**The banner space is reserved before there is an ad to put in it.** §48 wants no +layout jump when one loads and a graceful gap when one fails, and both are +properties of the space existing whether or not it is filled. Retrofitting the +reservation in Batch 07 means shipping the jump first and finding it in a QA +round. It is empty and invisible now — deliberately not a "your ad here" box, +which would be a placeholder for the thing a user pays to remove. + **4. Ads never touch a health action.** No banner in onboarding, in the period-start confirmation, in the period-end confirmation, or between steps of a health workflow — and never an interstitial after logging diff --git a/domain/prediction/src/main/kotlin/dev/privacyllc/period/domain/prediction/CycleStatus.kt b/domain/prediction/src/main/kotlin/dev/privacyllc/period/domain/prediction/CycleStatus.kt new file mode 100644 index 0000000..36ed938 --- /dev/null +++ b/domain/prediction/src/main/kotlin/dev/privacyllc/period/domain/prediction/CycleStatus.kt @@ -0,0 +1,165 @@ +package dev.privacyllc.period.domain.prediction + +import dev.privacyllc.period.domain.cycle.PeriodRecord +import java.time.LocalDate + +/** + * Which of PRODUCT_PLAN.md §22's states the user is in right now. + * + * Derived here rather than inside a composable so the boundaries are testable. + * They are the kind of logic that is obviously right until a user is on the + * boundary of two of them — the day a period is predicted, the day after it + * ends — and every one of those days is somebody's actual Tuesday. + */ +sealed interface CycleStatus { + + /** Nothing logged. The empty state, not a forecast of nothing. */ + data object NoData : CycleStatus + + /** A period is on. §22's first state. */ + data class DuringPeriod( + val dayOfPeriod: Int, + val startedOn: LocalDate, + /** + * Set once the user has said when it ended — which can be today. + * + * The distinction matters on screen rather than in the maths: a period + * ending today still includes today, so the state does not change when + * "Period ended" is tapped. Without this field the screen showed exactly + * the same thing afterwards and the tap looked like it had failed. + */ + val endedOn: LocalDate?, + val typicalDurationDays: IntRange?, + ) : CycleStatus + + /** + * The ordinary middle of a cycle. + * + * [daysToFertileWindow] is null until Batch 04 estimates fertility. §22 + * shows the line; showing a placeholder number instead would be inventing a + * fertility estimate, which is the one thing this screen must not do. + */ + data class BetweenPeriodAndFertile( + val cycleDay: Int, + val daysToNextPeriod: Int, + val daysToFertileWindow: Int?, + ) : CycleStatus + + /** Inside the estimated fertile window. Batch 04 makes this reachable. */ + data class DuringFertileWindow( + val cycleDay: Int, + val daysToOvulation: Int?, + val daysToNextPeriod: Int, + ) : CycleStatus + + /** The countdown. §22's hero state, and the one §38 says must dominate. */ + data class PeriodApproaching( + val cycleDay: Int, + val daysUntil: Int, + val forecast: Prediction, + ) : CycleStatus + + /** The day itself. */ + data class PredictedDay(val cycleDay: Int, val forecast: Prediction) : CycleStatus + + /** + * Past the forecast, and **never described as late**. + * + * §22 is explicit: avoid "Your period is late!". Nothing is wrong with the + * user, the model was imprecise, and the copy says so. [originalForecast] is + * kept so the screen can show what it had said before re-conditioning — + * which is the app being accountable rather than quietly moving the goalposts. + */ + data class BeyondForecast( + val cycleDay: Int, + val daysPast: Int, + val originalForecast: LocalDate, + val updated: Prediction, + ) : CycleStatus +} + +object CycleStatusRules { + /** + * How long an unclosed period is assumed to still be running. + * + * Past this, the user simply never tapped "Period Ended" — keeping the app + * on the during-period screen for three weeks would be obviously wrong and + * would hide the next forecast. Not a medical claim; a UI timeout. + */ + const val MAX_ASSUMED_PERIOD_DAYS = 10 + + /** Within this many days, the countdown becomes the screen. */ + const val APPROACHING_DAYS = 7 + + fun statusFor( + periods: List, + forecast: Prediction?, + today: LocalDate, + typicalPeriodDuration: IntRange? = null, + /** From Batch 04. Null means fertility is not estimated yet. */ + fertileWindow: ClosedRange? = null, + ovulation: LocalDate? = null, + /** What the forecast said before any "Not yet" re-conditioned it. */ + originalForecastDate: LocalDate? = null, + ): CycleStatus { + val latest = periods.filter { it.isConfirmed }.maxByOrNull { it.startDate } + ?: return CycleStatus.NoData + + val cycleDay = (today.toEpochDay() - latest.startDate.toEpochDay()).toInt() + 1 + if (cycleDay < 1) return CycleStatus.NoData + + // 1. A period that is on beats everything: it is a fact, and the rest of + // this function is estimates. + val end = latest.endDate + val running = when { + end != null -> !today.isAfter(end) + else -> cycleDay <= MAX_ASSUMED_PERIOD_DAYS + } + if (running && !latest.startDate.isAfter(today)) { + return CycleStatus.DuringPeriod( + dayOfPeriod = cycleDay, + startedOn = latest.startDate, + endedOn = end, + typicalDurationDays = typicalPeriodDuration, + ) + } + + forecast ?: return CycleStatus.BetweenPeriodAndFertile(cycleDay, daysToNextPeriod = 0, null) + + val daysUntil = (forecast.mostLikelyStartDate.toEpochDay() - today.toEpochDay()).toInt() + + return when { + // 2. Past what was forecast. Checked before the predicted day so a + // re-conditioned forecast cannot keep saying "may start today" + // every day for a week. + daysUntil < 0 || (originalForecastDate != null && today > originalForecastDate) -> + CycleStatus.BeyondForecast( + cycleDay = cycleDay, + daysPast = ((originalForecastDate ?: forecast.mostLikelyStartDate).let { + today.toEpochDay() - it.toEpochDay() + }).toInt(), + originalForecast = originalForecastDate ?: forecast.mostLikelyStartDate, + updated = forecast, + ) + + daysUntil == 0 -> CycleStatus.PredictedDay(cycleDay, forecast) + + fertileWindow != null && today in fertileWindow -> + CycleStatus.DuringFertileWindow( + cycleDay = cycleDay, + daysToOvulation = ovulation?.let { (it.toEpochDay() - today.toEpochDay()).toInt() }, + daysToNextPeriod = daysUntil, + ) + + daysUntil <= APPROACHING_DAYS -> CycleStatus.PeriodApproaching(cycleDay, daysUntil, forecast) + + else -> CycleStatus.BetweenPeriodAndFertile( + cycleDay = cycleDay, + daysToNextPeriod = daysUntil, + daysToFertileWindow = fertileWindow?.let { + (it.start.toEpochDay() - today.toEpochDay()).toInt().takeIf { d -> d > 0 } + }, + ) + } + } +} diff --git a/domain/prediction/src/test/kotlin/dev/privacyllc/period/domain/prediction/CycleStatusTest.kt b/domain/prediction/src/test/kotlin/dev/privacyllc/period/domain/prediction/CycleStatusTest.kt new file mode 100644 index 0000000..6561e64 --- /dev/null +++ b/domain/prediction/src/test/kotlin/dev/privacyllc/period/domain/prediction/CycleStatusTest.kt @@ -0,0 +1,164 @@ +package dev.privacyllc.period.domain.prediction + +import dev.privacyllc.period.domain.cycle.PeriodRecord +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.LocalDate + +/** + * The six states of §22, and specifically their boundaries. + * + * Every boundary here is somebody's actual Tuesday: the day a period is + * predicted, the day after one ends, the day a forecast slips. Those are the + * days the screen is most read and the ones a hand-check never covers. + */ +class CycleStatusTest { + + private val today = LocalDate.of(2026, 8, 18) + + private fun period(start: LocalDate, end: LocalDate? = null) = + PeriodRecord(id = 1, startDate = start, endDate = end) + + private fun forecast(on: LocalDate) = Prediction( + mostLikelyStartDate = on, + windowStart = on.minusDays(2), + windowEnd = on.plusDays(2), + confidenceScore = 0.6, + confidenceLabel = ConfidenceLabel.MEDIUM, + modelVersion = "test", + ) + + private fun status( + periods: List, + forecastOn: LocalDate? = null, + at: LocalDate = today, + fertile: ClosedRange? = null, + ovulation: LocalDate? = null, + original: LocalDate? = null, + ) = CycleStatusRules.statusFor( + periods = periods, + forecast = forecastOn?.let(::forecast), + today = at, + fertileWindow = fertile, + ovulation = ovulation, + originalForecastDate = original, + ) + + @Test fun `no history is the empty state rather than a forecast of nothing`() { + assertEquals(CycleStatus.NoData, status(emptyList())) + } + + // ----------------------------------------------------------------------- + // During a period + // ----------------------------------------------------------------------- + + @Test fun `the day a period starts is day one, not day zero`() { + val s = status(listOf(period(today))) as CycleStatus.DuringPeriod + assertEquals(1, s.dayOfPeriod) + } + + @Test fun `an ended period stops being current the day after it ends`() { + val start = today.minusDays(5) + assertTrue(status(listOf(period(start, end = today)), forecastOn = today.plusDays(20)) + is CycleStatus.DuringPeriod) + assertTrue( + "the day after the end is no longer during the period", + status(listOf(period(start, end = today.minusDays(1))), forecastOn = today.plusDays(20)) + !is CycleStatus.DuringPeriod, + ) + } + + @Test fun `an unclosed period does not hold the screen forever`() { + // Somebody who never tapped "Period Ended" three weeks ago is not still + // bleeding; they forgot. Keeping the during-period screen would hide the + // next forecast, which is the thing they opened the app for. + val old = today.minusDays(CycleStatusRules.MAX_ASSUMED_PERIOD_DAYS.toLong()) + assertTrue(status(listOf(period(old)), forecastOn = today.plusDays(18)) !is CycleStatus.DuringPeriod) + } + + // ----------------------------------------------------------------------- + // Approaching, the day, and past it + // ----------------------------------------------------------------------- + + @Test fun `the countdown appears within a week and not before`() { + val periods = listOf(period(today.minusDays(20), end = today.minusDays(16))) + + val soon = status(periods, forecastOn = today.plusDays(CycleStatusRules.APPROACHING_DAYS.toLong())) + assertTrue(soon is CycleStatus.PeriodApproaching) + assertEquals(CycleStatusRules.APPROACHING_DAYS, (soon as CycleStatus.PeriodApproaching).daysUntil) + + val later = status(periods, forecastOn = today.plusDays(CycleStatusRules.APPROACHING_DAYS + 1L)) + assertTrue(later is CycleStatus.BetweenPeriodAndFertile) + } + + @Test fun `the predicted day is its own state`() { + val periods = listOf(period(today.minusDays(28), end = today.minusDays(24))) + assertTrue(status(periods, forecastOn = today) is CycleStatus.PredictedDay) + } + + @Test fun `past the forecast is beyond, never late`() { + val periods = listOf(period(today.minusDays(32), end = today.minusDays(28))) + val s = status(periods, forecastOn = today.plusDays(1), original = today.minusDays(2)) + + // §22: no "your period is late". The state carries what was originally + // said so the screen can be accountable about it. + assertTrue(s is CycleStatus.BeyondForecast) + s as CycleStatus.BeyondForecast + assertEquals(today.minusDays(2), s.originalForecast) + assertEquals(2, s.daysPast) + } + + @Test fun `a re-conditioned forecast does not keep saying may start today`() { + // The engine moves the forecast forward on each "Not yet". Without the + // original date, every one of those days would look like a fresh + // predicted day and the app would sound like it never learns. + val periods = listOf(period(today.minusDays(33), end = today.minusDays(29))) + val s = status(periods, forecastOn = today, original = today.minusDays(3)) + assertTrue(s is CycleStatus.BeyondForecast) + } + + // ----------------------------------------------------------------------- + // Fertility — Batch 04 makes these reachable + // ----------------------------------------------------------------------- + + @Test fun `fertility states do not appear before fertility is estimated`() { + val periods = listOf(period(today.minusDays(10), end = today.minusDays(6))) + val s = status(periods, forecastOn = today.plusDays(18)) + + assertTrue(s is CycleStatus.BetweenPeriodAndFertile) + // Null rather than a number: showing a placeholder here would be + // inventing a fertility estimate, which is the one thing this screen + // must never do. + assertEquals(null, (s as CycleStatus.BetweenPeriodAndFertile).daysToFertileWindow) + } + + @Test fun `the fertile window state appears once fertility is known`() { + val periods = listOf(period(today.minusDays(12), end = today.minusDays(8))) + val s = status( + periods, + forecastOn = today.plusDays(16), + fertile = today.minusDays(1)..today.plusDays(3), + ovulation = today.plusDays(2), + ) + assertTrue(s is CycleStatus.DuringFertileWindow) + assertEquals(2, (s as CycleStatus.DuringFertileWindow).daysToOvulation) + } + + @Test fun `an imminent period outranks the fertile window`() { + // Both can be true at once for a short cycle. The period is what the + // user opened the app to find out. + val periods = listOf(period(today.minusDays(24), end = today.minusDays(20))) + val s = status(periods, forecastOn = today, fertile = today.minusDays(1)..today.plusDays(3)) + assertTrue(s is CycleStatus.PredictedDay) + } + + @Test fun `a period that is on outranks every estimate`() { + val s = status( + listOf(period(today.minusDays(1))), + forecastOn = today, + fertile = today..today.plusDays(3), + ) + assertTrue("a fact beats an estimate", s is CycleStatus.DuringPeriod) + } +}