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.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.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -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