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