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:
parent
2479a1ddf4
commit
afd22dddfa
|
|
@ -1,16 +1,20 @@
|
||||||
package dev.privacyllc.period.feature.today
|
package dev.privacyllc.period.feature.today
|
||||||
|
|
||||||
|
import android.content.res.Configuration
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
|
@ -21,215 +25,487 @@ import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
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.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
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.cycle.PeriodRecord
|
||||||
import dev.privacyllc.period.domain.prediction.ConfidenceLabel
|
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.LocalDate
|
||||||
import java.time.format.DateTimeFormatter
|
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
|
* **The forecast number dominates** (§38). Nothing on this screen competes with
|
||||||
* states, restrained motion. That is Batch 03. This exists to prove the loop in
|
* it, including anything of ours: the ad slot is at the very bottom, the history
|
||||||
* §58 end to end — enter starts, store them, recalculate, see it change — and
|
* is not here at all, and the secondary lines are deliberately quiet.
|
||||||
* 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.
|
* 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
|
@Composable
|
||||||
fun TodayScreen(viewModel: TodayViewModel = hiltViewModel()) {
|
fun TodayScreen(viewModel: TodayViewModel = hiltViewModel()) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val message by viewModel.message.collectAsStateWithLifecycle()
|
val message by viewModel.message.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
LazyColumn(
|
TodayContent(
|
||||||
modifier = Modifier.fillMaxSize().padding(16.dp),
|
state = state,
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
message = message,
|
||||||
) {
|
onStarted = { viewModel.confirmStart(state.today) },
|
||||||
item {
|
onNotYet = viewModel::notYet,
|
||||||
Text(
|
onEndedToday = {
|
||||||
"Batch 01 · working surface — the designed Today screen is Batch 03",
|
state.periods.maxByOrNull { it.startDate }?.let { viewModel.setEnd(it.id, state.today) }
|
||||||
style = MaterialTheme.typography.labelSmall,
|
},
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
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 {
|
||||||
|
Spacer(Modifier.height(20.dp))
|
||||||
message?.let { text ->
|
|
||||||
item {
|
|
||||||
Card(Modifier.fillMaxWidth()) {
|
Card(Modifier.fillMaxWidth()) {
|
||||||
Row(
|
Row(
|
||||||
Modifier.fillMaxWidth().padding(12.dp),
|
Modifier.fillMaxWidth().padding(12.dp),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text(text, style = MaterialTheme.typography.bodyMedium)
|
Text(it, style = MaterialTheme.typography.bodyMedium)
|
||||||
TextButton(onClick = viewModel::messageShown) { Text("OK") }
|
TextButton(onClick = onMessageShown) { Text("OK") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
item {
|
Spacer(Modifier.weight(1f, fill = false))
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
BannerSlot()
|
||||||
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) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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
|
@Composable
|
||||||
private fun ForecastCard(state: TodayUiState, viewModel: TodayViewModel) {
|
private fun HeroCount(label: String, count: Int, unit: String) {
|
||||||
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(
|
Text(
|
||||||
"Log a period and the app will start learning your cycle.",
|
label.uppercase(),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
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(
|
Text(
|
||||||
when {
|
"$count",
|
||||||
days == null -> "Period expected"
|
style = MaterialTheme.typography.displayLarge,
|
||||||
days > 1L -> "Period likely in $days days"
|
color = MaterialTheme.colorScheme.primary,
|
||||||
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 {
|
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(
|
Text(
|
||||||
when {
|
unit.uppercase(),
|
||||||
it == 0 -> "Last prediction: exact"
|
style = MaterialTheme.typography.labelLarge,
|
||||||
it < 0 -> "Last prediction: ${-it} day(s) early"
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
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
|
@Composable
|
||||||
private fun PeriodRow(
|
private fun ForecastDetail(forecast: Prediction) {
|
||||||
record: PeriodRecord,
|
Spacer(Modifier.height(20.dp))
|
||||||
onShiftStart: (Long) -> Unit,
|
Quiet("Most likely")
|
||||||
onEndToday: () -> Unit,
|
Text(forecast.mostLikelyStartDate.pretty(), style = MaterialTheme.typography.titleMedium)
|
||||||
onStillGoing: () -> Unit,
|
Spacer(Modifier.height(8.dp))
|
||||||
onDelete: () -> Unit,
|
Quiet("Expected")
|
||||||
) {
|
Text("${forecast.windowStart.short()} – ${forecast.windowEnd.short()}")
|
||||||
Card(Modifier.fillMaxWidth()) {
|
Spacer(Modifier.height(8.dp))
|
||||||
Column(Modifier.padding(12.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(
|
Row(
|
||||||
Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
modifier = Modifier.clearAndSetSemantics {
|
||||||
|
contentDescription = "Prediction confidence: ${label.readable()}"
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
Column {
|
Text("●".repeat(filled) + "○".repeat(3 - filled), color = MaterialTheme.colorScheme.primary)
|
||||||
Text(record.startDate.pretty(), style = MaterialTheme.typography.titleSmall)
|
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(
|
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,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
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(
|
Text(
|
||||||
record.source.name.lowercase(),
|
"Your period may start today.",
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
}
|
ForecastDetail(status.forecast)
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
PrimaryAction("Started", onStarted)
|
||||||
TextButton(onClick = { onShiftStart(-1) }) { Text("−1d") }
|
Spacer(Modifier.height(8.dp))
|
||||||
TextButton(onClick = { onShiftStart(1) }) { Text("+1d") }
|
OutlinedButton(onClick = onNotYet, modifier = Modifier.fillMaxWidth()) { Text("Not yet") }
|
||||||
if (record.endDate == null) {
|
}
|
||||||
TextButton(onClick = onEndToday) { Text("Ended") }
|
|
||||||
} else {
|
/**
|
||||||
TextButton(onClick = onStillGoing) { Text("Still going") }
|
* §22's last state, and the one the tone of the whole product rests on.
|
||||||
}
|
*
|
||||||
TextButton(onClick = onDelete) { Text("Delete") }
|
* "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. */
|
/** Standard AdMob banner height, so Batch 07 changes what is inside and not the layout. */
|
||||||
private fun ConfidenceLabel.label() = when (this) {
|
private val BANNER_HEIGHT = 50.dp
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
internal fun ConfidenceLabel.readable(): String = when (this) {
|
||||||
ConfidenceLabel.LOW -> "Low"
|
ConfidenceLabel.LOW -> "Low"
|
||||||
ConfidenceLabel.MEDIUM -> "Medium"
|
ConfidenceLabel.MEDIUM -> "Medium"
|
||||||
ConfidenceLabel.HIGH -> "High"
|
ConfidenceLabel.HIGH -> "High"
|
||||||
}
|
}
|
||||||
|
|
||||||
private val formatter = DateTimeFormatter.ofPattern("d MMM yyyy")
|
private val longFormat = DateTimeFormatter.ofPattern("d MMMM")
|
||||||
private fun LocalDate.pretty(): String = format(formatter)
|
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 = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ import dev.privacyllc.period.core.data.PeriodWriteResult
|
||||||
import dev.privacyllc.period.core.data.PredictionAccuracy
|
import dev.privacyllc.period.core.data.PredictionAccuracy
|
||||||
import dev.privacyllc.period.domain.cycle.CycleRecord
|
import dev.privacyllc.period.domain.cycle.CycleRecord
|
||||||
import dev.privacyllc.period.domain.cycle.PeriodRecord
|
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 dev.privacyllc.period.domain.prediction.Prediction
|
||||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
|
@ -35,6 +37,8 @@ data class TodayUiState(
|
||||||
val forecast: Prediction? = null,
|
val forecast: Prediction? = null,
|
||||||
val accuracy: PredictionAccuracy = PredictionAccuracy.Empty,
|
val accuracy: PredictionAccuracy = PredictionAccuracy.Empty,
|
||||||
val today: LocalDate = LocalDate.ofEpochDay(0),
|
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?
|
val daysUntil: Long?
|
||||||
get() = forecast?.let { it.mostLikelyStartDate.toEpochDay() - today.toEpochDay() }
|
get() = forecast?.let { it.mostLikelyStartDate.toEpochDay() - today.toEpochDay() }
|
||||||
|
|
@ -43,6 +47,42 @@ data class TodayUiState(
|
||||||
get() = periods.maxByOrNull { it.startDate }
|
get() = periods.maxByOrNull { it.startDate }
|
||||||
?.let { today.toEpochDay() - it.startDate.toEpochDay() + 1 }
|
?.let { today.toEpochDay() - it.startDate.toEpochDay() + 1 }
|
||||||
?.takeIf { it > 0 }
|
?.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
|
@HiltViewModel
|
||||||
|
|
@ -57,7 +97,8 @@ class TodayViewModel @Inject constructor(
|
||||||
repository.cycles,
|
repository.cycles,
|
||||||
repository.forecast,
|
repository.forecast,
|
||||||
repository.accuracy,
|
repository.accuracy,
|
||||||
) { periods, cycles, forecast, accuracy ->
|
repository.notYetObservations,
|
||||||
|
) { periods, cycles, forecast, accuracy, notYet ->
|
||||||
TodayUiState(
|
TodayUiState(
|
||||||
loading = false,
|
loading = false,
|
||||||
periods = periods.sortedByDescending { it.startDate },
|
periods = periods.sortedByDescending { it.startDate },
|
||||||
|
|
@ -65,6 +106,11 @@ class TodayViewModel @Inject constructor(
|
||||||
forecast = forecast,
|
forecast = forecast,
|
||||||
accuracy = accuracy,
|
accuracy = accuracy,
|
||||||
today = LocalDate.now(clock),
|
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(
|
}.stateIn(
|
||||||
scope = viewModelScope,
|
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) }
|
fun delete(id: Long) = write { repository.deletePeriod(id) }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
distinguishable to a colourblind user and to a screenshot in a bug report.
|
||||||
Predicted and confirmed days must never look identical.
|
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
|
**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
|
period-start confirmation, in the period-end confirmation, or between steps of a
|
||||||
health workflow — and never an interstitial after logging
|
health workflow — and never an interstitial after logging
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue