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.geometry.Size
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.clipPath import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@@ -95,6 +96,112 @@ fun niceStep(maxKronor: Long): Long {
return (factor * magnitude).toLong().coerceAtLeast(1) 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, * 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 * 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, modifier: Modifier = Modifier,
xCaption: String? = null, xCaption: String? = null,
) { ) {
val language = kvittLanguage()
val typography = KvittTheme.typography
val colors = KvittTheme.colors val colors = KvittTheme.colors
val maxKronor = (buckets.maxOfOrNull { it.totalOre } ?: 0L) / 100 val scale = axisScale(buckets.maxOfOrNull { it.totalOre } ?: 0L)
val step = niceStep(maxKronor) val gutter = axisGutter(scale)
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
Column(modifier.fillMaxWidth().semantics { contentDescription = summary }) { Column(modifier.fillMaxWidth().semantics { contentDescription = summary }) {
BoxWithConstraints(Modifier.fillMaxWidth()) { BoxWithConstraints(Modifier.fillMaxWidth()) {
val plotWidth = maxWidth - gutter val layout = slotLayout(buckets.size, 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 scroll = rememberScrollState() val scroll = rememberScrollState()
LaunchedEffect(scrolls, buckets.size) { if (scrolls) scroll.scrollTo(scroll.maxValue) } LaunchedEffect(layout.scrolls, buckets.size) { if (layout.scrolls) scroll.scrollTo(scroll.maxValue) }
val labelHeight = with(LocalDensity.current) { typography.chartAxis.lineHeight.toDp() }
Row { Row {
Box(Modifier.width(gutter).height(KvittDimens.chartPlot).clearAndSetSemantics { }) { YAxisLabels(scale, gutter)
for (i in 0..lines) { Box(Modifier.weight(1f).then(if (layout.scrolls) Modifier.horizontalScroll(scroll) else Modifier)) {
val fraction = if (lines == 0L) 0f else i.toFloat() / lines Column(Modifier.width(layout.contentWidth)) {
Text( Box(Modifier.width(layout.contentWidth).height(KvittDimens.chartPlot)) {
MoneyFormatter.wholeKronor(step * i, language), Canvas(Modifier.width(layout.contentWidth).height(KvittDimens.chartPlot).clearAndSetSemantics { }) {
Modifier drawGridlines(scale, colors.chartGrid)
.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)
}
} }
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 -> buckets.forEach { bucket ->
val description = barDescription(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) } .clickable(role = Role.Button) { onBucket(bucket.key) }
.clearAndSetSemantics { contentDescription = description; role = Role.Button }) .clearAndSetSemantics { contentDescription = description; role = Role.Button })
} }
} }
} }
Row( XLabels(buckets.map { it.key }, layout)
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,
)
}
}
} }
} }
} }
} }
if (xCaption != null) { if (xCaption != null) XCaption(xCaption, gutter)
Text(xCaption, Modifier.fillMaxWidth().padding(start = gutter), style = typography.chartXLabel, color = colors.chartAxisLabel, textAlign = TextAlign.Center)
}
} }
} }
@@ -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 * 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. * [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 * [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). * 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, periodSpoken: String,
onPeriod: () -> Unit, onPeriod: () -> Unit,
granularity: FilterToggle, granularity: FilterToggle,
measure: FilterToggle, measure: FilterToggle?,
groupBy: FilterToggle, groupBy: FilterToggle?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
periodOwnRow: Boolean = false, 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 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)) { Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3)) {
if (large) { if (large) {
chip(Modifier) chip(Modifier)
toggle(granularity, Modifier.fillMaxWidth()) toggle(granularity, Modifier.fillMaxWidth())
toggle(measure, Modifier.fillMaxWidth()) measure?.let { toggle(it, Modifier.fillMaxWidth()) }
toggle(groupBy, Modifier.fillMaxWidth()) groupBy?.let { toggle(it, Modifier.fillMaxWidth()) }
} else if (periodOwnRow) { } else if (periodOwnRow) {
chip(Modifier) chip(Modifier)
toggle(granularity, Modifier.fillMaxWidth()) toggle(granularity, Modifier.fillMaxWidth())
Row(horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2)) { secondRow()
toggle(measure, Modifier.weight(1f))
toggle(groupBy, Modifier.weight(1f))
}
} else { } else {
Row(horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2), verticalAlignment = Alignment.CenterVertically) { Row(horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2), verticalAlignment = Alignment.CenterVertically) {
chip(Modifier.weight(1f)) chip(Modifier.weight(1f))
toggle(granularity, Modifier.weight(1f).width(KvittDimens.filterToggleMinWidth)) toggle(granularity, Modifier.weight(1f).width(KvittDimens.filterToggleMinWidth))
} }
Row(horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2)) { secondRow()
toggle(measure, Modifier.weight(1f))
toggle(groupBy, Modifier.weight(1f))
}
} }
} }
} }
@@ -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

@@ -13,6 +13,8 @@ import se.hagstedt.kvitt.model.GroupBy
import se.hagstedt.kvitt.model.Measure import se.hagstedt.kvitt.model.Measure
import se.hagstedt.kvitt.model.RangePreset import se.hagstedt.kvitt.model.RangePreset
import se.hagstedt.kvitt.model.SyncSummary import se.hagstedt.kvitt.model.SyncSummary
import kotlin.math.abs
import kotlin.math.sign
/** Which period control is open on S-11: none, the preset menu, or the custom range picker. */ /** Which period control is open on S-11: none, the preset menu, or the custom range picker. */
enum class PeriodDialog { None, Menu, CustomPicker } enum class PeriodDialog { None, Menu, CustomPicker }
@@ -32,8 +34,8 @@ sealed interface AnalyticsContent {
/** S-11 state: the [filter] the toggles show (applied at once), the open [dialog], and the [content] (null before the first emission). */ /** S-11 state: the [filter] the toggles show (applied at once), the open [dialog], and the [content] (null before the first emission). */
data class AnalyticsUiState(val filter: AnalyticsFilter, val dialog: PeriodDialog, val content: AnalyticsContent?) data class AnalyticsUiState(val filter: AnalyticsFilter, val dialog: PeriodDialog, val content: AnalyticsContent?)
/** S-11 events (the ViewModel implements them; previews and screenshot tests pass their own). */ /** The period and granularity events S-11 and S-12 share (D-128: S-12 has its own range and granularity). */
interface AnalyticsEvents { interface PeriodEvents {
fun onPeriod() fun onPeriod()
fun onDismissPeriod() fun onDismissPeriod()
@@ -43,6 +45,10 @@ interface AnalyticsEvents {
/** The picker's "Använd": ignored unless both dates are set (§14.2.4: the editor never sends a missing date). */ /** The picker's "Använd": ignored unless both dates are set (§14.2.4: the editor never sends a missing date). */
fun onCustomRange(start: String?, end: String?) fun onCustomRange(start: String?, end: String?)
fun onGranularity(granularity: Granularity) fun onGranularity(granularity: Granularity)
}
/** S-11 events (the ViewModel implements them; previews and screenshot tests pass their own). */
interface AnalyticsEvents : PeriodEvents {
fun onMeasure(measure: Measure) fun onMeasure(measure: Measure)
fun onGroupBy(groupBy: GroupBy) fun onGroupBy(groupBy: GroupBy)
} }
@@ -96,9 +102,13 @@ data class DrillDownArgs(val filter: AnalyticsFilter, val bucketKey: String?, va
} }
/** /**
* S-12 state: the [args], the repository's [data] (null until it emits), the range the filter resolves to ([start], * S-12 state: the [args], the repository's [data] (null until it emits), the range [filter] resolves to ([start],
* [end], for the title of a category or account over the whole range), and the [category] or [account] behind the * [end], for the title of a category or account over the whole range), and the [category] or [account] behind the
* title and header, with the account's chart position [accountIndex]. * title and header, with the account's chart position [accountIndex].
*
* A category or account over the range (D-128) has its own [filter] (range and granularity, initialised from S-11's;
* measure and group-by stay S-11's), its open period [dialog], and the [series] of its line chart; a bucket drill-down
* keeps `args.filter` and has no series.
*/ */
data class DrillDownUiState( data class DrillDownUiState(
val args: DrillDownArgs, val args: DrillDownArgs,
@@ -108,8 +118,19 @@ data class DrillDownUiState(
val category: Category?, val category: Category?,
val account: Account?, val account: Account?,
val accountIndex: Int, val accountIndex: Int,
val filter: AnalyticsFilter = args.filter,
val dialog: PeriodDialog = PeriodDialog.None,
val series: List<SeriesPoint>? = null,
) )
/** One bucket of a group's series on S-12 (D-128): key, the bucket's dates clipped to the range, and the group's öre. */
data class SeriesPoint(val key: String, val start: String, val end: String, val ore: Long)
/** How the latest full bucket compares with the range average (D-128): "Oktober 2026: 12 % över snittet". */
data class AverageComparison(val key: String, val percent: Long, val direction: Direction) {
enum class Direction { ABOVE, BELOW, ON }
}
/** The input rules of S-11 and S-12, pure so the unit tests reach them. */ /** The input rules of S-11 and S-12, pure so the unit tests reach them. */
object AnalyticsRules { object AnalyticsRules {
/** AC-14.2 to AC-14.5: last 6 months, month, household, category. */ /** AC-14.2 to AC-14.5: last 6 months, month, household, category. */
@@ -137,6 +158,75 @@ object AnalyticsRules {
/** Nothing has been synced yet: the first sync is the only loading state (SPEC §13.3). */ /** Nothing has been synced yet: the first sync is the only loading state (SPEC §13.3). */
fun isFirstSync(summary: SyncSummary): Boolean = summary.lastSuccessAt == null fun isFirstSync(summary: SyncSummary): Boolean = summary.lastSuccessAt == null
/** D-128: the moving average on S-12 is trailing over this many buckets (the bucket and the two before it). */
const val MOVING_AVERAGE_WINDOW = 3
private const val PERCENT = 100L
private const val WEEK_LAST_DAY = 6L
/** S-12 shows its own range controls and the line chart for a category or account over the range, not for a bucket (D-128). */
fun isOverRange(args: DrillDownArgs): Boolean = args.bucketKey == null && (args.categoryId != null || args.accountId != null)
/** The S-11 query whose buckets carry the drill-down group's totals: grouped by account for an account, else by category. */
fun seriesFilter(args: DrillDownArgs, filter: AnalyticsFilter): AnalyticsFilter =
filter.copy(groupBy = if (args.accountId != null) GroupBy.ACCOUNT else GroupBy.CATEGORY)
/** One point per bucket of [state] for [groupId], empty buckets as zero (AC-14.7); edge buckets are clipped by `spendSeries`. */
fun groupSeries(state: AnalyticsState, groupId: String): List<SeriesPoint> =
state.buckets.map { SeriesPoint(it.key, it.start, it.end, it.totals[groupId] ?: 0L) }
/** [a] / [b] rounded half-up (away from zero at exactly half). */
fun divHalfUp(a: Long, b: Long): Long {
require(b != 0L)
val q = a / b
val r = a % b
return if (2 * abs(r) >= abs(b)) q + a.sign * b.sign else q
}
/** The range average per bucket (D-128): total ÷ number of buckets, half-up to öre; 0 for no buckets. */
fun rangeAverage(values: List<Long>): Long = if (values.isEmpty()) 0L else divHalfUp(values.sum(), values.size.toLong())
/** The average lines and the caption need spending in the range and at least two buckets (one bucket is its own average). */
fun hasAverage(values: List<Long>): Boolean = values.size > 1 && rangeAverage(values) > 0L
/**
* The trailing moving average (D-128): each bucket with the [window] − 1 before it, half-up to öre; the first
* buckets average what exists (1, then 2 buckets).
*/
fun movingAverage(values: List<Long>, window: Int = MOVING_AVERAGE_WINDOW): List<Long> = values.indices.map { i ->
val slice = values.subList((i - window + 1).coerceAtLeast(0), i + 1)
divHalfUp(slice.sum(), slice.size.toLong())
}
/** A bucket the range does not clip: a whole calendar month (1st to last day) or a whole ISO week (7 days). */
fun isFullBucket(point: SeriesPoint): Boolean {
val start = LocalDate.parse(point.start)
val end = LocalDate.parse(point.end)
return if (point.key.contains("-W")) {
end.toEpochDays() - start.toEpochDays() == WEEK_LAST_DAY
} else {
start.day == 1 && LocalDate.fromEpochDays(end.toEpochDays() + 1).day == 1
}
}
/**
* The caption under the S-12 chart (D-128): the latest full bucket against the range average, percent of the average
* half-up to a whole number, "on average" when that rounds to 0. Null when [hasAverage] is false or no bucket is full.
*/
fun compareWithAverage(series: List<SeriesPoint>): AverageComparison? {
if (!hasAverage(series.map { it.ore })) return null
val average = rangeAverage(series.map { it.ore })
val latest = series.lastOrNull(::isFullBucket) ?: return null
val diff = latest.ore - average
val percent = divHalfUp(abs(diff) * PERCENT, average)
val direction = when {
percent == 0L -> AverageComparison.Direction.ON
diff > 0 -> AverageComparison.Direction.ABOVE
else -> AverageComparison.Direction.BELOW
}
return AverageComparison(latest.key, percent, direction)
}
/** The date picker works in UTC midnight milliseconds. */ /** The date picker works in UTC midnight milliseconds. */
fun millis(isoDate: String): Long = LocalDate.parse(isoDate).toEpochDays() * MILLIS_PER_DAY fun millis(isoDate: String): Long = LocalDate.parse(isoDate).toEpochDays() * MILLIS_PER_DAY
@@ -49,6 +49,7 @@ import se.hagstedt.kvitt.design.icon.KvittIcon
import se.hagstedt.kvitt.design.theme.KvittDimens import se.hagstedt.kvitt.design.theme.KvittDimens
import se.hagstedt.kvitt.model.Account import se.hagstedt.kvitt.model.Account
import se.hagstedt.kvitt.model.AccountId import se.hagstedt.kvitt.model.AccountId
import se.hagstedt.kvitt.model.AnalyticsFilter
import se.hagstedt.kvitt.model.AnalyticsState import se.hagstedt.kvitt.model.AnalyticsState
import se.hagstedt.kvitt.model.Category import se.hagstedt.kvitt.model.Category
import se.hagstedt.kvitt.model.CategoryId import se.hagstedt.kvitt.model.CategoryId
@@ -127,11 +128,7 @@ private fun Filters(state: AnalyticsUiState, events: AnalyticsEvents) {
period = period, period = period,
periodSpoken = stringResource(R.string.analytics_period_spoken, period), periodSpoken = stringResource(R.string.analytics_period_spoken, period),
onPeriod = events::onPeriod, onPeriod = events::onPeriod,
granularity = FilterToggle( granularity = granularityToggle(filter, events),
stringResource(R.string.analytics_granularity),
listOf(stringResource(R.string.analytics_granularity_month), stringResource(R.string.analytics_granularity_week)),
Granularity.entries.indexOf(filter.granularity),
) { events.onGranularity(Granularity.entries[it]) },
measure = FilterToggle( measure = FilterToggle(
stringResource(R.string.analytics_measure), stringResource(R.string.analytics_measure),
listOf(stringResource(R.string.analytics_measure_household), stringResource(R.string.analytics_measure_my_share)), listOf(stringResource(R.string.analytics_measure_household), stringResource(R.string.analytics_measure_my_share)),
@@ -147,6 +144,14 @@ private fun Filters(state: AnalyticsUiState, events: AnalyticsEvents) {
) )
} }
/** "Månad | Vecka" (AC-14.3), shared by S-11 and S-12 (D-128). */
@Composable
internal fun granularityToggle(filter: AnalyticsFilter, events: PeriodEvents): FilterToggle = FilterToggle(
stringResource(R.string.analytics_granularity),
listOf(stringResource(R.string.analytics_granularity_month), stringResource(R.string.analytics_granularity_week)),
Granularity.entries.indexOf(filter.granularity),
) { events.onGranularity(Granularity.entries[it]) }
/** The range caption: "maj–okt 2026 · Hushållet", or "24 aug.–17 okt. · 8 veckor" in week granularity. */ /** The range caption: "maj–okt 2026 · Hushållet", or "24 aug.–17 okt. · 8 veckor" in week granularity. */
@Composable @Composable
private fun totalCaption(data: AnalyticsState): String { private fun totalCaption(data: AnalyticsState): String {
@@ -31,6 +31,13 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import se.hagstedt.kvitt.design.KvittTheme import se.hagstedt.kvitt.design.KvittTheme
import se.hagstedt.kvitt.design.chart.AnalyticsFilterBar
import se.hagstedt.kvitt.design.chart.ChartCard
import se.hagstedt.kvitt.design.chart.LineKind
import se.hagstedt.kvitt.design.chart.LineLegend
import se.hagstedt.kvitt.design.chart.LineLegendItem
import se.hagstedt.kvitt.design.chart.LinePoint
import se.hagstedt.kvitt.design.chart.SpendLineChart
import se.hagstedt.kvitt.design.component.AccountTile import se.hagstedt.kvitt.design.component.AccountTile
import se.hagstedt.kvitt.design.component.AmountStyle import se.hagstedt.kvitt.design.component.AmountStyle
import se.hagstedt.kvitt.design.component.AmountTone import se.hagstedt.kvitt.design.component.AmountTone
@@ -49,7 +56,10 @@ import se.hagstedt.kvitt.design.component.kvittLanguage
import se.hagstedt.kvitt.design.component.spokenMoney import se.hagstedt.kvitt.design.component.spokenMoney
import se.hagstedt.kvitt.design.icon.KvittIcon import se.hagstedt.kvitt.design.icon.KvittIcon
import se.hagstedt.kvitt.design.theme.KvittDimens import se.hagstedt.kvitt.design.theme.KvittDimens
import se.hagstedt.kvitt.model.AnalyticsFilter
import se.hagstedt.kvitt.model.ExpenseId import se.hagstedt.kvitt.model.ExpenseId
import se.hagstedt.kvitt.model.Granularity
import se.hagstedt.kvitt.model.RangePreset
import se.hagstedt.kvitt.core.design.R as DesignR import se.hagstedt.kvitt.core.design.R as DesignR
/** /**
@@ -65,13 +75,18 @@ fun DrillDownScreen(
viewModel: DrillDownViewModel = hiltViewModel(), viewModel: DrillDownViewModel = hiltViewModel(),
) { ) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
DrillDownScreen(state, onOpenExpense, onBack, modifier, snackbarHostState = snackbarHostState) DrillDownScreen(state, viewModel, onOpenExpense, onBack, modifier, snackbarHostState = snackbarHostState)
} }
/** The stateless S-12 that previews and screenshot tests render. */ /**
* The stateless S-12 that previews and screenshot tests render. A category or account over the range (D-128) shows its
* own period and granularity controls ([events]) above the header, and the line chart of its spending between the header
* and the expense rows; a bucket drill-down shows the header and the rows only.
*/
@Composable @Composable
fun DrillDownScreen( fun DrillDownScreen(
state: DrillDownUiState, state: DrillDownUiState,
events: PeriodEvents,
onOpenExpense: (ExpenseId) -> Unit, onOpenExpense: (ExpenseId) -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -94,10 +109,19 @@ fun DrillDownScreen(
verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.detailGap), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.detailGap),
) { ) {
val data = state.data ?: return@Column val data = state.data ?: return@Column
val series = state.series
if (series != null) {
Box {
RangeFilters(state.filter, events)
if (state.dialog == PeriodDialog.Menu) PeriodMenuPopup(state.filter.preset, events)
}
}
Header(state, title, data.totalOre, data.rows.size) Header(state, title, data.totalOre, data.rows.size)
if (series != null) OverTimeLine(state, series)
if (data.rows.isEmpty()) { if (data.rows.isEmpty()) {
Text( Text(
stringResource(R.string.analytics_drilldown_empty), // A range with no spending reads like S-11's empty state; a bucket emptied since S-11 "Inga utgifter längre".
stringResource(if (series != null) R.string.analytics_empty else R.string.analytics_drilldown_empty),
Modifier.fillMaxWidth().padding(KvittTheme.spacing.s4), Modifier.fillMaxWidth().padding(KvittTheme.spacing.s4),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -111,6 +135,105 @@ fun DrillDownScreen(
} }
} }
} }
if (state.dialog == PeriodDialog.CustomPicker && state.series != null) {
CustomRangeDialog(state.filter.customStart ?: state.start, state.filter.customEnd ?: state.end, events)
}
}
/** S-12's own period chip and "Månad | Vecka" (D-128); measure and group-by stay S-11's and are not shown. */
@Composable
private fun RangeFilters(filter: AnalyticsFilter, events: PeriodEvents) {
val period = periodLabel(filter)
AnalyticsFilterBar(
period = period,
periodSpoken = stringResource(R.string.analytics_period_spoken, period),
onPeriod = events::onPeriod,
granularity = granularityToggle(filter, events),
measure = null,
groupBy = null,
periodOwnRow = filter.preset == RangePreset.CUSTOM,
)
}
/**
* "Utgifter över tid" for one category or account (D-128): the line of its spending per bucket, the dashed range
* average and the trailing moving average over [AnalyticsRules.MOVING_AVERAGE_WINDOW] buckets, a legend naming the
* three lines, and the caption comparing the latest full bucket with the average.
*/
@Composable
private fun OverTimeLine(state: DrillDownUiState, series: List<SeriesPoint>) {
val colors = KvittTheme.colors
val category = state.category
val color = when {
category != null -> colors.category(category.color)
state.account != null -> colors.account(state.accountIndex)
else -> MaterialTheme.colorScheme.primary
}
val name = category?.let { categoryName(it) } ?: state.account?.name.orEmpty()
val values = series.map { it.ore }
val average = AnalyticsRules.rangeAverage(values)
val withOverlays = AnalyticsRules.hasAverage(values)
val window = AnalyticsRules.MOVING_AVERAGE_WINDOW
val legend = listOfNotNull(
LineLegendItem(name, LineKind.Series),
LineLegendItem(stringResource(R.string.analytics_average), LineKind.Average).takeIf { withOverlays },
LineLegendItem(stringResource(R.string.analytics_moving_average, window), LineKind.MovingAverage).takeIf { withOverlays },
)
ChartCard(stringResource(R.string.analytics_over_time)) {
SpendLineChart(
points = series.map { LinePoint(it.key, it.ore) },
color = color,
summary = lineSummary(state, name, series, average),
averageOre = average.takeIf { withOverlays },
movingAverage = if (withOverlays) AnalyticsRules.movingAverage(values, window) else emptyList(),
xCaption = if (state.filter.granularity == Granularity.WEEK) stringResource(R.string.analytics_granularity_week) else null,
)
LineLegend(legend, color)
AnalyticsRules.compareWithAverage(series)?.let { comparison ->
Text(comparisonText(comparison), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
/**
* The chart's TalkBack summary (D-128): "Resor över tid, maj–okt 2026: från 860 kronor i maj 2026 till 9 600 kronor i
* oktober 2026, totalt 30 237 kronor, snitt 5 040 kronor."; one bucket: "…: totalt 9 600 kronor."
*/
@Composable
private fun lineSummary(state: DrillDownUiState, name: String, series: List<SeriesPoint>, average: Long): String {
val start = state.start
val end = state.end
val range = if (start != null && end != null) rangeText(start, end, state.filter, LocalKvittClock.current.today, kvittLanguage(), long = true) else ""
val first = series.firstOrNull()
val last = series.lastOrNull()
val total = spokenMoney(series.sumOf { it.ore })
return if (first == null || last == null || series.size == 1) {
stringResource(R.string.analytics_summary_line_single, name, range, total)
} else {
stringResource(
R.string.analytics_summary_line,
name,
range,
spokenMoney(first.ore),
bucketName(first.key, capitalized = false),
spokenMoney(last.ore),
bucketName(last.key, capitalized = false),
total,
spokenMoney(average),
)
}
}
/** "September 2026: 12 % över snittet", "… under snittet" or "…: på snittet" (D-128). */
@Composable
private fun comparisonText(comparison: AverageComparison): String {
val bucket = bucketName(comparison.key, capitalized = true)
val percent = stringResource(DesignR.string.common_percent, comparison.percent.toString())
return when (comparison.direction) {
AverageComparison.Direction.ABOVE -> stringResource(R.string.analytics_vs_average_above, bucket, percent)
AverageComparison.Direction.BELOW -> stringResource(R.string.analytics_vs_average_below, bucket, percent)
AverageComparison.Direction.ON -> stringResource(R.string.analytics_vs_average_on, bucket)
}
} }
/** /**
@@ -119,15 +242,14 @@ fun DrillDownScreen(
*/ */
@Composable @Composable
internal fun drillDownTitle(state: DrillDownUiState): String { internal fun drillDownTitle(state: DrillDownUiState): String {
val args = state.args
val name = state.category?.let { categoryName(it) } ?: state.account?.name val name = state.category?.let { categoryName(it) } ?: state.account?.name
val bucket = args.bucketKey val bucket = state.args.bucketKey
val start = state.start val start = state.start
val end = state.end val end = state.end
return when { return when {
name != null && bucket != null -> stringResource(R.string.analytics_drilldown_title, name, bucketName(bucket, capitalized = false)) name != null && bucket != null -> stringResource(R.string.analytics_drilldown_title, name, bucketName(bucket, capitalized = false))
name != null && start != null && end != null -> name != null && start != null && end != null ->
stringResource(R.string.analytics_drilldown_title, name, rangeText(start, end, args.filter, LocalKvittClock.current.today, kvittLanguage())) stringResource(R.string.analytics_drilldown_title, name, rangeText(start, end, state.filter, LocalKvittClock.current.today, kvittLanguage()))
name != null -> name name != null -> name
bucket != null -> bucketName(bucket, capitalized = true) bucket != null -> bucketName(bucket, capitalized = true)
else -> "" else -> ""
@@ -4,26 +4,42 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import se.hagstedt.kvitt.model.Account
import se.hagstedt.kvitt.model.AccountRepository import se.hagstedt.kvitt.model.AccountRepository
import se.hagstedt.kvitt.model.AnalyticsFilter
import se.hagstedt.kvitt.model.AnalyticsRepository import se.hagstedt.kvitt.model.AnalyticsRepository
import se.hagstedt.kvitt.model.AnalyticsState
import se.hagstedt.kvitt.model.Category
import se.hagstedt.kvitt.model.CategoryRepository import se.hagstedt.kvitt.model.CategoryRepository
import se.hagstedt.kvitt.model.DrillDownState
import se.hagstedt.kvitt.model.Granularity
import se.hagstedt.kvitt.model.RangePreset
import javax.inject.Inject import javax.inject.Inject
/** /**
* S-12 (AC-14.8): the expenses behind one number of S-11. The arguments come from the navigation route * S-12 (AC-14.8): the expenses behind one number of S-11. The arguments come from the navigation route
* ([DrillDownArgs.KEYS]); the title is formatted by the screen from them, not from `DrillDownState.title` (§14.2.4). * ([DrillDownArgs.KEYS]); the title is formatted by the screen from them, not from `DrillDownState.title` (§14.2.4).
*
* A category or account over the range (D-128) keeps its own range and granularity, starting from S-11's; changing them
* re-queries the total, the list and the line chart here only (S-11 keeps its own filter). The series is the group's
* column of S-11's own analytics query (`spendSeries`, §14.5.4), so it is computed on the phone from Room (AC-14.1).
*/ */
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel @HiltViewModel
class DrillDownViewModel( class DrillDownViewModel(
args: DrillDownArgs, private val args: DrillDownArgs,
analytics: AnalyticsRepository, analytics: AnalyticsRepository,
categories: CategoryRepository, categories: CategoryRepository,
accounts: AccountRepository, accounts: AccountRepository,
) : ViewModel() { ) : ViewModel(), PeriodEvents {
@Inject @Inject
constructor( constructor(
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
@@ -32,25 +48,71 @@ class DrillDownViewModel(
accounts: AccountRepository, accounts: AccountRepository,
) : this(DrillDownArgs.fromArguments { savedStateHandle.get<String>(it) }, analytics, categories, accounts) ) : this(DrillDownArgs.fromArguments { savedStateHandle.get<String>(it) }, analytics, categories, accounts)
val state: StateFlow<DrillDownUiState> = combine( private val filter = MutableStateFlow(args.filter)
analytics.drillDown(args.filter, args.bucketKey, args.categoryId, args.accountId), private val dialog = MutableStateFlow(PeriodDialog.None)
analytics.analytics(args.filter),
categories.all(), private val queried = filter.flatMapLatest { f ->
accounts.all(), combine(
) { data, range, cats, accs -> analytics.drillDown(f, args.bucketKey, args.categoryId, args.accountId),
val index = accs.indexOfFirst { it.id == args.accountId } analytics.analytics(AnalyticsRules.seriesFilter(args, f)),
DrillDownUiState( ) { data, range -> Triple(f, data, range) }
args = args, }
data = data,
start = range.start, val state: StateFlow<DrillDownUiState> = combine(queried, dialog, categories.all(), accounts.all()) { (f, data, range), d, cats, accs ->
end = range.end, drillDownUiState(args, f, d, data, range, cats, accs)
category = cats.firstOrNull { it.id == args.categoryId },
account = accs.getOrNull(index),
accountIndex = index.coerceAtLeast(0),
)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS), DrillDownUiState(args, null, null, null, null, null, 0)) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS), DrillDownUiState(args, null, null, null, null, null, 0))
override fun onPeriod() = dialog.update { PeriodDialog.Menu }
override fun onDismissPeriod() = dialog.update { PeriodDialog.None }
override fun onPreset(preset: RangePreset) {
if (preset == RangePreset.CUSTOM) {
dialog.update { PeriodDialog.CustomPicker }
} else {
filter.update { AnalyticsRules.withPreset(it, preset) }
dialog.update { PeriodDialog.None }
}
}
override fun onCustomRange(start: String?, end: String?) {
val applied = AnalyticsRules.withCustomRange(filter.value, start, end) ?: return
filter.update { applied }
dialog.update { PeriodDialog.None }
}
override fun onGranularity(granularity: Granularity) = filter.update { it.copy(granularity = granularity) }
private companion object { private companion object {
const val STOP_TIMEOUT_MILLIS = 5_000L const val STOP_TIMEOUT_MILLIS = 5_000L
} }
} }
/**
* S-12's state from one round of queries: the drill-down [data], the [range] of the current [filter] (whose buckets
* carry the group's series, D-128), and the categories and accounts behind the title and header.
*/
internal fun drillDownUiState(
args: DrillDownArgs,
filter: AnalyticsFilter,
dialog: PeriodDialog,
data: DrillDownState,
range: AnalyticsState,
categories: List<Category>,
accounts: List<Account>,
): DrillDownUiState {
val index = accounts.indexOfFirst { it.id == args.accountId }
val groupId = args.categoryId?.value ?: args.accountId?.value
return DrillDownUiState(
args = args,
data = data,
start = range.start,
end = range.end,
category = categories.firstOrNull { it.id == args.categoryId },
account = accounts.getOrNull(index),
accountIndex = index.coerceAtLeast(0),
filter = filter,
dialog = dialog,
series = if (AnalyticsRules.isOverRange(args) && groupId != null) AnalyticsRules.groupSeries(range, groupId) else null,
)
}
@@ -30,7 +30,7 @@ import se.hagstedt.kvitt.model.RangePreset
/** The period menu below the chip (design-spec S-11: the presets of AC-14.2, then "Anpassad…"). */ /** The period menu below the chip (design-spec S-11: the presets of AC-14.2, then "Anpassad…"). */
@Composable @Composable
internal fun PeriodMenuPopup(selected: RangePreset, events: AnalyticsEvents) { internal fun PeriodMenuPopup(selected: RangePreset, events: PeriodEvents) {
val below = with(LocalDensity.current) { (KvittDimens.filterButtonMin + KvittTheme.spacing.s1).roundToPx() } val below = with(LocalDensity.current) { (KvittDimens.filterButtonMin + KvittTheme.spacing.s1).roundToPx() }
Popup(alignment = Alignment.TopStart, offset = IntOffset(0, below), onDismissRequest = events::onDismissPeriod, properties = PopupProperties(focusable = true)) { Popup(alignment = Alignment.TopStart, offset = IntOffset(0, below), onDismissRequest = events::onDismissPeriod, properties = PopupProperties(focusable = true)) {
PeriodMenu(selected, events::onPreset) PeriodMenu(selected, events::onPreset)
@@ -52,7 +52,7 @@ internal fun PeriodMenu(selected: RangePreset, onPreset: (RangePreset) -> Unit,
} }
@Composable @Composable
internal fun CustomRangeDialog(start: String?, end: String?, events: AnalyticsEvents) { internal fun CustomRangeDialog(start: String?, end: String?, events: PeriodEvents) {
Dialog(onDismissRequest = events::onDismissPeriod, properties = DialogProperties(usePlatformDefaultWidth = false)) { Dialog(onDismissRequest = events::onDismissPeriod, properties = DialogProperties(usePlatformDefaultWidth = false)) {
CustomRangePicker(start, end, events::onCustomRange, events::onDismissPeriod) CustomRangePicker(start, end, events::onCustomRange, events::onDismissPeriod)
} }
@@ -43,4 +43,11 @@
<string name="analytics_measure">Mått</string> <string name="analytics_measure">Mått</string>
<string name="analytics_group_by">Gruppera efter</string> <string name="analytics_group_by">Gruppera efter</string>
<string name="analytics_range_apply">Använd</string> <string name="analytics_range_apply">Använd</string>
<string name="analytics_average">Snitt</string>
<string name="analytics_moving_average">Glidande snitt (%1$d)</string>
<string name="analytics_vs_average_above">%1$s: %2$s över snittet</string>
<string name="analytics_vs_average_below">%1$s: %2$s under snittet</string>
<string name="analytics_vs_average_on">%1$s: på snittet</string>
<string name="analytics_summary_line">%1$s över tid, %2$s: från %3$s i %4$s till %5$s i %6$s, totalt %7$s, snitt %8$s.</string>
<string name="analytics_summary_line_single">%1$s över tid, %2$s: totalt %3$s.</string>
</resources> </resources>
@@ -43,4 +43,11 @@
<string name="analytics_measure">Measure</string> <string name="analytics_measure">Measure</string>
<string name="analytics_group_by">Group by</string> <string name="analytics_group_by">Group by</string>
<string name="analytics_range_apply">Apply</string> <string name="analytics_range_apply">Apply</string>
<string name="analytics_average">Average</string>
<string name="analytics_moving_average">Moving average (%1$d)</string>
<string name="analytics_vs_average_above">%1$s: %2$s above average</string>
<string name="analytics_vs_average_below">%1$s: %2$s below average</string>
<string name="analytics_vs_average_on">%1$s: on average</string>
<string name="analytics_summary_line">%1$s over time, %2$s: from %3$s in %4$s to %5$s in %6$s, %7$s in total, average %8$s.</string>
<string name="analytics_summary_line_single">%1$s over time, %2$s: %3$s in total.</string>
</resources> </resources>
@@ -0,0 +1,88 @@
package se.hagstedt.kvitt.feature.analytics
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import se.hagstedt.kvitt.feature.analytics.AverageComparison.Direction
/** D-128: the range average, the trailing moving average over three buckets and the caption against the average. */
class AverageRulesTest {
private fun month(key: String, ore: Long, start: String = "$key-01", end: String = lastDay(key)) = SeriesPoint(key, start, end, ore)
private fun lastDay(key: String): String = when (key.substringAfter('-')) {
"02" -> "$key-28"
"04", "06", "09", "11" -> "$key-30"
else -> "$key-31"
}
@Test
fun halfUpDivision() {
assertEquals(2L, AnalyticsRules.divHalfUp(3, 2))
assertEquals(1L, AnalyticsRules.divHalfUp(4, 3))
assertEquals(2L, AnalyticsRules.divHalfUp(5, 3))
assertEquals(-2L, AnalyticsRules.divHalfUp(-3, 2))
assertEquals(0L, AnalyticsRules.divHalfUp(0, 7))
}
@Test
fun rangeAverageIsTotalOverBucketsHalfUpToOre() {
assertEquals(100L, AnalyticsRules.rangeAverage(listOf(100, 200, 0)))
assertEquals(2L, AnalyticsRules.rangeAverage(listOf(1, 2)))
// Empty buckets count (AC-14.7): 1 000 kr over three months, one of them empty, is 333,33 kr.
assertEquals(33_333L, AnalyticsRules.rangeAverage(listOf(50_000, 0, 50_000)))
assertEquals(0L, AnalyticsRules.rangeAverage(emptyList()))
assertEquals(0L, AnalyticsRules.rangeAverage(listOf(0, 0, 0)))
}
@Test
fun movingAverageTrailsThreeBucketsAndShortensAtTheStart() {
assertEquals(3, AnalyticsRules.MOVING_AVERAGE_WINDOW)
assertEquals(listOf(300L, 150L, 300L, 500L, 600L), AnalyticsRules.movingAverage(listOf(300, 0, 600, 900, 300)))
assertEquals(listOf(1L, 2L), AnalyticsRules.movingAverage(listOf(1, 2)))
assertEquals(listOf(7L), AnalyticsRules.movingAverage(listOf(7)))
assertEquals(emptyList<Long>(), AnalyticsRules.movingAverage(emptyList()))
assertEquals(listOf(0L, 0L, 0L), AnalyticsRules.movingAverage(listOf(0, 0, 0)))
}
@Test
fun fullBucketsAreWholeMonthsAndWholeWeeks() {
assertTrue(AnalyticsRules.isFullBucket(month("2026-09", 1)))
assertTrue(AnalyticsRules.isFullBucket(month("2026-02", 1)))
assertFalse(AnalyticsRules.isFullBucket(month("2026-10", 1, end = "2026-10-17")))
assertFalse(AnalyticsRules.isFullBucket(month("2026-05", 1, start = "2026-05-10")))
assertTrue(AnalyticsRules.isFullBucket(SeriesPoint("2026-W41", "2026-10-05", "2026-10-11", 1)))
assertFalse(AnalyticsRules.isFullBucket(SeriesPoint("2026-W42", "2026-10-12", "2026-10-17", 1)))
}
@Test
fun captionComparesTheLatestFullBucketWithTheAverage() {
// Average 1 000 kr; September (full) 1 120 kr is 12 % above; October is cut off by today and skipped.
val series = listOf(month("2026-08", 80_000), month("2026-09", 112_000), month("2026-10", 108_000, end = "2026-10-17"))
assertEquals(100_000L, AnalyticsRules.rangeAverage(series.map { it.ore }))
assertEquals(AverageComparison("2026-09", 12, Direction.ABOVE), AnalyticsRules.compareWithAverage(series))
}
@Test
fun captionBelowAndOnAverageRoundHalfUpToWholePercent() {
// Average 1 000 kr; 875 kr is 12,5 % below, which rounds half-up to 13.
val below = listOf(month("2026-08", 112_500), month("2026-09", 87_500))
assertEquals(AverageComparison("2026-09", 13, Direction.BELOW), AnalyticsRules.compareWithAverage(below))
// Average 1 000,02 kr; 1 000,00 kr is 0,002 % below: "on average".
val on = listOf(month("2026-08", 100_004), month("2026-09", 100_000))
assertEquals(AverageComparison("2026-09", 0, Direction.ON), AnalyticsRules.compareWithAverage(on))
}
@Test
fun noCaptionWithoutAnAverageOrAFullBucket() {
assertNull(AnalyticsRules.compareWithAverage(listOf(month("2026-08", 0), month("2026-09", 0))))
assertNull(AnalyticsRules.compareWithAverage(emptyList()))
assertNull(AnalyticsRules.compareWithAverage(listOf(month("2026-10", 50_000, end = "2026-10-17"))))
// One bucket is its own average: no lines, no caption.
assertFalse(AnalyticsRules.hasAverage(listOf(50_000)))
assertNull(AnalyticsRules.compareWithAverage(listOf(month("2026-09", 50_000))))
assertTrue(AnalyticsRules.hasAverage(listOf(50_000, 0)))
assertFalse(AnalyticsRules.hasAverage(listOf(0, 0)))
}
}
@@ -4,11 +4,14 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import se.hagstedt.kvitt.fakes.FakeRepositories import se.hagstedt.kvitt.fakes.FakeRepositories
import se.hagstedt.kvitt.fakes.analytics.AnalyticsScenarios import se.hagstedt.kvitt.fakes.analytics.AnalyticsScenarios
import se.hagstedt.kvitt.design.testing.MainDispatcherRule import se.hagstedt.kvitt.design.testing.MainDispatcherRule
import se.hagstedt.kvitt.model.Granularity
import se.hagstedt.kvitt.model.RangePreset
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
class DrillDownViewModelTest { class DrillDownViewModelTest {
@@ -39,6 +42,80 @@ class DrillDownViewModelTest {
assertEquals(1_384_000L + 412_600L + 960_000L, state.data?.totalOre) assertEquals(1_384_000L + 412_600L + 960_000L, state.data?.totalOre)
} }
private fun vm(args: DrillDownArgs): DrillDownViewModel {
val fakes = FakeRepositories(AnalyticsScenarios.loaded)
return DrillDownViewModel(args, fakes.analytics, fakes.categories, fakes.accounts)
}
/** Groceries per month of the scenario (May to September: four shops, growing by 97,80 kr a month). */
private fun groceries(month: Int): Long = 504_615L + 9_780L * month
@Test
fun categoryOverTheRangeHasOnePointPerMonth() = runTest {
val state = load(DrillDownArgs(AnalyticsRules.defaultFilter, null, AnalyticsScenarios.groceries.id, null))
val series = checkNotNull(state.series)
assertEquals(listOf("2026-05", "2026-06", "2026-07", "2026-08", "2026-09", "2026-10"), series.map { it.key })
assertEquals((5..9).map(::groceries) + 391_000L, series.map { it.ore })
assertEquals(state.data?.totalOre, series.sumOf { it.ore })
}
@Test
fun emptyBucketsAreZero() = runTest {
// Japan 2027 only has spending in September and October (AC-14.7).
val state = load(DrillDownArgs(AnalyticsRules.defaultFilter, null, null, AnalyticsScenarios.japan.id))
assertEquals(listOf(0L, 0L, 0L, 0L, 1_384_000L + 412_600L, 960_000L), state.series?.map { it.ore })
}
@Test
fun customRangeEdgeBucketsCountOnlyDatesInside() = runTest {
val filter = AnalyticsRules.defaultFilter.copy(preset = RangePreset.CUSTOM, customStart = "2026-05-10", customEnd = "2026-10-10")
val series = checkNotNull(load(DrillDownArgs(filter, null, AnalyticsScenarios.groceries.id, null)).series)
val may = series.first()
val october = series.last()
assertEquals("2026-05-10", may.start)
assertEquals((118_760L + 12_400L) + (136_510L + 9_850L) + (97_300L + 11_050L), may.ore) // 11, 19 and 25 May; not 4 May
assertEquals("2026-10-10", october.end)
assertEquals(101_245L + 78_560L, october.ore) // 1 and 7 October; not 12 or 16 October
assertEquals(false, AnalyticsRules.isFullBucket(may))
assertEquals(false, AnalyticsRules.isFullBucket(october))
}
@Test
fun ownRangeAndGranularityRequeryChartTotalAndList() = runTest {
val vm = vm(DrillDownArgs(AnalyticsRules.defaultFilter, null, AnalyticsScenarios.groceries.id, null))
vm.onGranularity(Granularity.WEEK)
val weeks = vm.state.first { it.series?.firstOrNull()?.key?.contains("-W") == true }
assertEquals(Granularity.WEEK, weeks.filter.granularity)
assertEquals(weeks.data?.totalOre, weeks.series?.sumOf { it.ore })
vm.onPreset(RangePreset.THIS_MONTH)
val october = vm.state.first { it.filter.preset == RangePreset.THIS_MONTH && it.data?.totalOre == 391_000L }
// Still weekly: October's ISO weeks up to today, 1–4, 5–11 and 12–17 October.
assertEquals(listOf("2026-W40", "2026-W41", "2026-W42"), october.series?.map { it.key })
assertEquals(listOf(101_245L, 78_560L, 86_435L + 124_760L), october.series?.map { it.ore })
assertEquals(4, october.data?.rows?.size)
// Measure and group-by stay S-11's (D-128).
assertEquals(AnalyticsRules.defaultFilter.measure, october.filter.measure)
}
@Test
fun customRangeOpensThePickerAndApplies() = runTest {
val vm = vm(DrillDownArgs(AnalyticsRules.defaultFilter, null, AnalyticsScenarios.groceries.id, null))
vm.onPeriod()
assertEquals(PeriodDialog.Menu, vm.state.first { it.dialog == PeriodDialog.Menu }.dialog)
vm.onPreset(RangePreset.CUSTOM)
vm.state.first { it.dialog == PeriodDialog.CustomPicker }
vm.onCustomRange("2026-09-30", "2026-08-01")
val applied = vm.state.first { it.filter.preset == RangePreset.CUSTOM && it.dialog == PeriodDialog.None && it.start == "2026-08-01" }
assertEquals(listOf("2026-08", "2026-09"), applied.series?.map { it.key })
assertEquals(groceries(8) + groceries(9), applied.data?.totalOre)
}
@Test
fun bucketAloneHasNoSeries() = runTest {
assertNull(load(DrillDownArgs(AnalyticsRules.defaultFilter, "2026-10", null, null)).series)
assertNull(load(DrillDownArgs(AnalyticsRules.defaultFilter, "2026-10", AnalyticsScenarios.groceries.id, null)).series)
}
@Test @Test
fun bucketAloneListsEveryGroup() = runTest { fun bucketAloneListsEveryGroup() = runTest {
val state = load(DrillDownArgs(AnalyticsRules.defaultFilter, "2026-10", null, null)) val state = load(DrillDownArgs(AnalyticsRules.defaultFilter, "2026-10", null, null))
@@ -17,10 +17,16 @@ import se.hagstedt.kvitt.feature.analytics.AnalyticsRules
import se.hagstedt.kvitt.feature.analytics.DrillDownArgs import se.hagstedt.kvitt.feature.analytics.DrillDownArgs
import se.hagstedt.kvitt.feature.analytics.DrillDownScreen import se.hagstedt.kvitt.feature.analytics.DrillDownScreen
import se.hagstedt.kvitt.feature.analytics.DrillDownUiState import se.hagstedt.kvitt.feature.analytics.DrillDownUiState
import se.hagstedt.kvitt.feature.analytics.PeriodDialog
import se.hagstedt.kvitt.feature.analytics.drillDownUiState
import se.hagstedt.kvitt.model.AnalyticsFilter
import se.hagstedt.kvitt.model.Granularity
import se.hagstedt.kvitt.model.GroupBy
import se.hagstedt.kvitt.model.Measure
import se.hagstedt.kvitt.model.RangePreset import se.hagstedt.kvitt.model.RangePreset
import se.hagstedt.kvitt.design.testing.ScreenSnapshots import se.hagstedt.kvitt.design.testing.ScreenSnapshots
/** S-12 in every state of PLAN-M7e §5, × light/dark × sv/en (plus 200 % for long content). */ /** S-12 in every state of PLAN-M7e §5 and D-128 (the line chart in month and week, an empty range), × light/dark × sv/en (plus 200 % for long content). */
@RunWith(ParameterizedRobolectricTestRunner::class) @RunWith(ParameterizedRobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE) @GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [36], qualifiers = ScreenSnapshots.QUALIFIERS) @Config(sdk = [36], qualifiers = ScreenSnapshots.QUALIFIERS)
@@ -38,26 +44,32 @@ class DrillDownScreenshotTest(private val name: String) {
private fun ui(scenario: FakeScenario, args: DrillDownArgs): DrillDownUiState = runBlocking { private fun ui(scenario: FakeScenario, args: DrillDownArgs): DrillDownUiState = runBlocking {
val fakes = FakeRepositories(scenario) val fakes = FakeRepositories(scenario)
val accounts = fakes.accounts.all().first() drillDownUiState(
val range = fakes.analytics.analytics(args.filter).first()
val index = accounts.indexOfFirst { it.id == args.accountId }
DrillDownUiState(
args = args, args = args,
filter = args.filter,
dialog = PeriodDialog.None,
data = fakes.analytics.drillDown(args.filter, args.bucketKey, args.categoryId, args.accountId).first(), data = fakes.analytics.drillDown(args.filter, args.bucketKey, args.categoryId, args.accountId).first(),
start = range.start, range = fakes.analytics.analytics(AnalyticsRules.seriesFilter(args, args.filter)).first(),
end = range.end, categories = fakes.categories.all().first(),
category = fakes.categories.all().first().firstOrNull { it.id == args.categoryId }, accounts = fakes.accounts.all().first(),
account = accounts.getOrNull(index),
accountIndex = index.coerceAtLeast(0),
) )
} }
private fun screen(state: DrillDownUiState): @Composable () -> Unit = { DrillDownScreen(state, {}, {}) } private fun screen(state: DrillDownUiState): @Composable () -> Unit = { DrillDownScreen(state, NoEvents, {}, {}) }
/** S-12's own week range (D-128): S-11's custom weeks, the viewer's share, a category over the range. */
private val weekRange = AnalyticsScenarios.weekFilter.copy(groupBy = GroupBy.CATEGORY)
/** Three future months without expenses: three empty buckets, the 0 baseline only (D-068). */
private val emptyRange = AnalyticsFilter(RangePreset.CUSTOM, "2026-11-01", "2027-01-31", Granularity.MONTH, Measure.HOUSEHOLD, GroupBy.CATEGORY)
val states: Map<String, @Composable () -> Unit> = mapOf( val states: Map<String, @Composable () -> Unit> = mapOf(
"s12_category_in_bucket" to screen(ui(AnalyticsScenarios.loaded, DrillDownArgs(defaults, "2026-10", AnalyticsScenarios.groceries.id, null))), "s12_category_in_bucket" to screen(ui(AnalyticsScenarios.loaded, DrillDownArgs(defaults, "2026-10", AnalyticsScenarios.groceries.id, null))),
"s12_bucket" to screen(ui(AnalyticsScenarios.loaded, DrillDownArgs(defaults, "2026-10", null, null))), "s12_bucket" to screen(ui(AnalyticsScenarios.loaded, DrillDownArgs(defaults, "2026-10", null, null))),
"s12_category" to screen(ui(AnalyticsScenarios.loaded, DrillDownArgs(defaults, null, AnalyticsScenarios.travel.id, null))), "s12_category" to screen(ui(AnalyticsScenarios.loaded, DrillDownArgs(defaults, null, AnalyticsScenarios.travel.id, null))),
"s12_category_groceries" to screen(ui(AnalyticsScenarios.loaded, DrillDownArgs(defaults, null, AnalyticsScenarios.groceries.id, null))),
"s12_category_week" to screen(ui(AnalyticsScenarios.loaded, DrillDownArgs(weekRange, null, AnalyticsScenarios.groceries.id, null))),
"s12_category_empty_range" to screen(ui(AnalyticsScenarios.loaded, DrillDownArgs(emptyRange, null, AnalyticsScenarios.groceries.id, null))),
"s12_account" to screen(ui(AnalyticsScenarios.loaded, DrillDownArgs(defaults, null, null, AnalyticsScenarios.japan.id))), "s12_account" to screen(ui(AnalyticsScenarios.loaded, DrillDownArgs(defaults, null, null, AnalyticsScenarios.japan.id))),
"s12_account_week" to screen(ui(AnalyticsScenarios.loaded, DrillDownArgs(AnalyticsScenarios.weekFilter, "2026-W41", null, AnalyticsScenarios.vardag.id))), "s12_account_week" to screen(ui(AnalyticsScenarios.loaded, DrillDownArgs(AnalyticsScenarios.weekFilter, "2026-W41", null, AnalyticsScenarios.vardag.id))),
"s12_empty" to screen(ui(AnalyticsScenarios.loaded, DrillDownArgs(AnalyticsScenarios.emptyFilter, "2026-11", AnalyticsScenarios.groceries.id, null))), "s12_empty" to screen(ui(AnalyticsScenarios.loaded, DrillDownArgs(AnalyticsScenarios.emptyFilter, "2026-11", AnalyticsScenarios.groceries.id, null))),
Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 56 KiB