feat: draw the cycle history instead of listing it

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) <noreply@anthropic.com>
This commit is contained in:
null 2026-08-21 01:55:44 -05:00
parent f91ba6ebe6
commit 7910ffd05f
2 changed files with 141 additions and 4 deletions

View File

@ -12,6 +12,13 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Card 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.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -68,7 +75,7 @@ private fun InsightsContent(state: InsightsUiState) {
if (i.recentCycleLengths.isNotEmpty()) { if (i.recentCycleLengths.isNotEmpty()) {
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
RecentCyclesCard(i.recentCycleLengths) RecentCyclesCard(i.recentCycleLengths, i.typicalCycleRange)
} }
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
@ -137,18 +144,90 @@ private fun CycleCard(i: CycleInsights) {
} }
@Composable @Composable
private fun RecentCyclesCard(lengths: List<Int>) { internal fun RecentCyclesCard(lengths: List<Int>, typical: IntRange?) {
Card(Modifier.fillMaxWidth()) { Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp)) { Column(Modifier.padding(16.dp)) {
Text("Recent cycles", style = MaterialTheme.typography.titleMedium) Text("Recent cycles", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(8.dp)) 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. * §16's accuracy figures the ones the document calls a powerful trust feature.
* *

View File

@ -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<ComponentActivity>()
@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 2730 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()
}
}