S-12: line chart of a category or account over the range, own period and granularity, average overlays (D-128)

A drill-down opened from a breakdown row now shows its own period chip and
Month/Week toggle (initialised from S-11, S-11 keeps its own filter), the
header total, a line chart of the group's spend per bucket with the dashed
range average, the trailing 3-bucket moving average and a legend, and a
caption comparing the latest full bucket with the average; the expense rows
follow. Bucket drill-downs are unchanged. The series is the group's column of
AnalyticsState.buckets from spendSeries, so no contract change.

:core:design chart: SpendLineChart and LineLegend beside StackedBarChart,
shared axis/slot helpers (bar chart pixels unchanged), AnalyticsFilterBar
measure and group-by toggles optional.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
2026-10-21 20:27:34 +02:00
parent b0a90da630
commit 12e2508934
39 changed files with 964 additions and 122 deletions
@@ -35,6 +35,7 @@ import androidx.compose.ui.geometry.RoundRect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
@@ -95,6 +96,112 @@ fun niceStep(maxKronor: Long): Long {
return (factor * magnitude).toLong().coerceAtLeast(1)
}
/** The y scale of a chart: [step] kronor between gridlines and [lines] gridlines above the 0 baseline (0 when the range is empty, D-068). */
internal class AxisScale(val step: Long, val lines: Long) {
/** The plot's top in öre. */
val topOre: Long get() = (step * lines).coerceAtLeast(1) * 100
/** Gridline [i]'s height as a fraction of the plot. */
fun fraction(i: Long): Float = if (lines == 0L) 0f else i.toFloat() / lines
}
/** Round gridlines for the largest value [maxOre]: [niceStep] kronor apart, enough of them to reach it. */
internal fun axisScale(maxOre: Long): AxisScale {
val maxKronor = maxOre / 100
val step = niceStep(maxKronor)
val lines = if (maxKronor == 0L) 0L else ((maxKronor + step - 1) / step).coerceAtLeast(1)
return AxisScale(step, lines)
}
/** The y gutter: 44 dp, wider when the widest gridline label needs it (200 %). */
@Composable
internal fun axisGutter(scale: AxisScale): Dp {
val language = kvittLanguage()
val measurer = rememberTextMeasurer()
val widest = (0..scale.lines).maxOf { measurer.measure(MoneyFormatter.wholeKronor(scale.step * it, language), KvittTheme.typography.chartAxis).size.width }
return maxOf(KvittDimens.chartAxisGutter, with(LocalDensity.current) { widest.toDp() } + KvittTheme.spacing.s2)
}
/** The gridline labels in whole kronor, right-aligned in the [gutter]; decorative for TalkBack. */
@Composable
internal fun YAxisLabels(scale: AxisScale, gutter: Dp) {
val language = kvittLanguage()
val labelHeight = with(LocalDensity.current) { KvittTheme.typography.chartAxis.lineHeight.toDp() }
Box(Modifier.width(gutter).height(KvittDimens.chartPlot).clearAndSetSemantics { }) {
for (i in 0..scale.lines) {
Text(
MoneyFormatter.wholeKronor(scale.step * i, language),
Modifier
.width(gutter - KvittTheme.spacing.s2)
.offset(y = KvittDimens.chartPlot * (1 - scale.fraction(i)) - labelHeight / 2),
style = KvittTheme.typography.chartAxis,
color = KvittTheme.colors.chartAxisLabel,
textAlign = TextAlign.End,
maxLines = 1,
)
}
}
}
/** The 1 dp gridlines at the scale's round values (only the 0 baseline for an empty range). */
internal fun DrawScope.drawGridlines(scale: AxisScale, color: Color) {
val stroke = KvittDimens.chartGridLine.toPx()
for (i in 0..scale.lines) {
val y = (size.height * (1 - scale.fraction(i))).coerceIn(stroke, size.height - stroke)
drawLine(color, Offset(0f, y), Offset(size.width, y), stroke)
}
}
/**
* Where a chart's buckets sit: one slot of [slotWidth] per bucket (16 dp at least, 56 at most), [spacing] apart, in
* [contentWidth]; [scrolls] when the slots do not fit at the minimum width.
*/
internal class SlotLayout(val slotWidth: Dp, val spacing: Dp, val contentWidth: Dp, val scrolls: Boolean)
internal fun slotLayout(count: Int, plotWidth: Dp): SlotLayout {
val n = count.coerceAtLeast(1)
val natural = (plotWidth - KvittDimens.chartBarGap * (n - 1)) / n
val slot = natural.coerceIn(KvittDimens.chartMinBar, KvittDimens.chartMaxBar)
val scrolls = natural < KvittDimens.chartMinBar
val contentWidth = if (scrolls) (slot + KvittDimens.chartBarGap) * n else plotWidth
val spacing = if (scrolls) KvittDimens.chartBarGap else (contentWidth - slot * n) / (n.coerceAtLeast(2) - 1).coerceAtLeast(1)
return SlotLayout(slot, spacing, contentWidth, scrolls)
}
/** The x labels under the slots ("maj", or the week number "41"); every second one at 200 % when the slots are narrow. */
@Composable
internal fun XLabels(keys: List<String>, layout: SlotLayout, arrangement: Arrangement.Horizontal = Arrangement.spacedBy(layout.spacing)) {
val language = kvittLanguage()
val large = LocalDensity.current.fontScale >= KvittDimens.LARGE_FONT_SCALE
Row(Modifier.width(layout.contentWidth).padding(top = KvittDimens.chartXLabelTop).clearAndSetSemantics { }, horizontalArrangement = arrangement) {
val thin = large && layout.slotWidth < KvittDimens.chartMaxBar
keys.forEachIndexed { index, key ->
Text(
if (thin && index % 2 == 1) "" else DateFormatter.bucketLabel(key, language),
Modifier.width(layout.slotWidth),
style = KvittTheme.typography.chartXLabel,
color = KvittTheme.colors.chartAxisLabel,
textAlign = TextAlign.Center,
maxLines = 1,
softWrap = false,
overflow = TextOverflow.Visible,
)
}
}
}
/** The caption under the x axis ("Vecka" in week granularity), centred under the plot. */
@Composable
internal fun XCaption(text: String, gutter: Dp, endGutter: Dp = 0.dp) {
Text(
text,
Modifier.fillMaxWidth().padding(start = gutter, end = endGutter),
style = KvittTheme.typography.chartXLabel,
color = KvittTheme.colors.chartAxisLabel,
textAlign = TextAlign.Center,
)
}
/**
* Spending over time (design-spec §3.10): 170 dp plot, a 44 dp y gutter (wider when its labels need it, at 200 %) with gridlines at round whole-kronor values,
* one bar per bucket (empty buckets as zero), segments stacked in [groups] order with 2 dp gaps, top corners 4 and
@@ -112,88 +219,37 @@ fun StackedBarChart(
modifier: Modifier = Modifier,
xCaption: String? = null,
) {
val language = kvittLanguage()
val typography = KvittTheme.typography
val colors = KvittTheme.colors
val maxKronor = (buckets.maxOfOrNull { it.totalOre } ?: 0L) / 100
val step = niceStep(maxKronor)
val lines = if (maxKronor == 0L) 0L else ((maxKronor + step - 1) / step).coerceAtLeast(1)
val top = (step * lines).coerceAtLeast(1)
val measurer = rememberTextMeasurer()
val widest = (0..lines).maxOf { measurer.measure(MoneyFormatter.wholeKronor(step * it, language), typography.chartAxis).size.width }
val gutter = maxOf(KvittDimens.chartAxisGutter, with(LocalDensity.current) { widest.toDp() } + KvittTheme.spacing.s2)
val large = LocalDensity.current.fontScale >= KvittDimens.LARGE_FONT_SCALE
val scale = axisScale(buckets.maxOfOrNull { it.totalOre } ?: 0L)
val gutter = axisGutter(scale)
Column(modifier.fillMaxWidth().semantics { contentDescription = summary }) {
BoxWithConstraints(Modifier.fillMaxWidth()) {
val plotWidth = maxWidth - gutter
val n = buckets.size.coerceAtLeast(1)
val natural = (plotWidth - KvittDimens.chartBarGap * (n - 1)) / n
val barWidth = natural.coerceIn(KvittDimens.chartMinBar, KvittDimens.chartMaxBar)
val scrolls = natural < KvittDimens.chartMinBar
val contentWidth = if (scrolls) (barWidth + KvittDimens.chartBarGap) * n else plotWidth
val layout = slotLayout(buckets.size, maxWidth - gutter)
val scroll = rememberScrollState()
LaunchedEffect(scrolls, buckets.size) { if (scrolls) scroll.scrollTo(scroll.maxValue) }
val labelHeight = with(LocalDensity.current) { typography.chartAxis.lineHeight.toDp() }
LaunchedEffect(layout.scrolls, buckets.size) { if (layout.scrolls) scroll.scrollTo(scroll.maxValue) }
Row {
Box(Modifier.width(gutter).height(KvittDimens.chartPlot).clearAndSetSemantics { }) {
for (i in 0..lines) {
val fraction = if (lines == 0L) 0f else i.toFloat() / lines
Text(
MoneyFormatter.wholeKronor(step * i, language),
Modifier
.width(gutter - KvittTheme.spacing.s2)
.offset(y = KvittDimens.chartPlot * (1 - fraction) - labelHeight / 2),
style = typography.chartAxis,
color = colors.chartAxisLabel,
textAlign = TextAlign.End,
maxLines = 1,
)
}
}
Box(Modifier.weight(1f).then(if (scrolls) Modifier.horizontalScroll(scroll) else Modifier)) {
Column(Modifier.width(contentWidth)) {
Box(Modifier.width(contentWidth).height(KvittDimens.chartPlot)) {
Canvas(Modifier.width(contentWidth).height(KvittDimens.chartPlot).clearAndSetSemantics { }) {
val stroke = KvittDimens.chartGridLine.toPx()
for (i in 0..lines) {
val y = size.height * (1 - if (lines == 0L) 0f else i.toFloat() / lines)
drawLine(colors.chartGrid, Offset(0f, y.coerceIn(stroke, size.height - stroke)), Offset(size.width, y.coerceIn(stroke, size.height - stroke)), stroke)
}
YAxisLabels(scale, gutter)
Box(Modifier.weight(1f).then(if (layout.scrolls) Modifier.horizontalScroll(scroll) else Modifier)) {
Column(Modifier.width(layout.contentWidth)) {
Box(Modifier.width(layout.contentWidth).height(KvittDimens.chartPlot)) {
Canvas(Modifier.width(layout.contentWidth).height(KvittDimens.chartPlot).clearAndSetSemantics { }) {
drawGridlines(scale, colors.chartGrid)
}
Row(horizontalArrangement = Arrangement.spacedBy(if (scrolls) KvittDimens.chartBarGap else (contentWidth - barWidth * n) / (n.coerceAtLeast(2) - 1).coerceAtLeast(1))) {
Row(horizontalArrangement = Arrangement.spacedBy(layout.spacing)) {
buckets.forEach { bucket ->
val description = barDescription(bucket)
Bar(bucket, groups, top * 100, barWidth, Modifier
Bar(bucket, groups, scale.topOre, layout.slotWidth, Modifier
.clickable(role = Role.Button) { onBucket(bucket.key) }
.clearAndSetSemantics { contentDescription = description; role = Role.Button })
}
}
}
Row(
Modifier.width(contentWidth).padding(top = KvittDimens.chartXLabelTop).clearAndSetSemantics { },
horizontalArrangement = Arrangement.spacedBy(if (scrolls) KvittDimens.chartBarGap else (contentWidth - barWidth * n) / (n.coerceAtLeast(2) - 1).coerceAtLeast(1)),
) {
val thin = large && barWidth < KvittDimens.chartMaxBar
buckets.forEachIndexed { index, bucket ->
Text(
if (thin && index % 2 == 1) "" else DateFormatter.bucketLabel(bucket.key, language),
Modifier.width(barWidth),
style = typography.chartXLabel,
color = colors.chartAxisLabel,
textAlign = TextAlign.Center,
maxLines = 1,
softWrap = false,
overflow = TextOverflow.Visible,
)
}
}
XLabels(buckets.map { it.key }, layout)
}
}
}
}
if (xCaption != null) {
Text(xCaption, Modifier.fillMaxWidth().padding(start = gutter), style = typography.chartXLabel, color = colors.chartAxisLabel, textAlign = TextAlign.Center)
}
if (xCaption != null) XCaption(xCaption, gutter)
}
}
@@ -307,6 +363,7 @@ data class FilterToggle(val groupLabel: String, val options: List<String>, val s
/**
* The analytics filters (design-spec §3.10): period chip ([period] "Senaste 6 mån", TalkBack [periodSpoken]) with
* [granularity] "Månad | Vecka"; then [measure] and [groupBy] at half width each. At ≥ 1.5× one control per row.
* S-12 (D-128) passes neither [measure] nor [groupBy]: the second row is left out.
* [periodOwnRow] puts the period chip on a row of its own and the granularity toggle on the next, full width, so a long
* custom range label ("Anpassad · 24 aug.–17 okt.") is not truncated (design frame S-11-analytics-week).
*/
@@ -316,8 +373,8 @@ fun AnalyticsFilterBar(
periodSpoken: String,
onPeriod: () -> Unit,
granularity: FilterToggle,
measure: FilterToggle,
groupBy: FilterToggle,
measure: FilterToggle?,
groupBy: FilterToggle?,
modifier: Modifier = Modifier,
periodOwnRow: Boolean = false,
) {
@@ -340,28 +397,30 @@ fun AnalyticsFilterBar(
}
}
val toggle: @Composable (FilterToggle, Modifier) -> Unit = { t, m -> ConnectedButtonGroup(t.options, t.selected, t.onSelect, t.groupLabel, m) }
val secondRow: @Composable () -> Unit = {
if (measure != null || groupBy != null) {
Row(horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2)) {
measure?.let { toggle(it, Modifier.weight(1f)) }
groupBy?.let { toggle(it, Modifier.weight(1f)) }
}
}
}
Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3)) {
if (large) {
chip(Modifier)
toggle(granularity, Modifier.fillMaxWidth())
toggle(measure, Modifier.fillMaxWidth())
toggle(groupBy, Modifier.fillMaxWidth())
measure?.let { toggle(it, Modifier.fillMaxWidth()) }
groupBy?.let { toggle(it, Modifier.fillMaxWidth()) }
} else if (periodOwnRow) {
chip(Modifier)
toggle(granularity, Modifier.fillMaxWidth())
Row(horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2)) {
toggle(measure, Modifier.weight(1f))
toggle(groupBy, Modifier.weight(1f))
}
secondRow()
} else {
Row(horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2), verticalAlignment = Alignment.CenterVertically) {
chip(Modifier.weight(1f))
toggle(granularity, Modifier.weight(1f).width(KvittDimens.filterToggleMinWidth))
}
Row(horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2)) {
toggle(measure, Modifier.weight(1f))
toggle(groupBy, Modifier.weight(1f))
}
secondRow()
}
}
}
@@ -0,0 +1,228 @@
package se.hagstedt.kvitt.design.chart
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import se.hagstedt.kvitt.design.KvittTheme
import se.hagstedt.kvitt.design.component.kvittLanguage
import se.hagstedt.kvitt.design.format.MoneyFormatter
import se.hagstedt.kvitt.design.theme.KvittDimens
// The S-12 line chart of one category or account over the range (D-128), a sibling of StackedBarChart: the same plot
// height, y gutter, gridlines, axis typography, slot geometry and horizontal scroll, drawn with Compose Canvas (D-063).
/** One bucket of a line series: its key ("2026-10", "2026-W41") and its value in öre. */
data class LinePoint(val key: String, val ore: Long)
/** The three lines of [SpendLineChart], for the chart and the swatches of [LineLegend]. */
enum class LineKind { Series, Average, MovingAverage }
/** A legend entry of [LineLegend]: its [label] and which line it names. */
data class LineLegendItem(val label: String, val kind: LineKind)
/** Line chart styling (D-128): sizes and alphas of the three lines, the points and the area under the series. */
internal object LineChartStyle {
val seriesWidth = 2.5.dp
val overlayWidth = 1.5.dp
val pointRadius = 3.dp
val dashOn = 6.dp
val dashOff = 4.dp
val swatchWidth = 16.dp
const val AREA_TOP_ALPHA = 0.22f
const val AREA_BOTTOM_ALPHA = 0.02f
const val AVERAGE_ALPHA = 0.75f
const val MOVING_ALPHA = 0.85f
}
/** The range average's colour: the group's colour, muted (D-128). */
internal fun averageColor(color: Color): Color = color.copy(alpha = LineChartStyle.AVERAGE_ALPHA)
/** The moving average's colour: the neutral axis colour, so it never reads as a second group (D-128). */
@Composable
internal fun movingAverageColor(): Color = KvittTheme.colors.chartAxisLabel.copy(alpha = LineChartStyle.MOVING_ALPHA)
/**
* One group's spending over time (S-12, D-128): the plot, y gutter, gridlines and x labels of [StackedBarChart], one
* point per bucket (empty buckets as zero, AC-14.7) joined by a 2.5 dp line in the group's [color] with a soft fill
* under it; [averageOre], the range average, as a dashed line in the muted group colour labelled with its amount at the
* right end; [movingAverage] (one value per point) as a thinner neutral line. A range without spending shows only the 0
* baseline (D-068). Not interactive: TalkBack reads [summary] for the whole chart.
*/
@Composable
fun SpendLineChart(
points: List<LinePoint>,
color: Color,
summary: String,
modifier: Modifier = Modifier,
averageOre: Long? = null,
movingAverage: List<Long> = emptyList(),
xCaption: String? = null,
) {
val scale = axisScale(points.maxOfOrNull { it.ore } ?: 0L)
val gutter = axisGutter(scale)
val empty = scale.lines == 0L
val average = averageOre?.takeIf { it > 0 && !empty }
val language = kvittLanguage()
val typography = KvittTheme.typography
val averageLabel = average?.let { it to MoneyFormatter.wholeKronor((it + HALF_KRONA) / ORE_PER_KRONA, language) }
val measurer = rememberTextMeasurer()
val density = LocalDensity.current
val endGutter = averageLabel?.let { (_, text) -> with(density) { measurer.measure(text, typography.chartAxis).size.width.toDp() } + KvittTheme.spacing.s2 } ?: 0.dp
val gridColor = KvittTheme.colors.chartGrid
val averageLine = averageColor(color)
val movingLine = movingAverageColor()
Column(modifier.fillMaxWidth().semantics { contentDescription = summary }) {
BoxWithConstraints(Modifier.fillMaxWidth()) {
val layout = slotLayout(points.size, maxWidth - gutter - endGutter)
val scroll = rememberScrollState()
// Keyed on the gutters too: a font-scale change narrows the viewport, and the newest bucket must stay in view.
LaunchedEffect(layout.scrolls, points.size, gutter, endGutter) { if (layout.scrolls) scroll.scrollTo(scroll.maxValue) }
val centred = points.size == 1
Row {
YAxisLabels(scale, gutter)
Box(Modifier.weight(1f).then(if (layout.scrolls) Modifier.horizontalScroll(scroll) else Modifier)) {
Column(Modifier.width(layout.contentWidth)) {
Canvas(Modifier.width(layout.contentWidth).height(KvittDimens.chartPlot).clearAndSetSemantics { }) {
drawGridlines(scale, gridColor)
if (empty) return@Canvas
val xs = points.indices.map { i -> pointX(i, centred, layout) }
fun y(ore: Long) = size.height * (1 - ore.toFloat() / scale.topOre)
drawArea(xs, points.map { y(it.ore) }, color)
if (average != null) drawAverage(y(average), averageLine)
if (movingAverage.size == points.size) drawPolyline(xs, movingAverage.map(::y), movingLine, LineChartStyle.overlayWidth.toPx())
drawPolyline(xs, points.map { y(it.ore) }, color, LineChartStyle.seriesWidth.toPx())
xs.forEachIndexed { i, x -> drawCircle(color, LineChartStyle.pointRadius.toPx(), Offset(x, y(points[i].ore))) }
}
XLabels(points.map { it.key }, layout, if (centred) Arrangement.Center else Arrangement.spacedBy(layout.spacing))
}
}
averageLabel?.let { (ore, averageText) ->
val labelHeight = with(density) { typography.chartAxis.lineHeight.toDp() }
val y = KvittDimens.chartPlot * (1 - ore.toFloat() / scale.topOre) - labelHeight / 2
Box(Modifier.width(endGutter).height(KvittDimens.chartPlot).clearAndSetSemantics { }) {
Text(
averageText,
Modifier.padding(start = KvittTheme.spacing.s2).offset(y = y.coerceIn(0.dp, KvittDimens.chartPlot - labelHeight)),
style = typography.chartAxis,
color = color,
maxLines = 1,
)
}
}
}
}
if (xCaption != null) XCaption(xCaption, gutter, endGutter)
}
}
private const val ORE_PER_KRONA = 100L
private const val HALF_KRONA = 50L
/** The centre of slot [i] in px (a single point sits in the middle of the plot). */
private fun DrawScope.pointX(i: Int, centred: Boolean, layout: SlotLayout): Float =
if (centred) size.width / 2 else ((layout.slotWidth + layout.spacing) * i + layout.slotWidth / 2).toPx()
private fun DrawScope.drawPolyline(xs: List<Float>, ys: List<Float>, color: Color, width: Float) {
if (xs.size < 2) return
val path = Path().apply {
moveTo(xs[0], ys[0])
for (i in 1 until xs.size) lineTo(xs[i], ys[i])
}
drawPath(path, color, style = Stroke(width, cap = StrokeCap.Round, join = StrokeJoin.Round))
}
/** The soft fill under the series: the group colour fading from 22 % to 2 % towards the baseline. */
private fun DrawScope.drawArea(xs: List<Float>, ys: List<Float>, color: Color) {
if (xs.size < 2) return
val path = Path().apply {
moveTo(xs.first(), size.height)
xs.indices.forEach { lineTo(xs[it], ys[it]) }
lineTo(xs.last(), size.height)
close()
}
val brush = Brush.verticalGradient(
listOf(color.copy(alpha = LineChartStyle.AREA_TOP_ALPHA), color.copy(alpha = LineChartStyle.AREA_BOTTOM_ALPHA)),
startY = ys.min(),
endY = size.height,
)
drawPath(path, brush)
}
/** The range average: a dashed line across the plot (6 dp on, 4 dp off). */
private fun DrawScope.drawAverage(y: Float, color: Color) {
val dash = PathEffect.dashPathEffect(floatArrayOf(LineChartStyle.dashOn.toPx(), LineChartStyle.dashOff.toPx()))
drawLine(color, Offset(0f, y), Offset(size.width, y), LineChartStyle.overlayWidth.toPx(), pathEffect = dash)
}
/**
* The legend of [SpendLineChart] (D-128): a short line swatch in each line's style (solid series, dashed average, thin
* neutral moving average) and its label, wrapping like [ChartLegend]; decorative for TalkBack (the summary names them).
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun LineLegend(items: List<LineLegendItem>, color: Color, modifier: Modifier = Modifier) {
val averageLine = averageColor(color)
val movingLine = movingAverageColor()
FlowRow(
modifier.fillMaxWidth().clearAndSetSemantics { },
horizontalArrangement = Arrangement.spacedBy(KvittDimens.legendItemGap),
verticalArrangement = Arrangement.spacedBy(KvittDimens.legendRowGap),
) {
items.forEach { item ->
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2)) {
LineSwatch(item.kind, color, averageLine, movingLine, LineChartStyle.swatchWidth)
Text(item.label, style = KvittTheme.typography.chartLegend, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
@Composable
private fun LineSwatch(kind: LineKind, series: Color, average: Color, moving: Color, width: Dp) {
Canvas(Modifier.size(width, KvittDimens.legendSwatch)) {
val y = size.height / 2
when (kind) {
LineKind.Series -> {
drawLine(series, Offset(0f, y), Offset(size.width, y), LineChartStyle.seriesWidth.toPx(), cap = StrokeCap.Round)
drawCircle(series, LineChartStyle.pointRadius.toPx(), Offset(size.width / 2, y))
}
LineKind.Average -> drawAverage(y, average)
LineKind.MovingAverage -> drawLine(moving, Offset(0f, y), Offset(size.width, y), LineChartStyle.overlayWidth.toPx(), cap = StrokeCap.Round)
}
}
}
@@ -0,0 +1,85 @@
package se.hagstedt.kvitt.design.chart
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.test.junit4.v2.createComposeRule
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.ParameterizedRobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
import se.hagstedt.kvitt.design.KvittTheme
import se.hagstedt.kvitt.design.screenshot.Snapshots
import se.hagstedt.kvitt.model.ColorToken
/**
* The S-12 line chart (D-128) beside the stacked bars: six months with the range average and the moving average, a
* single bucket (a point, no average lines), 30 weeks that scroll to the newest, and an empty range (the 0 baseline only, D-068), × light/dark ×
* Swedish/English, plus 200 % for the long sheet.
*/
@RunWith(ParameterizedRobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [36], qualifiers = "w411dp-h2400dp-mdpi")
class LineChartScreenshotTest(private val sheet: String) {
@get:Rule
val compose = createComposeRule()
@Test
fun chart() {
Snapshots.snap(compose, sheet, largeFont = sheet == "line_chart_variants", content = sheets.getValue(sheet))
}
companion object {
private val months = listOf("2026-05", "2026-06", "2026-07", "2026-08", "2026-09", "2026-10")
private val monthly = listOf(553_515L, 563_295L, 0L, 582_855L, 892_635L, 391_000L)
// Means of the samples, as S-12 computes them (AnalyticsRules in :feature:analytics).
private const val MONTHLY_AVERAGE = 497_217L
private val monthlyMoving = listOf(553_515L, 558_405L, 372_270L, 382_050L, 491_830L, 622_163L)
private val weeks = (16..45).map { "2026-W$it" to (it % 5) * 41_250L + (it % 3) * 18_900L + 20_000L }
@Composable
private fun legend(color: Color, overlays: Boolean) = LineLegend(
listOfNotNull(
LineLegendItem("Mat & hushåll", LineKind.Series),
LineLegendItem("Snitt", LineKind.Average).takeIf { overlays },
LineLegendItem("Glidande snitt (3)", LineKind.MovingAverage).takeIf { overlays },
),
color,
)
val sheets: Map<String, @Composable ColumnScope.() -> Unit> = mapOf(
"line_chart" to {
val color = KvittTheme.colors.category(ColorToken.GREEN)
ChartCard("Utgifter över tid") {
SpendLineChart(months.zip(monthly).map { LinePoint(it.first, it.second) }, color, "", averageOre = MONTHLY_AVERAGE, movingAverage = monthlyMoving)
legend(color, overlays = true)
}
},
"line_chart_variants" to {
val color = KvittTheme.colors.account(3)
ChartCard("En månad") {
SpendLineChart(listOf(LinePoint("2026-10", 960_000)), color, "")
legend(color, overlays = false)
}
ChartCard("30 veckor") {
val values = weeks.map { it.second }
val average = values.sum() / values.size
val moving = values.indices.map { i -> values.subList((i - 2).coerceAtLeast(0), i + 1).let { w -> w.sum() / w.size } }
SpendLineChart(weeks.map { LinePoint(it.first, it.second) }, color, "", averageOre = average, movingAverage = moving, xCaption = "Vecka")
legend(color, overlays = true)
}
ChartCard("Tom period") {
SpendLineChart(listOf("2026-11", "2026-12", "2027-01").map { LinePoint(it, 0) }, color, "")
legend(color, overlays = false)
}
},
)
@JvmStatic
@ParameterizedRobolectricTestRunner.Parameters(name = "{0}")
fun names(): List<Array<Any>> = sheets.keys.map { arrayOf<Any>(it) }
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB