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:
parent
f91ba6ebe6
commit
7910ffd05f
|
|
@ -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<Int>) {
|
||||
internal fun RecentCyclesCard(lengths: List<Int>, 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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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 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()
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue