From 7910ffd05fa4d7281776e61daf872af0687769a5 Mon Sep 17 00:00:00 2001 From: null Date: Fri, 21 Aug 2026 01:55:44 -0500 Subject: [PATCH] feat: draw the cycle history instead of listing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Insights screen's whole job is showing what the app has learned, and its cycle history was a column of numbers -- the least legible form of the one pattern a user actually wants to see. A bar per cycle now, Compose-drawn with no chart library, scaled to the longest with a floor so a single cycle does not fill the card and read as "long". A cycle outside her usual range is filled differently AND says so in its label, and the range itself is stated in words underneath. The number never leaves. A bar chart is exactly where §43's rule -- that nothing is legible only as a shape or only as a colour -- gets forgotten, because the picture reads so well to whoever built it that the number feels redundant. The bars annotate the numbers; they do not replace them. A screen reader hears one sentence per cycle, and it is the same sentence a sighted user reads beside the bar. With no usual range established, nothing is called unusual: one cycle is not a pattern, and this card must not imply it is. Proved: dropping the "outside your usual range" half of the label reddens exactly one test. closes #59 Co-Authored-By: Claude Opus 5 (1M context) --- .../period/feature/insights/InsightsScreen.kt | 87 ++++++++++++++++++- .../feature/insights/RecentCyclesCardTest.kt | 58 +++++++++++++ 2 files changed, 141 insertions(+), 4 deletions(-) create mode 100644 app/src/test/kotlin/dev/privacyllc/period/feature/insights/RecentCyclesCardTest.kt 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 index a2d9035..85ff9cd 100644 --- a/app/src/main/kotlin/dev/privacyllc/period/feature/insights/InsightsScreen.kt +++ b/app/src/main/kotlin/dev/privacyllc/period/feature/insights/InsightsScreen.kt @@ -12,6 +12,13 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Card +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.draw.clip +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -68,7 +75,7 @@ private fun InsightsContent(state: InsightsUiState) { if (i.recentCycleLengths.isNotEmpty()) { Spacer(Modifier.height(12.dp)) - RecentCyclesCard(i.recentCycleLengths) + RecentCyclesCard(i.recentCycleLengths, i.typicalCycleRange) } Spacer(Modifier.height(12.dp)) @@ -137,18 +144,90 @@ private fun CycleCard(i: CycleInsights) { } @Composable -private fun RecentCyclesCard(lengths: List) { +internal fun RecentCyclesCard(lengths: List, typical: IntRange?) { 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) + + // The longest bar sets the scale, with a floor so a single cycle + // does not fill the card and read as "long". + val longest = maxOf(lengths.maxOrNull() ?: 0, typical?.last ?: 0, 1) + + lengths.forEach { days -> + CycleBar( + days = days, + fraction = days.toFloat() / longest, + // Inside her usual range, or not. Said in the label as well + // as drawn, because §43 forbids meaning carried by colour. + unusual = typical != null && days !in typical, + ) + Spacer(Modifier.height(6.dp)) + } + + typical?.let { + Spacer(Modifier.height(4.dp)) + Text( + "Your usual range is ${it.first}–${it.last} days.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } } } +/** + * One cycle, as a bar and as a number. + * + * The number is not decoration and never leaves: §43's rule is that nothing on + * this screen may be legible only as a shape or only as a colour, and a bar + * chart is exactly where that gets forgotten. A screen reader hears one sentence + * per cycle; a sighted user sees the shape and the same sentence. + * + * Drawn with a Box rather than a chart library, because this is a row of + * rectangles and a dependency would be a permanent cost for a week's convenience. + */ +@Composable +private fun CycleBar(days: Int, fraction: Float, unusual: Boolean) { + val label = if (unusual) "$days days — outside your usual range" else "$days days" + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clearAndSetSemantics { contentDescription = label }, + ) { + Box( + Modifier + .weight(1f) + .height(18.dp) + .clip(RoundedCornerShape(4.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant), + ) { + Box( + Modifier + .fillMaxWidth(fraction.coerceIn(0.08f, 1f)) + .height(18.dp) + .clip(RoundedCornerShape(4.dp)) + .background( + if (unusual) { + MaterialTheme.colorScheme.tertiary + } else { + MaterialTheme.colorScheme.primary + }, + ), + ) + } + Spacer(Modifier.width(12.dp)) + Text( + label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } +} + /** * §16's accuracy figures — the ones the document calls a powerful trust feature. * diff --git a/app/src/test/kotlin/dev/privacyllc/period/feature/insights/RecentCyclesCardTest.kt b/app/src/test/kotlin/dev/privacyllc/period/feature/insights/RecentCyclesCardTest.kt new file mode 100644 index 0000000..1222194 --- /dev/null +++ b/app/src/test/kotlin/dev/privacyllc/period/feature/insights/RecentCyclesCardTest.kt @@ -0,0 +1,58 @@ +package dev.privacyllc.period.feature.insights + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import dev.privacyllc.period.designsystem.PeriodTheme +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * The cycle history, drawn. + * + * A bar chart is exactly where "nothing is legible only as a shape or only as a + * colour" (§43) gets forgotten — the picture reads so well to the person who + * built it that the number feels redundant. It is not: the bars annotate the + * numbers here, never replace them, and a cycle outside her usual range says so + * in words as well as in a different fill. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class RecentCyclesCardTest { + + @get:Rule val rule = createAndroidComposeRule() + + @Test fun `every cycle keeps its number`() { + rule.setContent { PeriodTheme { RecentCyclesCard(listOf(28, 30, 27), typical = 27..30) } } + + listOf(28, 30, 27).forEach { + rule.onNodeWithContentDescription("$it days").assertIsDisplayed() + } + } + + @Test fun `a cycle outside her usual range says so, not just looks different`() { + rule.setContent { PeriodTheme { RecentCyclesCard(listOf(29, 45), typical = 27..31) } } + + rule.onNodeWithContentDescription("45 days — outside your usual range").assertIsDisplayed() + // And the ordinary one is not labelled as unusual. + rule.onNodeWithContentDescription("29 days").assertIsDisplayed() + } + + @Test fun `the usual range is stated rather than only shaded`() { + rule.setContent { PeriodTheme { RecentCyclesCard(listOf(28), typical = 27..30) } } + + rule.onNodeWithText("Your usual range is 27–30 days.").assertIsDisplayed() + } + + @Test fun `with no usual range yet, nothing is called unusual`() { + // One cycle is not a pattern, so nothing on this card may imply it is. + rule.setContent { PeriodTheme { RecentCyclesCard(listOf(28), typical = null) } } + + rule.onNodeWithContentDescription("28 days").assertIsDisplayed() + } +}