diff --git a/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/chart/Charts.kt b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/chart/Charts.kt index 0c4ea35..f7e0101 100644 --- a/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/chart/Charts.kt +++ b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/chart/Charts.kt @@ -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, 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, 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() } } } diff --git a/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/chart/LineChart.kt b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/chart/LineChart.kt new file mode 100644 index 0000000..7656afa --- /dev/null +++ b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/chart/LineChart.kt @@ -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, + color: Color, + summary: String, + modifier: Modifier = Modifier, + averageOre: Long? = null, + movingAverage: List = 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, ys: List, 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, ys: List, 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, 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) + } + } +} diff --git a/android/core/design/src/test/kotlin/se/hagstedt/kvitt/design/chart/LineChartScreenshotTest.kt b/android/core/design/src/test/kotlin/se/hagstedt/kvitt/design/chart/LineChartScreenshotTest.kt new file mode 100644 index 0000000..18fbec8 --- /dev/null +++ b/android/core/design/src/test/kotlin/se/hagstedt/kvitt/design/chart/LineChartScreenshotTest.kt @@ -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 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> = sheets.keys.map { arrayOf(it) } + } +} diff --git a/android/core/design/src/test/screenshots/line_chart_en_light.png b/android/core/design/src/test/screenshots/line_chart_en_light.png new file mode 100644 index 0000000..1c47560 Binary files /dev/null and b/android/core/design/src/test/screenshots/line_chart_en_light.png differ diff --git a/android/core/design/src/test/screenshots/line_chart_sv_dark.png b/android/core/design/src/test/screenshots/line_chart_sv_dark.png new file mode 100644 index 0000000..b3cbab8 Binary files /dev/null and b/android/core/design/src/test/screenshots/line_chart_sv_dark.png differ diff --git a/android/core/design/src/test/screenshots/line_chart_sv_light.png b/android/core/design/src/test/screenshots/line_chart_sv_light.png new file mode 100644 index 0000000..56b8dc7 Binary files /dev/null and b/android/core/design/src/test/screenshots/line_chart_sv_light.png differ diff --git a/android/core/design/src/test/screenshots/line_chart_variants_en_light.png b/android/core/design/src/test/screenshots/line_chart_variants_en_light.png new file mode 100644 index 0000000..586e478 Binary files /dev/null and b/android/core/design/src/test/screenshots/line_chart_variants_en_light.png differ diff --git a/android/core/design/src/test/screenshots/line_chart_variants_sv_dark.png b/android/core/design/src/test/screenshots/line_chart_variants_sv_dark.png new file mode 100644 index 0000000..ec23770 Binary files /dev/null and b/android/core/design/src/test/screenshots/line_chart_variants_sv_dark.png differ diff --git a/android/core/design/src/test/screenshots/line_chart_variants_sv_light.png b/android/core/design/src/test/screenshots/line_chart_variants_sv_light.png new file mode 100644 index 0000000..ca8f469 Binary files /dev/null and b/android/core/design/src/test/screenshots/line_chart_variants_sv_light.png differ diff --git a/android/core/design/src/test/screenshots/line_chart_variants_sv_light_200.png b/android/core/design/src/test/screenshots/line_chart_variants_sv_light_200.png new file mode 100644 index 0000000..ec1dcbd Binary files /dev/null and b/android/core/design/src/test/screenshots/line_chart_variants_sv_light_200.png differ diff --git a/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/AnalyticsModel.kt b/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/AnalyticsModel.kt index 470c2b8..dd1e247 100644 --- a/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/AnalyticsModel.kt +++ b/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/AnalyticsModel.kt @@ -13,6 +13,8 @@ import se.hagstedt.kvitt.model.GroupBy import se.hagstedt.kvitt.model.Measure import se.hagstedt.kvitt.model.RangePreset 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. */ 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). */ 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). */ -interface AnalyticsEvents { +/** The period and granularity events S-11 and S-12 share (D-128: S-12 has its own range and granularity). */ +interface PeriodEvents { fun onPeriod() 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). */ fun onCustomRange(start: String?, end: String?) 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 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 * 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( val args: DrillDownArgs, @@ -108,8 +118,19 @@ data class DrillDownUiState( val category: Category?, val account: Account?, val accountIndex: Int, + val filter: AnalyticsFilter = args.filter, + val dialog: PeriodDialog = PeriodDialog.None, + val series: List? = 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. */ object AnalyticsRules { /** 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). */ 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 = + 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 = 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): 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, window: Int = MOVING_AVERAGE_WINDOW): List = 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): 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. */ fun millis(isoDate: String): Long = LocalDate.parse(isoDate).toEpochDays() * MILLIS_PER_DAY diff --git a/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/AnalyticsScreen.kt b/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/AnalyticsScreen.kt index 9714704..6b6993f 100644 --- a/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/AnalyticsScreen.kt +++ b/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/AnalyticsScreen.kt @@ -49,6 +49,7 @@ import se.hagstedt.kvitt.design.icon.KvittIcon import se.hagstedt.kvitt.design.theme.KvittDimens import se.hagstedt.kvitt.model.Account import se.hagstedt.kvitt.model.AccountId +import se.hagstedt.kvitt.model.AnalyticsFilter import se.hagstedt.kvitt.model.AnalyticsState import se.hagstedt.kvitt.model.Category import se.hagstedt.kvitt.model.CategoryId @@ -127,11 +128,7 @@ private fun Filters(state: AnalyticsUiState, events: AnalyticsEvents) { period = period, periodSpoken = stringResource(R.string.analytics_period_spoken, period), onPeriod = events::onPeriod, - granularity = 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]) }, + granularity = granularityToggle(filter, events), measure = FilterToggle( stringResource(R.string.analytics_measure), 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. */ @Composable private fun totalCaption(data: AnalyticsState): String { diff --git a/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/DrillDownScreen.kt b/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/DrillDownScreen.kt index 3896e77..5ba9dc8 100644 --- a/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/DrillDownScreen.kt +++ b/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/DrillDownScreen.kt @@ -31,6 +31,13 @@ import androidx.compose.ui.text.style.TextAlign import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.AmountStyle 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.icon.KvittIcon import se.hagstedt.kvitt.design.theme.KvittDimens +import se.hagstedt.kvitt.model.AnalyticsFilter 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 /** @@ -65,13 +75,18 @@ fun DrillDownScreen( viewModel: DrillDownViewModel = hiltViewModel(), ) { 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 fun DrillDownScreen( state: DrillDownUiState, + events: PeriodEvents, onOpenExpense: (ExpenseId) -> Unit, onBack: () -> Unit, modifier: Modifier = Modifier, @@ -94,10 +109,19 @@ fun DrillDownScreen( verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.detailGap), ) { 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) + if (series != null) OverTimeLine(state, series) if (data.rows.isEmpty()) { 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), style = MaterialTheme.typography.bodyLarge, 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) { + 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, 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 internal fun drillDownTitle(state: DrillDownUiState): String { - val args = state.args val name = state.category?.let { categoryName(it) } ?: state.account?.name - val bucket = args.bucketKey + val bucket = state.args.bucketKey val start = state.start val end = state.end return when { name != null && bucket != null -> stringResource(R.string.analytics_drilldown_title, name, bucketName(bucket, capitalized = false)) 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 bucket != null -> bucketName(bucket, capitalized = true) else -> "" diff --git a/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/DrillDownViewModel.kt b/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/DrillDownViewModel.kt index 88ab6f8..65564e1 100644 --- a/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/DrillDownViewModel.kt +++ b/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/DrillDownViewModel.kt @@ -4,26 +4,42 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest 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.AnalyticsFilter 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.DrillDownState +import se.hagstedt.kvitt.model.Granularity +import se.hagstedt.kvitt.model.RangePreset import javax.inject.Inject /** * 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). + * + * 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 class DrillDownViewModel( - args: DrillDownArgs, + private val args: DrillDownArgs, analytics: AnalyticsRepository, categories: CategoryRepository, accounts: AccountRepository, -) : ViewModel() { +) : ViewModel(), PeriodEvents { @Inject constructor( savedStateHandle: SavedStateHandle, @@ -32,25 +48,71 @@ class DrillDownViewModel( accounts: AccountRepository, ) : this(DrillDownArgs.fromArguments { savedStateHandle.get(it) }, analytics, categories, accounts) - val state: StateFlow = combine( - analytics.drillDown(args.filter, args.bucketKey, args.categoryId, args.accountId), - analytics.analytics(args.filter), - categories.all(), - accounts.all(), - ) { data, range, cats, accs -> - val index = accs.indexOfFirst { it.id == args.accountId } - DrillDownUiState( - args = args, - data = data, - start = range.start, - end = range.end, - category = cats.firstOrNull { it.id == args.categoryId }, - account = accs.getOrNull(index), - accountIndex = index.coerceAtLeast(0), - ) + private val filter = MutableStateFlow(args.filter) + private val dialog = MutableStateFlow(PeriodDialog.None) + + private val queried = filter.flatMapLatest { f -> + combine( + analytics.drillDown(f, args.bucketKey, args.categoryId, args.accountId), + analytics.analytics(AnalyticsRules.seriesFilter(args, f)), + ) { data, range -> Triple(f, data, range) } + } + + val state: StateFlow = combine(queried, dialog, categories.all(), accounts.all()) { (f, data, range), d, cats, accs -> + drillDownUiState(args, f, d, data, range, cats, accs) }.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 { 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, + accounts: List, +): 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, + ) +} diff --git a/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/PeriodPicker.kt b/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/PeriodPicker.kt index 1671481..f9fa736 100644 --- a/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/PeriodPicker.kt +++ b/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/PeriodPicker.kt @@ -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…"). */ @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() } Popup(alignment = Alignment.TopStart, offset = IntOffset(0, below), onDismissRequest = events::onDismissPeriod, properties = PopupProperties(focusable = true)) { PeriodMenu(selected, events::onPreset) @@ -52,7 +52,7 @@ internal fun PeriodMenu(selected: RangePreset, onPreset: (RangePreset) -> Unit, } @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)) { CustomRangePicker(start, end, events::onCustomRange, events::onDismissPeriod) } diff --git a/android/feature/analytics/src/main/res/values-sv/strings.xml b/android/feature/analytics/src/main/res/values-sv/strings.xml index bca5a92..a012196 100644 --- a/android/feature/analytics/src/main/res/values-sv/strings.xml +++ b/android/feature/analytics/src/main/res/values-sv/strings.xml @@ -43,4 +43,11 @@ Mått Gruppera efter Använd + Snitt + Glidande snitt (%1$d) + %1$s: %2$s över snittet + %1$s: %2$s under snittet + %1$s: på snittet + %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. + %1$s över tid, %2$s: totalt %3$s. diff --git a/android/feature/analytics/src/main/res/values/strings.xml b/android/feature/analytics/src/main/res/values/strings.xml index fbcb492..302071a 100644 --- a/android/feature/analytics/src/main/res/values/strings.xml +++ b/android/feature/analytics/src/main/res/values/strings.xml @@ -43,4 +43,11 @@ Measure Group by Apply + Average + Moving average (%1$d) + %1$s: %2$s above average + %1$s: %2$s below average + %1$s: on average + %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. + %1$s over time, %2$s: %3$s in total. diff --git a/android/feature/analytics/src/test/kotlin/se/hagstedt/kvitt/feature/analytics/AverageRulesTest.kt b/android/feature/analytics/src/test/kotlin/se/hagstedt/kvitt/feature/analytics/AverageRulesTest.kt new file mode 100644 index 0000000..5d2b9d9 --- /dev/null +++ b/android/feature/analytics/src/test/kotlin/se/hagstedt/kvitt/feature/analytics/AverageRulesTest.kt @@ -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(), 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))) + } +} diff --git a/android/feature/analytics/src/test/kotlin/se/hagstedt/kvitt/feature/analytics/DrillDownViewModelTest.kt b/android/feature/analytics/src/test/kotlin/se/hagstedt/kvitt/feature/analytics/DrillDownViewModelTest.kt index 73bcca3..a0d2319 100644 --- a/android/feature/analytics/src/test/kotlin/se/hagstedt/kvitt/feature/analytics/DrillDownViewModelTest.kt +++ b/android/feature/analytics/src/test/kotlin/se/hagstedt/kvitt/feature/analytics/DrillDownViewModelTest.kt @@ -4,11 +4,14 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Rule import org.junit.Test import se.hagstedt.kvitt.fakes.FakeRepositories import se.hagstedt.kvitt.fakes.analytics.AnalyticsScenarios import se.hagstedt.kvitt.design.testing.MainDispatcherRule +import se.hagstedt.kvitt.model.Granularity +import se.hagstedt.kvitt.model.RangePreset @OptIn(ExperimentalCoroutinesApi::class) class DrillDownViewModelTest { @@ -39,6 +42,80 @@ class DrillDownViewModelTest { 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 fun bucketAloneListsEveryGroup() = runTest { val state = load(DrillDownArgs(AnalyticsRules.defaultFilter, "2026-10", null, null)) diff --git a/android/feature/analytics/src/test/kotlin/se/hagstedt/kvitt/feature/analytics/screenshot/DrillDownScreenshotTest.kt b/android/feature/analytics/src/test/kotlin/se/hagstedt/kvitt/feature/analytics/screenshot/DrillDownScreenshotTest.kt index 70374eb..cb37b7d 100644 --- a/android/feature/analytics/src/test/kotlin/se/hagstedt/kvitt/feature/analytics/screenshot/DrillDownScreenshotTest.kt +++ b/android/feature/analytics/src/test/kotlin/se/hagstedt/kvitt/feature/analytics/screenshot/DrillDownScreenshotTest.kt @@ -17,10 +17,16 @@ import se.hagstedt.kvitt.feature.analytics.AnalyticsRules import se.hagstedt.kvitt.feature.analytics.DrillDownArgs import se.hagstedt.kvitt.feature.analytics.DrillDownScreen 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.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) @GraphicsMode(GraphicsMode.Mode.NATIVE) @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 { val fakes = FakeRepositories(scenario) - val accounts = fakes.accounts.all().first() - val range = fakes.analytics.analytics(args.filter).first() - val index = accounts.indexOfFirst { it.id == args.accountId } - DrillDownUiState( + drillDownUiState( args = args, + filter = args.filter, + dialog = PeriodDialog.None, data = fakes.analytics.drillDown(args.filter, args.bucketKey, args.categoryId, args.accountId).first(), - start = range.start, - end = range.end, - category = fakes.categories.all().first().firstOrNull { it.id == args.categoryId }, - account = accounts.getOrNull(index), - accountIndex = index.coerceAtLeast(0), + range = fakes.analytics.analytics(AnalyticsRules.seriesFilter(args, args.filter)).first(), + categories = fakes.categories.all().first(), + accounts = fakes.accounts.all().first(), ) } - 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 Unit> = mapOf( "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_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_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))), diff --git a/android/feature/analytics/src/test/screenshots/s12_account_en_light.png b/android/feature/analytics/src/test/screenshots/s12_account_en_light.png index ea9fc35..da94a8a 100644 Binary files a/android/feature/analytics/src/test/screenshots/s12_account_en_light.png and b/android/feature/analytics/src/test/screenshots/s12_account_en_light.png differ diff --git a/android/feature/analytics/src/test/screenshots/s12_account_sv_dark.png b/android/feature/analytics/src/test/screenshots/s12_account_sv_dark.png index 1353049..bf85c7b 100644 Binary files a/android/feature/analytics/src/test/screenshots/s12_account_sv_dark.png and b/android/feature/analytics/src/test/screenshots/s12_account_sv_dark.png differ diff --git a/android/feature/analytics/src/test/screenshots/s12_account_sv_light.png b/android/feature/analytics/src/test/screenshots/s12_account_sv_light.png index f8fd125..890c0bf 100644 Binary files a/android/feature/analytics/src/test/screenshots/s12_account_sv_light.png and b/android/feature/analytics/src/test/screenshots/s12_account_sv_light.png differ diff --git a/android/feature/analytics/src/test/screenshots/s12_category_empty_range_en_light.png b/android/feature/analytics/src/test/screenshots/s12_category_empty_range_en_light.png new file mode 100644 index 0000000..b5e5e17 Binary files /dev/null and b/android/feature/analytics/src/test/screenshots/s12_category_empty_range_en_light.png differ diff --git a/android/feature/analytics/src/test/screenshots/s12_category_empty_range_sv_dark.png b/android/feature/analytics/src/test/screenshots/s12_category_empty_range_sv_dark.png new file mode 100644 index 0000000..d741ac5 Binary files /dev/null and b/android/feature/analytics/src/test/screenshots/s12_category_empty_range_sv_dark.png differ diff --git a/android/feature/analytics/src/test/screenshots/s12_category_empty_range_sv_light.png b/android/feature/analytics/src/test/screenshots/s12_category_empty_range_sv_light.png new file mode 100644 index 0000000..58c8ccd Binary files /dev/null and b/android/feature/analytics/src/test/screenshots/s12_category_empty_range_sv_light.png differ diff --git a/android/feature/analytics/src/test/screenshots/s12_category_en_light.png b/android/feature/analytics/src/test/screenshots/s12_category_en_light.png index 5a7fbdb..efd0a90 100644 Binary files a/android/feature/analytics/src/test/screenshots/s12_category_en_light.png and b/android/feature/analytics/src/test/screenshots/s12_category_en_light.png differ diff --git a/android/feature/analytics/src/test/screenshots/s12_category_groceries_en_light.png b/android/feature/analytics/src/test/screenshots/s12_category_groceries_en_light.png new file mode 100644 index 0000000..a3fa3f1 Binary files /dev/null and b/android/feature/analytics/src/test/screenshots/s12_category_groceries_en_light.png differ diff --git a/android/feature/analytics/src/test/screenshots/s12_category_groceries_sv_dark.png b/android/feature/analytics/src/test/screenshots/s12_category_groceries_sv_dark.png new file mode 100644 index 0000000..84e1512 Binary files /dev/null and b/android/feature/analytics/src/test/screenshots/s12_category_groceries_sv_dark.png differ diff --git a/android/feature/analytics/src/test/screenshots/s12_category_groceries_sv_light.png b/android/feature/analytics/src/test/screenshots/s12_category_groceries_sv_light.png new file mode 100644 index 0000000..9b09898 Binary files /dev/null and b/android/feature/analytics/src/test/screenshots/s12_category_groceries_sv_light.png differ diff --git a/android/feature/analytics/src/test/screenshots/s12_category_sv_dark.png b/android/feature/analytics/src/test/screenshots/s12_category_sv_dark.png index 3b1d9e5..2b7b6c4 100644 Binary files a/android/feature/analytics/src/test/screenshots/s12_category_sv_dark.png and b/android/feature/analytics/src/test/screenshots/s12_category_sv_dark.png differ diff --git a/android/feature/analytics/src/test/screenshots/s12_category_sv_light.png b/android/feature/analytics/src/test/screenshots/s12_category_sv_light.png index 87c85ee..f1d593a 100644 Binary files a/android/feature/analytics/src/test/screenshots/s12_category_sv_light.png and b/android/feature/analytics/src/test/screenshots/s12_category_sv_light.png differ diff --git a/android/feature/analytics/src/test/screenshots/s12_category_week_en_light.png b/android/feature/analytics/src/test/screenshots/s12_category_week_en_light.png new file mode 100644 index 0000000..27c6aa5 Binary files /dev/null and b/android/feature/analytics/src/test/screenshots/s12_category_week_en_light.png differ diff --git a/android/feature/analytics/src/test/screenshots/s12_category_week_sv_dark.png b/android/feature/analytics/src/test/screenshots/s12_category_week_sv_dark.png new file mode 100644 index 0000000..7405840 Binary files /dev/null and b/android/feature/analytics/src/test/screenshots/s12_category_week_sv_dark.png differ diff --git a/android/feature/analytics/src/test/screenshots/s12_category_week_sv_light.png b/android/feature/analytics/src/test/screenshots/s12_category_week_sv_light.png new file mode 100644 index 0000000..27d9513 Binary files /dev/null and b/android/feature/analytics/src/test/screenshots/s12_category_week_sv_light.png differ diff --git a/android/feature/analytics/src/test/screenshots/s12_long_en_light.png b/android/feature/analytics/src/test/screenshots/s12_long_en_light.png index 1d8cc75..f735ed4 100644 Binary files a/android/feature/analytics/src/test/screenshots/s12_long_en_light.png and b/android/feature/analytics/src/test/screenshots/s12_long_en_light.png differ diff --git a/android/feature/analytics/src/test/screenshots/s12_long_sv_dark.png b/android/feature/analytics/src/test/screenshots/s12_long_sv_dark.png index bd0e10b..29fb19f 100644 Binary files a/android/feature/analytics/src/test/screenshots/s12_long_sv_dark.png and b/android/feature/analytics/src/test/screenshots/s12_long_sv_dark.png differ diff --git a/android/feature/analytics/src/test/screenshots/s12_long_sv_light.png b/android/feature/analytics/src/test/screenshots/s12_long_sv_light.png index ced59cf..67416f8 100644 Binary files a/android/feature/analytics/src/test/screenshots/s12_long_sv_light.png and b/android/feature/analytics/src/test/screenshots/s12_long_sv_light.png differ diff --git a/android/feature/analytics/src/test/screenshots/s12_long_sv_light_200.png b/android/feature/analytics/src/test/screenshots/s12_long_sv_light_200.png index e3943b8..4f3ede2 100644 Binary files a/android/feature/analytics/src/test/screenshots/s12_long_sv_light_200.png and b/android/feature/analytics/src/test/screenshots/s12_long_sv_light_200.png differ