diff --git a/app/src/main/kotlin/dev/privacyllc/period/feature/insights/InsightsScreen.kt b/app/src/main/kotlin/dev/privacyllc/period/feature/insights/InsightsScreen.kt new file mode 100644 index 0000000..6d900f7 --- /dev/null +++ b/app/src/main/kotlin/dev/privacyllc/period/feature/insights/InsightsScreen.kt @@ -0,0 +1,236 @@ +package dev.privacyllc.period.feature.insights + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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.core.data.PredictionAccuracy +import dev.privacyllc.period.designsystem.PeriodTheme +import dev.privacyllc.period.designsystem.art.LearningIllustration +import dev.privacyllc.period.domain.cycle.CycleInsights +import dev.privacyllc.period.domain.cycle.LearningStage +import dev.privacyllc.period.domain.cycle.PeriodRecord +import java.time.LocalDate + +/** + * §27. Its purpose is one sentence: **show the user what the app has learned.** + * + * Which means the screen has to be honest about how little that sometimes is. + * Every figure here is absent rather than approximated when there is not enough + * history — §27 ends with *do not overstate accuracy*, and the easiest way to + * overstate is to average two numbers and print a decimal place. + * + * Nothing on this screen leaves the device. §46 names `prediction_error=` as a + * value that must never become an analytics event, and this is exactly the + * screen that would tempt somebody to send one. + */ +@Composable +fun InsightsScreen(viewModel: InsightsViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsStateWithLifecycle() + InsightsContent(state) +} + +@Composable +private fun InsightsContent(state: InsightsUiState) { + val i = state.insights + + Column( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + ) { + LearningBanner(i.stage, i.cycleCount) + + if (i.stage != LearningStage.NO_DATA) { + Spacer(Modifier.height(16.dp)) + CycleCard(i) + } + + if (i.recentCycleLengths.isNotEmpty()) { + Spacer(Modifier.height(12.dp)) + RecentCyclesCard(i.recentCycleLengths) + } + + Spacer(Modifier.height(12.dp)) + AccuracyCard(state.accuracy) + } +} + +/** + * §27's learning copy, chosen by what the data supports rather than by mood. + * + * "Personalized to your cycle" is a claim. It appears when there are enough + * confirmed cycles for the forecast to genuinely be hers rather than a default, + * and not before. + */ +@Composable +private fun LearningBanner(stage: LearningStage, cycleCount: Int) { + val (title, body) = when (stage) { + LearningStage.NO_DATA -> + "Nothing learned yet" to "Log a period and this screen will start filling in." + LearningStage.LEARNING -> + "Learning your cycle" to "One cycle recorded. A few more and the predictions get noticeably better." + LearningStage.GETTING_TO_KNOW -> + "Getting to know your pattern" to "$cycleCount cycles recorded. The forecast is starting to be yours." + LearningStage.PERSONALIZED -> + "Personalized to your cycle" to "$cycleCount cycles recorded. Predictions are based on your own history." + } + + Column( + Modifier.fillMaxWidth().padding(vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (stage != LearningStage.PERSONALIZED) { + LearningIllustration( + color = MaterialTheme.colorScheme.primary, + accent = MaterialTheme.colorScheme.tertiary, + size = 110.dp, + ) + Spacer(Modifier.height(12.dp)) + } + Text(title, style = MaterialTheme.typography.headlineSmall, textAlign = TextAlign.Center) + Spacer(Modifier.height(6.dp)) + Text( + body, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } +} + +@Composable +private fun CycleCard(i: CycleInsights) { + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp)) { + Text("Your cycle", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(12.dp)) + // Absent rather than approximated. A dash is honest; a number + // computed from one observation is not. + Stat("Average cycle", i.averageCycleDays?.let { "%.1f days".format(it) }) + Stat("Typical range", i.typicalCycleRange?.let { "${it.first}–${it.last} days" }) + Stat("Average period", i.averagePeriodDays?.let { "%.1f days".format(it) }) + } + } +} + +@Composable +private fun RecentCyclesCard(lengths: List) { + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp)) { + Text("Recent cycles", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(8.dp)) + lengths.forEach { + Text("$it days", style = MaterialTheme.typography.bodyLarge) + } + } + } +} + +/** + * §16's accuracy figures — the ones the document calls a powerful trust feature. + * + * They are also the easiest thing on this screen to lie with, which is why + * `PredictionAccuracy` refuses to report below three scored forecasts and this + * card says so plainly instead of showing a mean of one. + */ +@Composable +private fun AccuracyCard(a: PredictionAccuracy) { + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp)) { + Text("Prediction accuracy", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(8.dp)) + + if (!a.hasEnoughToShow) { + Text( + if (a.scoredCount == 0) { + "Once a predicted period arrives, the app will start scoring itself here." + } else { + "${a.scoredCount} prediction(s) scored so far. Accuracy figures appear " + + "after ${PredictionAccuracy.MINIMUM_TO_SHOW}, so they mean something." + }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + return@Column + } + + a.lastSignedErrorDays?.let { + Stat( + "Last prediction", + when { + it == 0 -> "exact" + it < 0 -> "${-it} day(s) early" + else -> "$it day(s) late" + }, + ) + } + Stat("Average error", a.meanAbsoluteErrorDays?.let { "%.1f days".format(it) }) + Stat("Within one day", "${a.withinOneDay} of your last ${a.scoredCount}") + Stat("Within two days", "${a.withinTwoDays} of your last ${a.scoredCount}") + } + } +} + +@Composable +private fun Stat(label: String, value: String?) { + Row( + Modifier.fillMaxWidth().padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(value ?: "—", style = MaterialTheme.typography.bodyLarge) + } +} + +// --------------------------------------------------------------------------- + +private fun previewPeriods(vararg gaps: Long): List { + var cursor = LocalDate.of(2026, 3, 1) + val out = mutableListOf(cursor) + gaps.forEach { cursor = cursor.plusDays(it); out += cursor } + return out.mapIndexed { i, d -> PeriodRecord(i.toLong(), d, d.plusDays(4)) } +} + +@Preview(name = "Insights · learning", showBackground = true, heightDp = 800) +@Composable +private fun PreviewLearning() = PeriodTheme { + InsightsContent(InsightsUiState(insights = CycleInsights.from(previewPeriods(29)))) +} + +@Preview(name = "Insights · personalized", showBackground = true, heightDp = 900) +@Preview( + name = "Insights · personalized dark", + showBackground = true, + heightDp = 900, + uiMode = Configuration.UI_MODE_NIGHT_YES, +) +@Composable +private fun PreviewPersonalized() = PeriodTheme { + InsightsContent( + InsightsUiState( + insights = CycleInsights.from(previewPeriods(29, 28, 30, 29, 28, 30)), + accuracy = PredictionAccuracy.from(listOf(-1, 1, 0, 2, -1, 1)), + ), + ) +} diff --git a/app/src/main/kotlin/dev/privacyllc/period/feature/insights/InsightsViewModel.kt b/app/src/main/kotlin/dev/privacyllc/period/feature/insights/InsightsViewModel.kt new file mode 100644 index 0000000..f8429f5 --- /dev/null +++ b/app/src/main/kotlin/dev/privacyllc/period/feature/insights/InsightsViewModel.kt @@ -0,0 +1,36 @@ +package dev.privacyllc.period.feature.insights + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dev.privacyllc.period.core.data.CycleRepository +import dev.privacyllc.period.core.data.PredictionAccuracy +import dev.privacyllc.period.domain.cycle.CycleInsights +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject + +data class InsightsUiState( + val insights: CycleInsights = CycleInsights.from(emptyList()), + val accuracy: PredictionAccuracy = PredictionAccuracy.Empty, +) + +/** + * §27, and everything on it is computed here on the device. + * + * §46 lists `prediction_error=` among the values that must never leave as an + * analytics event, and this is the screen that would tempt somebody to send one. + * There is no network call in this file and there must never be. + */ +@HiltViewModel +class InsightsViewModel @Inject constructor( + repository: CycleRepository, +) : ViewModel() { + + val state: StateFlow = + combine(repository.confirmedPeriods, repository.accuracy) { periods, accuracy -> + InsightsUiState(insights = CycleInsights.from(periods), accuracy = accuracy) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), InsightsUiState()) +} diff --git a/app/src/main/kotlin/dev/privacyllc/period/navigation/PeriodApp.kt b/app/src/main/kotlin/dev/privacyllc/period/navigation/PeriodApp.kt index ce08876..b9257bf 100644 --- a/app/src/main/kotlin/dev/privacyllc/period/navigation/PeriodApp.kt +++ b/app/src/main/kotlin/dev/privacyllc/period/navigation/PeriodApp.kt @@ -36,6 +36,7 @@ import androidx.navigation.compose.rememberNavController import dev.privacyllc.period.R import dev.privacyllc.period.designsystem.PeriodTheme import dev.privacyllc.period.feature.calendar.CalendarScreen +import dev.privacyllc.period.feature.insights.InsightsScreen import dev.privacyllc.period.feature.onboarding.OnboardingScreen import dev.privacyllc.period.feature.today.TodayScreen @@ -109,11 +110,12 @@ fun PeriodApp() { ) { composable(PeriodDestination.TODAY.route) { TodayScreen() } composable(PeriodDestination.CALENDAR.route) { CalendarScreen() } + composable(PeriodDestination.INSIGHTS.route) { InsightsScreen() } - // Insights and Settings arrive in Batches 03 and 06. They say - // "not built yet" rather than showing a convincing mock. + // Settings arrives in Batch 06. It says "not built yet" rather than + // showing a convincing mock. PeriodDestination.entries - .filter { it != PeriodDestination.TODAY && it != PeriodDestination.CALENDAR } + .filter { it == PeriodDestination.SETTINGS } .forEach { destination -> composable(destination.route) { PlaceholderScreen(stringResource(destination.labelRes)) diff --git a/domain/cycle/src/main/kotlin/dev/privacyllc/period/domain/cycle/CycleInsights.kt b/domain/cycle/src/main/kotlin/dev/privacyllc/period/domain/cycle/CycleInsights.kt new file mode 100644 index 0000000..3c9746d --- /dev/null +++ b/domain/cycle/src/main/kotlin/dev/privacyllc/period/domain/cycle/CycleInsights.kt @@ -0,0 +1,101 @@ +package dev.privacyllc.period.domain.cycle + +import kotlin.math.roundToInt + +/** + * How much the app can honestly claim to know. + * + * PRODUCT_PLAN.md §27's copy — "Learning your cycle", "Getting to know your + * pattern", "Personalized to your cycle" — is a claim about the model, so which + * one is shown has to be a fact about the data rather than a mood. + */ +enum class LearningStage { + /** Nothing to learn from yet. */ + NO_DATA, + + /** One or two intervals. A forecast exists; a *pattern* does not. */ + LEARNING, + + /** Enough to see a shape, not enough to be sure of it. */ + GETTING_TO_KNOW, + + /** Enough history that the forecast is genuinely hers rather than a default. */ + PERSONALIZED, +} + +/** + * What Insights shows — §27, whose whole purpose is one sentence: **show the + * user what the app has learned.** + * + * Every field is nullable and every one means "not enough data to say" when it + * is null. That is deliberate: §27 ends with *do not overstate accuracy*, and + * the easiest way to overstate is to compute a mean over two numbers and print + * it with a decimal place. + */ +data class CycleInsights( + val cycleCount: Int, + val averageCycleDays: Double?, + val typicalCycleRange: IntRange?, + val averagePeriodDays: Double?, + /** Newest first, for the "recent cycles" list. */ + val recentCycleLengths: List, + val stage: LearningStage, +) { + companion object { + /** Below this there is no average worth printing — one interval is an anecdote. */ + const val MINIMUM_FOR_AVERAGE = 2 + + /** Below this, §27's copy says "getting to know" rather than "personalized". */ + const val PERSONALIZED_FROM = 5 + + const val RECENT_COUNT = 6 + + fun from(periods: List): CycleInsights { + val cycles = periods.toCycles() + val lengths = cycles.map { it.cycleLengthDays }.filter { it in 10..90 } + + val durations = periods.mapNotNull { p -> + p.endDate?.let { (it.toEpochDay() - p.startDate.toEpochDay()).toInt() + 1 } + }.filter { it in 1..14 } + + val stage = when { + lengths.isEmpty() -> LearningStage.NO_DATA + lengths.size < MINIMUM_FOR_AVERAGE -> LearningStage.LEARNING + lengths.size < PERSONALIZED_FROM -> LearningStage.GETTING_TO_KNOW + else -> LearningStage.PERSONALIZED + } + + val enough = lengths.size >= MINIMUM_FOR_AVERAGE + + return CycleInsights( + cycleCount = lengths.size, + averageCycleDays = if (enough) lengths.average() else null, + // The middle of the distribution rather than min..max: one long + // cycle should not be reported as this user's "typical range", + // which would make the app describe her as more erratic than she + // is on the screen whose job is to say what it has learned. + typicalCycleRange = if (enough) typicalRange(lengths) else null, + averagePeriodDays = if (durations.size >= MINIMUM_FOR_AVERAGE) durations.average() else null, + recentCycleLengths = lengths.reversed().take(RECENT_COUNT), + stage = stage, + ) + } + + /** + * The interquartile span, indexed off `size - 1` rather than `size`. + * + * Using `size` puts the upper index one place too high, which on an + * even-length list lands on the largest value — so a history of + * 29, 28, 30, 29, 61, 29 reported a "typical range" of 29–61 and + * described a regular cycle as wildly erratic. Caught by the test that + * says one long cycle must not become the typical range. + */ + private fun typicalRange(lengths: List): IntRange { + val sorted = lengths.sorted() + val last = sorted.lastIndex + val low = sorted[(last * 0.25).roundToInt().coerceIn(0, last)] + val high = sorted[(last * 0.75).roundToInt().coerceIn(0, last)] + return low..maxOf(low, high) + } + } +} diff --git a/domain/cycle/src/test/kotlin/dev/privacyllc/period/domain/cycle/CycleInsightsTest.kt b/domain/cycle/src/test/kotlin/dev/privacyllc/period/domain/cycle/CycleInsightsTest.kt new file mode 100644 index 0000000..4c0e082 --- /dev/null +++ b/domain/cycle/src/test/kotlin/dev/privacyllc/period/domain/cycle/CycleInsightsTest.kt @@ -0,0 +1,87 @@ +package dev.privacyllc.period.domain.cycle + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.LocalDate + +/** + * §27's rule, which is easier to state than to keep: **do not overstate what the + * app has learned.** Most of these tests are about staying quiet. + */ +class CycleInsightsTest { + + private fun records(vararg gaps: Long, duration: Long? = null): List { + var cursor = LocalDate.of(2026, 1, 1) + val out = mutableListOf(cursor) + gaps.forEach { cursor = cursor.plusDays(it); out += cursor } + return out.mapIndexed { i, d -> + PeriodRecord(id = i.toLong(), startDate = d, endDate = duration?.let { d.plusDays(it - 1) }) + } + } + + @Test fun `no history says so rather than showing zeroes`() { + val i = CycleInsights.from(emptyList()) + assertEquals(LearningStage.NO_DATA, i.stage) + assertNull(i.averageCycleDays) + assertNull(i.typicalCycleRange) + assertTrue(i.recentCycleLengths.isEmpty()) + } + + @Test fun `one interval is an anecdote, not an average`() { + val i = CycleInsights.from(records(29)) + assertEquals(LearningStage.LEARNING, i.stage) + assertEquals(1, i.cycleCount) + // "Average cycle: 29.0 days" from a single observation is the app + // claiming a pattern it has seen once. + assertNull(i.averageCycleDays) + assertNull(i.typicalCycleRange) + } + + @Test fun `averages appear from two intervals`() { + val i = CycleInsights.from(records(28, 30)) + assertEquals(29.0, i.averageCycleDays!!, 0.001) + assertEquals(LearningStage.GETTING_TO_KNOW, i.stage) + } + + @Test fun `the learning stage tracks the amount of history`() { + assertEquals(LearningStage.GETTING_TO_KNOW, CycleInsights.from(records(29, 28, 30)).stage) + assertEquals( + LearningStage.PERSONALIZED, + CycleInsights.from(records(29, 28, 30, 29, 28)).stage, + ) + } + + @Test fun `recent cycles are newest first and capped`() { + val i = CycleInsights.from(records(21, 22, 23, 24, 25, 26, 27, 28)) + assertEquals(CycleInsights.RECENT_COUNT, i.recentCycleLengths.size) + assertEquals(28, i.recentCycleLengths.first()) + assertEquals(23, i.recentCycleLengths.last()) + } + + @Test fun `one long cycle does not become the typical range`() { + // Reporting 28..61 as "typical" would describe her as far more erratic + // than she is, on the screen whose entire job is to say what has been + // learned about her. + val i = CycleInsights.from(records(29, 28, 30, 29, 61, 29)) + val range = i.typicalCycleRange!! + assertTrue("range was $range", range.last <= 32) + } + + @Test fun `period duration needs more than one closed period`() { + assertNull(CycleInsights.from(records(29, duration = null)).averagePeriodDays) + assertNull(CycleInsights.from(records(29)).averagePeriodDays) + + val i = CycleInsights.from(records(29, 28, duration = 5)) + assertEquals(5.0, i.averagePeriodDays!!, 0.001) + } + + @Test fun `implausible intervals are excluded from what is reported`() { + // A 200-day gap is a user returning after a year, not a cycle, and + // averaging it in would make every figure on the screen wrong. + val i = CycleInsights.from(records(29, 28, 200, 29)) + assertTrue("200 must not appear", i.recentCycleLengths.none { it > 90 }) + assertTrue(i.averageCycleDays!! < 40) + } +}