feat: the Today screen and its six states, with the number as the hero

§21 and §22, each state its own screen rather than a variant of one. Which one
applies is decided by CycleStatusRules in a pure module with twelve tests on its
boundaries — and the boundaries are the point, because they are the days this
screen is most read: the day a period is due, the day after one ends, the day a
forecast slips.

The number dominates (§38): displayLarge at 72sp, in the primary colour, with
the unit as a separate quiet line so "4" reads instantly and "DAYS" is there if
you look. Its content description carries the whole sentence, so TalkBack says
"Period likely in: 4 days" rather than reading a bare numeral.

The state this screen exists to get right is the last one. Past the forecast the
app NEVER says late — late implies a schedule the user failed to keep, and the
truth is that an estimate was imprecise. It shows what it originally said, what
it says now, and asks.

A UX DEFECT FOUND BY DRIVING IT

Tapping "Period ended" changed nothing on screen. The logic was right — a period
that ends today still includes today, so the state does not change — but the
button looked broken, which is worse than being broken somewhere visible.

DuringPeriod now carries the end date, so the screen shows "Ended 18 August" and
offers only the useful action (undo) rather than a button that visibly does
nothing. §24's "Updated ✓" acknowledgement is there too. No test would have
caught this; it needed somebody to tap the button and look.

The banner slot is reserved and empty. §48 wants 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 it is filled — reserving it in Batch 07 instead means shipping
the jump first. Deliberately not a "your ad here" box, which would be a
placeholder for the thing a user pays to remove.

Fertility lines are absent rather than faked: §22 shows them and Batch 04
estimates them, and a placeholder number there would be inventing a fertility
estimate, which is the one thing this screen must not do.

Preview pairs for every state, light and dark. 129 tests, all passing.

closes #17
This commit is contained in:
null 2026-08-18 03:50:28 -05:00
parent 2479a1ddf4
commit afd22dddfa
5 changed files with 827 additions and 165 deletions

View File

@ -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),
) {
item {
Text(
"Batch 01 · working surface — the designed Today screen is Batch 03",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
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,
) {
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") }
}
}
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,
)
}
}
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) },
)
}
if (state.cycles.isNotEmpty()) {
item {
HorizontalDivider()
Text("Cycle lengths", style = MaterialTheme.typography.titleMedium)
Text(
state.cycles.joinToString(" · ") { "${it.cycleLengthDays}d" },
style = MaterialTheme.typography.bodyMedium,
)
}
}
if (state.accuracy.hasEnoughToShow) {
item { AccuracyCard(state) }
}
Spacer(Modifier.weight(1f, fill = false))
BannerSlot()
}
}
// ---------------------------------------------------------------------------
// The hero
// ---------------------------------------------------------------------------
/**
* 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 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)
private fun HeroCount(label: String, count: Int, unit: String) {
Text(
"Log a period and the app will start learning your cycle.",
style = MaterialTheme.typography.bodyMedium,
label.uppercase(),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
return@Column
}
state.cycleDay?.let { Text("Cycle day $it", style = MaterialTheme.typography.labelLarge) }
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()}",
"$count",
style = MaterialTheme.typography.displayLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.clearAndSetSemantics {
contentDescription = "Prediction confidence: ${forecast.confidenceLabel.label()}"
contentDescription = "$label: $count $unit"
},
)
}
}
}
@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"
},
unit.uppercase(),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
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,
onStillGoing: () -> Unit,
onDelete: () -> Unit,
) {
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
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)
}
/**
* 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(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.clearAndSetSemantics {
contentDescription = "Prediction confidence: ${label.readable()}"
},
) {
Column {
Text(record.startDate.pretty(), style = MaterialTheme.typography.titleSmall)
Text("".repeat(filled) + "".repeat(3 - filled), color = MaterialTheme.colorScheme.primary)
Text(label.readable(), style = MaterialTheme.typography.bodyMedium)
}
}
@Composable
private fun Quiet(text: String) = Text(
text,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@Composable
private fun PrimaryAction(text: String, onClick: () -> Unit) {
Spacer(Modifier.height(28.dp))
Button(onClick = onClick, modifier = Modifier.fillMaxWidth()) { Text(text) }
}
// ---------------------------------------------------------------------------
// 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(
record.endDate?.let { "ended ${it.pretty()}" } ?: "still going",
"Log one and the app will start learning your cycle.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
@Composable
private fun DuringPeriod(
status: CycleStatus.DuringPeriod,
onEndedToday: () -> Unit,
onStillGoing: () -> Unit,
) {
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)
}
}
@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(
record.source.name.lowercase(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
"Your period may start today.",
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
)
}
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") }
}
}
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.
}
}
/** "Low", not "LOW" — the user-facing labels in §15 are words, not enum names. */
private fun ConfidenceLabel.label() = when (this) {
/** 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 = {},
)
}
}

View File

@ -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) }

View File

@ -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

View File

@ -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<PeriodRecord>,
forecast: Prediction?,
today: LocalDate,
typicalPeriodDuration: IntRange? = null,
/** From Batch 04. Null means fertility is not estimated yet. */
fertileWindow: ClosedRange<LocalDate>? = 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 }
},
)
}
}
}

View File

@ -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<PeriodRecord>,
forecastOn: LocalDate? = null,
at: LocalDate = today,
fertile: ClosedRange<LocalDate>? = 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)
}
}