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
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 40 KiB |