M7e: feature analytics (S-11, S-12) with fake scenarios and screenshot tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
2026-10-18 10:41:03 +02:00
parent 1ea752cd80
commit ea7d494ffa
18 changed files with 1738 additions and 15 deletions
+17 -1
View File
@@ -1,4 +1,20 @@
// :feature:analytics — Analytics and drill-down (S-11, S-12; M7e). Convention: android/README.md.
// :feature:analytics — Analytics (S-11) and drill-down (S-12), M7e. Convention: android/README.md. :core:model,
// :core:design and (tests and debug only) :core:fakes come from the kvitt.android.feature plugin.
plugins {
id("kvitt.android.feature")
alias(libs.plugins.roborazzi)
}
roborazzi {
outputDir.set(file("src/test/screenshots"))
}
dependencies {
debugImplementation(libs.androidx.compose.ui.test.manifest)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.androidx.compose.ui.test.junit4)
testImplementation(libs.robolectric)
testImplementation(libs.roborazzi)
testImplementation(libs.roborazzi.compose)
testImplementation(libs.roborazzi.junit.rule)
}
@@ -1,14 +0,0 @@
package se.hagstedt.kvitt.feature.analytics
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
/**
* Module shell from M2b: the feature's entry point. The owning milestone replaces it with one composable per screen
* that takes its Hilt ViewModel (`hiltViewModel()`) by default and navigation as plain lambdas (android/README.md).
*/
@Composable
fun AnalyticsEntry(modifier: Modifier = Modifier) {
Box(modifier)
}
@@ -0,0 +1,144 @@
package se.hagstedt.kvitt.feature.analytics
import kotlinx.datetime.LocalDate
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
import se.hagstedt.kvitt.model.DrillDownState
import se.hagstedt.kvitt.model.Granularity
import se.hagstedt.kvitt.model.GroupBy
import se.hagstedt.kvitt.model.Measure
import se.hagstedt.kvitt.model.RangePreset
import se.hagstedt.kvitt.model.SyncSummary
/** Which period control is open on S-11: none, the preset menu, or the custom range picker. */
enum class PeriodDialog { None, Menu, CustomPicker }
/** What S-11 shows below the filters. */
sealed interface AnalyticsContent {
/** The first sync has not finished (SPEC §13.3 "loading, first sync only"): skeleton cards. */
data object Loading : AnalyticsContent
/**
* The repository's [data] with the categories (default names, colours, sort order) and the accounts in
* `AccountRepository.all()` order (chart colour by position, archived badge; design-spec §2.4).
*/
data class Ready(val data: AnalyticsState, val categories: List<Category>, val accounts: List<Account>) : 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 {
fun onPeriod()
fun onDismissPeriod()
/** A preset from the menu; [RangePreset.CUSTOM] opens the range picker. */
fun onPreset(preset: RangePreset)
/** 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)
fun onMeasure(measure: Measure)
fun onGroupBy(groupBy: GroupBy)
}
/**
* The arguments of a drill-down (S-12, AC-14.8): the S-11 filter and at most one of a bucket, a category or an account
* (a bar gives the bucket, a breakdown row the category or account). [toArguments] and [fromArguments] are the
* string form `:app`'s navigation route carries (keys [KEYS]); `DrillDownViewModel` reads them from its SavedStateHandle.
*/
data class DrillDownArgs(val filter: AnalyticsFilter, val bucketKey: String?, val categoryId: CategoryId?, val accountId: AccountId?) {
fun toArguments(): Map<String, String> = buildMap {
put(KEY_PRESET, filter.preset.name.lowercase())
filter.customStart?.let { put(KEY_CUSTOM_START, it) }
filter.customEnd?.let { put(KEY_CUSTOM_END, it) }
put(KEY_GRANULARITY, filter.granularity.name.lowercase())
put(KEY_MEASURE, filter.measure.name.lowercase())
put(KEY_GROUP_BY, filter.groupBy.name.lowercase())
bucketKey?.let { put(KEY_BUCKET, it) }
categoryId?.let { put(KEY_CATEGORY, it.value) }
accountId?.let { put(KEY_ACCOUNT, it.value) }
}
companion object {
const val KEY_PRESET = "preset"
const val KEY_CUSTOM_START = "customStart"
const val KEY_CUSTOM_END = "customEnd"
const val KEY_GRANULARITY = "granularity"
const val KEY_MEASURE = "measure"
const val KEY_GROUP_BY = "groupBy"
const val KEY_BUCKET = "bucket"
const val KEY_CATEGORY = "category"
const val KEY_ACCOUNT = "account"
val KEYS = listOf(KEY_PRESET, KEY_CUSTOM_START, KEY_CUSTOM_END, KEY_GRANULARITY, KEY_MEASURE, KEY_GROUP_BY, KEY_BUCKET, KEY_CATEGORY, KEY_ACCOUNT)
/** Unknown or missing filter values fall back to the S-11 defaults (AC-14.2 to AC-14.5). */
fun fromArguments(get: (String) -> String?): DrillDownArgs {
val defaults = AnalyticsRules.defaultFilter
fun <E : Enum<E>> read(key: String, values: Array<E>, default: E): E =
get(key)?.let { v -> values.firstOrNull { it.name.equals(v, ignoreCase = true) } } ?: default
val filter = AnalyticsFilter(
preset = read(KEY_PRESET, RangePreset.entries.toTypedArray(), defaults.preset),
customStart = get(KEY_CUSTOM_START),
customEnd = get(KEY_CUSTOM_END),
granularity = read(KEY_GRANULARITY, Granularity.entries.toTypedArray(), defaults.granularity),
measure = read(KEY_MEASURE, Measure.entries.toTypedArray(), defaults.measure),
groupBy = read(KEY_GROUP_BY, GroupBy.entries.toTypedArray(), defaults.groupBy),
)
return DrillDownArgs(filter, get(KEY_BUCKET), get(KEY_CATEGORY)?.let(::CategoryId), get(KEY_ACCOUNT)?.let(::AccountId))
}
}
}
/**
* S-12 state: the [args], the repository's [data] (null until it emits), the range the 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].
*/
data class DrillDownUiState(
val args: DrillDownArgs,
val data: DrillDownState?,
val start: String?,
val end: String?,
val category: Category?,
val account: Account?,
val accountIndex: Int,
)
/** 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. */
val defaultFilter = AnalyticsFilter(RangePreset.LAST_6_MONTHS, null, null, Granularity.MONTH, Measure.HOUSEHOLD, GroupBy.CATEGORY)
private const val MILLIS_PER_DAY = 86_400_000L
/** A preset other than custom drops the custom dates; custom keeps them (the picker sets them). */
fun withPreset(filter: AnalyticsFilter, preset: RangePreset): AnalyticsFilter =
if (preset == RangePreset.CUSTOM) filter.copy(preset = preset) else filter.copy(preset = preset, customStart = null, customEnd = null)
/** "Använd" is enabled only with both dates (design-spec S-11, §14.2.4). */
fun canApply(start: Long?, end: Long?): Boolean = start != null && end != null
/** A custom range from the picker, start before end; null when a date is missing, so nothing is applied. */
fun withCustomRange(filter: AnalyticsFilter, start: String?, end: String?): AnalyticsFilter? {
if (start == null || end == null) return null
val (first, last) = if (start <= end) start to end else end to start
return filter.copy(preset = RangePreset.CUSTOM, customStart = first, customEnd = last)
}
/** No spend in the range for the measure: the empty state replaces the charts (S-11-analytics-empty). */
fun isEmpty(state: AnalyticsState): Boolean = state.totalOre == 0L && state.byCategory.isEmpty() && state.byAccount.isEmpty()
/** Nothing has been synced yet: the first sync is the only loading state (SPEC §13.3). */
fun isFirstSync(summary: SyncSummary): Boolean = summary.lastSuccessAt == null
/** The date picker works in UTC midnight milliseconds. */
fun millis(isoDate: String): Long = LocalDate.parse(isoDate).toEpochDays() * MILLIS_PER_DAY
fun isoDate(millis: Long): String = LocalDate.fromEpochDays(Math.floorDiv(millis, MILLIS_PER_DAY)).toString()
}
@@ -0,0 +1,305 @@
package se.hagstedt.kvitt.feature.analytics
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
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.BreakdownList
import se.hagstedt.kvitt.design.chart.BreakdownRow
import se.hagstedt.kvitt.design.chart.ChartCard
import se.hagstedt.kvitt.design.chart.ChartGroup
import se.hagstedt.kvitt.design.chart.ChartLegend
import se.hagstedt.kvitt.design.chart.FilterToggle
import se.hagstedt.kvitt.design.chart.StackedBarChart
import se.hagstedt.kvitt.design.chart.TotalCard
import se.hagstedt.kvitt.design.component.AccountTile
import se.hagstedt.kvitt.design.component.ButtonKind
import se.hagstedt.kvitt.design.component.CategoryTile
import se.hagstedt.kvitt.design.component.EmptyState
import se.hagstedt.kvitt.design.component.KvittCard
import se.hagstedt.kvitt.design.component.KvittDestination
import se.hagstedt.kvitt.design.component.KvittNavigationBar
import se.hagstedt.kvitt.design.component.KvittScaffold
import se.hagstedt.kvitt.design.component.KvittTopAppBar
import se.hagstedt.kvitt.design.component.LocalKvittClock
import se.hagstedt.kvitt.design.component.Skeleton
import se.hagstedt.kvitt.design.component.categoryName
import se.hagstedt.kvitt.design.component.kvittLanguage
import se.hagstedt.kvitt.design.component.spokenMoney
import se.hagstedt.kvitt.design.format.MoneyFormatter
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.AnalyticsState
import se.hagstedt.kvitt.model.Category
import se.hagstedt.kvitt.model.CategoryId
import se.hagstedt.kvitt.model.ColorToken
import se.hagstedt.kvitt.model.DefaultCategoryKey
import se.hagstedt.kvitt.model.Granularity
import se.hagstedt.kvitt.model.GroupBy
import se.hagstedt.kvitt.model.GroupKey
import se.hagstedt.kvitt.model.Measure
import se.hagstedt.kvitt.core.design.R as DesignR
/**
* S-11 Analytics (design-spec S-11, FR-14). [onOpenDrillDown] opens S-12 for a bar or a breakdown row (AC-14.8);
* [onNavigate] is the bottom navigation (Statistik selected).
*/
@Composable
fun AnalyticsScreen(
onOpenDrillDown: (DrillDownArgs) -> Unit,
onNavigate: (KvittDestination) -> Unit,
modifier: Modifier = Modifier,
viewModel: AnalyticsViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
AnalyticsScreen(state, viewModel, onOpenDrillDown, onNavigate, modifier)
}
/** The stateless S-11 that previews and screenshot tests render. */
@Composable
fun AnalyticsScreen(
state: AnalyticsUiState,
events: AnalyticsEvents,
onOpenDrillDown: (DrillDownArgs) -> Unit,
onNavigate: (KvittDestination) -> Unit,
modifier: Modifier = Modifier,
scrollState: ScrollState = rememberScrollState(),
) {
val scrolled by remember(scrollState) { derivedStateOf { scrollState.value > 0 } }
KvittScaffold(
modifier.fillMaxSize(),
topBar = { KvittTopAppBar(stringResource(R.string.analytics_title), scrolled = scrolled) },
bottomBar = { KvittNavigationBar(KvittDestination.Analytics, onNavigate) },
) { padding ->
Column(
Modifier
.padding(padding)
.fillMaxSize()
.verticalScroll(scrollState)
.padding(horizontal = KvittTheme.spacing.gutter, vertical = KvittTheme.spacing.s2),
verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.detailGap),
) {
Box {
Filters(state, events)
if (state.dialog == PeriodDialog.Menu) PeriodMenuPopup(state.filter.preset, events)
}
when (val content = state.content) {
null -> Unit
AnalyticsContent.Loading -> LoadingCards()
is AnalyticsContent.Ready -> Ready(content, events) { bucket, category, account ->
onOpenDrillDown(DrillDownArgs(content.data.filter, bucket, category, account))
}
}
}
}
if (state.dialog == PeriodDialog.CustomPicker) {
val data = (state.content as? AnalyticsContent.Ready)?.data
CustomRangeDialog(state.filter.customStart ?: data?.start, state.filter.customEnd ?: data?.end, events)
}
}
@Composable
private fun Filters(state: AnalyticsUiState, events: AnalyticsEvents) {
val filter = state.filter
val period = periodLabel(filter)
AnalyticsFilterBar(
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]) },
measure = FilterToggle(
stringResource(R.string.analytics_measure),
listOf(stringResource(R.string.analytics_measure_household), stringResource(R.string.analytics_measure_my_share)),
Measure.entries.indexOf(filter.measure),
) { events.onMeasure(Measure.entries[it]) },
groupBy = FilterToggle(
stringResource(R.string.analytics_group_by),
listOf(stringResource(R.string.analytics_group_by_category), stringResource(R.string.analytics_group_by_account)),
GroupBy.entries.indexOf(filter.groupBy),
) { events.onGroupBy(GroupBy.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 {
val range = rangeText(data.start, data.end, data.filter, LocalKvittClock.current.today, kvittLanguage())
val second = when {
data.filter.granularity == Granularity.WEEK -> pluralStringResource(R.plurals.analytics_weeks, data.buckets.size, data.buckets.size)
data.filter.measure == Measure.HOUSEHOLD -> stringResource(R.string.analytics_measure_household)
else -> null
}
return listOfNotNull(range, second).joinToString(stringResource(DesignR.string.common_separator_dot))
}
/** "Totalt", or "Totalt · Min del" for the viewer's share. */
@Composable
internal fun totalLabel(measure: Measure): String {
val total = stringResource(R.string.analytics_total)
return if (measure == Measure.HOUSEHOLD) total else total + stringResource(DesignR.string.common_separator_dot) + stringResource(R.string.analytics_measure_my_share)
}
@Composable
private fun Ready(content: AnalyticsContent.Ready, events: AnalyticsEvents, open: (String?, CategoryId?, AccountId?) -> Unit) {
val data = content.data
TotalCard(data.totalOre, totalLabel(data.filter.measure), totalCaption(data))
if (AnalyticsRules.isEmpty(data)) {
val other = content.categories.firstOrNull { it.defaultKey == DefaultCategoryKey.OTHER }
EmptyState(
title = stringResource(R.string.analytics_empty),
body = stringResource(R.string.analytics_empty_body),
illustration = if (other != null) listOf(other, other, other) else emptyList(),
illustrationContainer = true,
action = stringResource(R.string.analytics_change_period),
actionIcon = KvittIcon.DateRange,
actionKind = ButtonKind.Tonal,
onAction = events::onPeriod,
)
return
}
OverTime(content) { open(it, null, null) }
val categoryRows = categoryRows(data.byCategory, content.categories)
Breakdown(stringResource(R.string.analytics_by_category), categoryRows, data.totalOre) { open(null, CategoryId(it), null) }
val accountRows = accountRows(data.byAccount, content.accounts)
Breakdown(stringResource(R.string.analytics_by_account), accountRows, data.totalOre) { open(null, null, AccountId(it)) }
}
@Composable
private fun OverTime(content: AnalyticsContent.Ready, onBucket: (String) -> Unit) {
val data = content.data
val groups = chartGroups(data, content.categories, content.accounts)
val range = rangeText(data.start, data.end, data.filter, LocalKvittClock.current.today, kvittLanguage())
val highest = data.buckets.maxByOrNull { it.totalOre }
val lowest = data.buckets.minByOrNull { it.totalOre }
val summary = if (highest != null && lowest != null) {
stringResource(
R.string.analytics_summary_over_time,
range,
bucketName(highest.key, capitalized = false),
spokenMoney(highest.totalOre),
bucketName(lowest.key, capitalized = false),
spokenMoney(lowest.totalOre),
)
} else {
stringResource(R.string.analytics_over_time)
}
val names = data.buckets.associate { it.key to bucketName(it.key, capitalized = true) }
val amounts = data.buckets.associate { it.key to spokenMoney(it.totalOre) }
val barSpoken = stringResource(R.string.analytics_bar_spoken)
ChartCard(stringResource(R.string.analytics_over_time)) {
StackedBarChart(
buckets = data.buckets,
groups = groups,
summary = summary,
barDescription = { barSpoken.format(names[it.key].orEmpty(), amounts[it.key].orEmpty()) },
onBucket = onBucket,
xCaption = if (data.filter.granularity == Granularity.WEEK) stringResource(R.string.analytics_granularity_week) else null,
)
ChartLegend(groups)
}
}
/**
* Series of chart 1 in legend and stacking order: categories by their sort order, accounts in `AccountRepository.all()`
* order (design-spec §2.4); only groups with data.
*/
@Composable
private fun chartGroups(data: AnalyticsState, categories: List<Category>, accounts: List<Account>): List<ChartGroup> {
val colors = KvittTheme.colors
val ids = data.groups.map { it.id }.toSet()
return when (data.filter.groupBy) {
GroupBy.CATEGORY -> {
val known = categories.filter { it.id.value in ids }.sortedBy { it.sortOrder }
known.map { c -> ChartGroup(c.id.value, colors.category(c.color), groupLabel(data.groups, c)) } +
data.groups.filter { g -> known.none { it.id.value == g.id } }.map { ChartGroup(it.id, colors.category(it.color ?: ColorToken.GREY), it.label.orEmpty()) }
}
GroupBy.ACCOUNT -> {
val known = accounts.withIndex().filter { it.value.id.value in ids }
known.map { (i, a) -> ChartGroup(a.id.value, colors.account(i), a.name) } +
data.groups.filter { g -> known.none { it.value.id.value == g.id } }.map { ChartGroup(it.id, colors.account(accounts.size), it.label.orEmpty()) }
}
}
}
@Composable
private fun groupLabel(groups: List<GroupKey>, category: Category): String =
groups.firstOrNull { it.id == category.id.value }?.label ?: categoryName(category)
@Composable
private fun categoryRows(totals: List<Pair<GroupKey, Long>>, categories: List<Category>): List<BreakdownRow> {
val colors = KvittTheme.colors
return totals.mapNotNull { (key, ore) ->
val category = categories.firstOrNull { it.id.value == key.id } ?: return@mapNotNull null
BreakdownRow(key.id, key.label ?: categoryName(category), ore, colors.category(category.color)) {
CategoryTile(category, KvittDimens.tileBreakdown)
}
}
}
@Composable
private fun accountRows(totals: List<Pair<GroupKey, Long>>, accounts: List<Account>): List<BreakdownRow> {
val colors = KvittTheme.colors
return totals.mapNotNull { (key, ore) ->
val index = accounts.indexOfFirst { it.id.value == key.id }
val account = accounts.getOrNull(index) ?: return@mapNotNull null
BreakdownRow(key.id, account.name, ore, colors.account(index), archived = account.archived) {
AccountTile(account.name, index, size = KvittDimens.tileBreakdown, muted = account.archived)
}
}
}
/** Charts 2 and 3 (AC-14.6) with the summary sentence of design-spec §3.10 for TalkBack. */
@Composable
private fun Breakdown(title: String, rows: List<BreakdownRow>, totalOre: Long, onRow: (String) -> Unit) {
if (rows.isEmpty()) return
val largest = rows.maxBy { it.amountOre }
val percent = stringResource(DesignR.string.common_percent, MoneyFormatter.percent(largest.amountOre, totalOre, kvittLanguage()))
val summary = stringResource(R.string.analytics_summary_breakdown, title, largest.name, spokenMoney(largest.amountOre), percent)
ChartCard(title) {
BreakdownList(rows, totalOre, onRow, Modifier.semantics { contentDescription = summary })
}
}
/** First sync (SPEC §13.3): skeleton blocks in the shape of the total card and a chart card. */
@Composable
private fun LoadingCards() {
KvittCard(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2)) {
Skeleton(KvittDimens.menuMinWidth / 2, KvittTheme.spacing.s4)
Skeleton(KvittDimens.menuMinWidth, KvittTheme.spacing.s8)
Skeleton(KvittDimens.menuMinWidth / 2, KvittTheme.spacing.s3)
}
ChartCard(stringResource(R.string.analytics_over_time)) {
Skeleton(KvittDimens.chartPlot, KvittDimens.chartPlot, Modifier.fillMaxWidth())
}
KvittCard(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3)) {
Skeleton(KvittDimens.menuMinWidth / 2, KvittTheme.spacing.s4)
repeat(SKELETON_ROWS) { Skeleton(KvittDimens.chartPlot, KvittTheme.spacing.s8, Modifier.fillMaxWidth()) }
}
}
private const val SKELETON_ROWS = 3
@@ -0,0 +1,75 @@
package se.hagstedt.kvitt.feature.analytics
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.AccountRepository
import se.hagstedt.kvitt.model.AnalyticsRepository
import se.hagstedt.kvitt.model.CategoryRepository
import se.hagstedt.kvitt.model.Granularity
import se.hagstedt.kvitt.model.GroupBy
import se.hagstedt.kvitt.model.Measure
import se.hagstedt.kvitt.model.RangePreset
import se.hagstedt.kvitt.model.SyncRepository
import javax.inject.Inject
/**
* S-11 (FR-14). The filter lives here, so it survives tab switches while the destination's back stack entry does
* (design-spec S-11); every toggle re-queries at once. Analytics are local and include pending changes, so the screen
* works offline (AC-14.1); only the first sync shows a loading state.
*/
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class AnalyticsViewModel @Inject constructor(
analytics: AnalyticsRepository,
categories: CategoryRepository,
accounts: AccountRepository,
sync: SyncRepository,
) : ViewModel(), AnalyticsEvents {
private val filter = MutableStateFlow(AnalyticsRules.defaultFilter)
private val dialog = MutableStateFlow(PeriodDialog.None)
private val content = combine(filter.flatMapLatest { analytics.analytics(it) }, categories.all(), accounts.all(), sync.status()) { data, cats, accs, status ->
if (AnalyticsRules.isFirstSync(status.summary)) AnalyticsContent.Loading else AnalyticsContent.Ready(data, cats, accs)
}
val state: StateFlow<AnalyticsUiState> = combine(filter, dialog, content) { f, d, c -> AnalyticsUiState(f, d, c) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS), AnalyticsUiState(filter.value, dialog.value, null))
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) }
override fun onMeasure(measure: Measure) = filter.update { it.copy(measure = measure) }
override fun onGroupBy(groupBy: GroupBy) = filter.update { it.copy(groupBy = groupBy) }
private companion object {
const val STOP_TIMEOUT_MILLIS = 5_000L
}
}
@@ -0,0 +1,63 @@
package se.hagstedt.kvitt.feature.analytics
import androidx.annotation.StringRes
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.res.stringResource
import kotlinx.datetime.LocalDate
import se.hagstedt.kvitt.design.component.LocalKvittClock
import se.hagstedt.kvitt.design.component.kvittLanguage
import se.hagstedt.kvitt.design.format.DateFormatter
import se.hagstedt.kvitt.design.format.KvittLanguage
import se.hagstedt.kvitt.model.AnalyticsFilter
import se.hagstedt.kvitt.model.Granularity
import se.hagstedt.kvitt.model.Measure
import se.hagstedt.kvitt.model.RangePreset
// Words of S-11 and S-12 built from strings.xml and :core:design's formatters (SPEC §9.8).
@StringRes
internal fun presetLabel(preset: RangePreset): Int = when (preset) {
RangePreset.THIS_MONTH -> R.string.analytics_range_this_month
RangePreset.LAST_3_MONTHS -> R.string.analytics_range_last_3_months
RangePreset.LAST_6_MONTHS -> R.string.analytics_range_last_6_months
RangePreset.LAST_12_MONTHS -> R.string.analytics_range_last_12_months
RangePreset.THIS_YEAR -> R.string.analytics_range_this_year
RangePreset.ALL_TIME -> R.string.analytics_range_all_time
RangePreset.CUSTOM -> R.string.analytics_range_custom
}
@StringRes
internal fun measureLabel(measure: Measure): Int =
if (measure == Measure.HOUSEHOLD) R.string.analytics_measure_household else R.string.analytics_measure_my_share
/** The range in a caption: whole months "maj–okt 2026" for a month preset, else days "24 aug.–17 okt.". */
internal fun rangeText(start: String, end: String, filter: AnalyticsFilter, today: LocalDate, language: KvittLanguage): String {
val first = LocalDate.parse(start)
val last = LocalDate.parse(end)
return if (filter.granularity == Granularity.MONTH && filter.preset != RangePreset.CUSTOM) {
DateFormatter.monthRange(first, last, language)
} else {
DateFormatter.dayRange(first, last, today, language)
}
}
/** The period chip: the preset's name, or "Anpassad · 1–30 nov." for a custom range. */
@Composable
@ReadOnlyComposable
internal fun periodLabel(filter: AnalyticsFilter): String {
val start = filter.customStart
val end = filter.customEnd
if (filter.preset != RangePreset.CUSTOM || start == null || end == null) return stringResource(presetLabel(filter.preset))
val days = DateFormatter.dayRange(LocalDate.parse(start), LocalDate.parse(end), LocalKvittClock.current.today, kvittLanguage())
return stringResource(R.string.analytics_range_custom_dates, days)
}
/** A bucket key in running text: "oktober 2026" or "vecka 41"; [capitalized] at the start of a title or sentence. */
@Composable
@ReadOnlyComposable
internal fun bucketName(key: String, capitalized: Boolean): String {
val week = DateFormatter.weekOfKey(key)
val text = if (week != null) stringResource(R.string.analytics_week_label, week) else DateFormatter.monthYear(key, kvittLanguage())
return if (capitalized) text.replaceFirstChar { it.uppercaseChar() } else text
}
@@ -0,0 +1,173 @@
package se.hagstedt.kvitt.feature.analytics
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.heading
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.component.AccountTile
import se.hagstedt.kvitt.design.component.AmountStyle
import se.hagstedt.kvitt.design.component.AmountTone
import se.hagstedt.kvitt.design.component.CategoryTile
import se.hagstedt.kvitt.design.component.ExpenseRow
import se.hagstedt.kvitt.design.component.KvittCard
import se.hagstedt.kvitt.design.component.KvittIconImage
import se.hagstedt.kvitt.design.component.KvittScaffold
import se.hagstedt.kvitt.design.component.KvittTopAppBar
import se.hagstedt.kvitt.design.component.LocalKvittClock
import se.hagstedt.kvitt.design.component.MoneyText
import se.hagstedt.kvitt.design.component.SegmentedGroup
import se.hagstedt.kvitt.design.component.TopBarNavigation
import se.hagstedt.kvitt.design.component.categoryName
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.ExpenseId
import se.hagstedt.kvitt.core.design.R as DesignR
/** S-12 Analytics drill-down (design-spec S-12, AC-14.8). [onOpenExpense] opens S-8. */
@Composable
fun DrillDownScreen(
onOpenExpense: (ExpenseId) -> Unit,
onBack: () -> Unit,
modifier: Modifier = Modifier,
viewModel: DrillDownViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
DrillDownScreen(state, onOpenExpense, onBack, modifier)
}
/** The stateless S-12 that previews and screenshot tests render. */
@Composable
fun DrillDownScreen(
state: DrillDownUiState,
onOpenExpense: (ExpenseId) -> Unit,
onBack: () -> Unit,
modifier: Modifier = Modifier,
scrollState: ScrollState = rememberScrollState(),
) {
val title = drillDownTitle(state)
val scrolled by remember(scrollState) { derivedStateOf { scrollState.value > 0 } }
KvittScaffold(
modifier.fillMaxSize(),
topBar = { KvittTopAppBar(title, navigation = TopBarNavigation.Back, onNavigation = onBack, scrolled = scrolled) },
) { padding ->
Column(
Modifier
.padding(padding)
.fillMaxSize()
.verticalScroll(scrollState)
.padding(horizontal = KvittTheme.spacing.gutter, vertical = KvittTheme.spacing.s2),
verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.detailGap),
) {
val data = state.data ?: return@Column
Header(state, title, data.totalOre, data.rows.size)
if (data.rows.isEmpty()) {
Text(
stringResource(R.string.analytics_drilldown_empty),
Modifier.fillMaxWidth().padding(KvittTheme.spacing.s4),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
} else {
SegmentedGroup(data.rows.size) { item ->
val row = data.rows[item.index]
ExpenseRow(item, row, onClick = { onOpenExpense(row.id) })
}
}
}
}
}
/**
* The visible title, formatted from the drill-down arguments (§14.2.4): "Mat & hushåll, oktober 2026",
* "Vardag, vecka 41", "Oktober 2026", or "Mat & hushåll, maj–okt 2026" for a category or account over the range.
*/
@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 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()))
name != null -> name
bucket != null -> bucketName(bucket, capitalized = true)
else -> ""
}
}
/** The header card: tile 56, "Totalt · Hushållet" over the total over "4 utgifter"; one TalkBack node. */
@Composable
private fun Header(state: DrillDownUiState, title: String, totalOre: Long, count: Int) {
val colors = KvittTheme.colors
val category = state.category
val account = state.account
val fill = when {
category != null -> colors.categoryContainer(category.color)
account != null -> colors.accountHeader(state.accountIndex)
else -> MaterialTheme.colorScheme.primaryContainer
}
val separator = stringResource(DesignR.string.common_separator_dot)
val label = stringResource(R.string.analytics_total) + separator + stringResource(measureLabel(state.args.filter.measure))
val countText = pluralStringResource(R.plurals.analytics_drilldown_expenses, count, count)
val comma = stringResource(DesignR.string.common_separator_comma)
val spoken = listOf(title, stringResource(R.string.analytics_total) + " " + spokenMoney(totalOre), countText).joinToString(comma)
KvittCard(
Modifier.fillMaxWidth().clearAndSetSemantics { contentDescription = spoken; heading() },
color = fill,
contentPadding = PaddingValues(horizontal = KvittDimens.cardPaddingLarge, vertical = KvittDimens.chartCardTop),
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s5)) {
when {
category != null -> CategoryTile(category, KvittDimens.tileHeader)
account != null -> AccountTile(account.name, state.accountIndex, size = KvittDimens.tileHeader, muted = account.archived)
else -> BucketTile()
}
Column(Modifier.weight(1f)) {
Text(label, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
MoneyText(totalOre, AmountStyle.Large, AmountTone.Neutral)
Text(countText, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
/** A whole bucket's tile: category shape in `primary` with a filled `bar_chart` (design-spec S-12). */
@Composable
private fun BucketTile() {
val scheme = MaterialTheme.colorScheme
Box(Modifier.size(KvittDimens.tileHeader).background(scheme.primary, KvittTheme.shapes.categoryTile), contentAlignment = Alignment.Center) {
KvittIconImage(KvittIcon.BarChart, null, filled = true, size = KvittDimens.tileHeader * KvittDimens.TILE_ICON_RATIO, tint = scheme.onPrimary)
}
}
@@ -0,0 +1,56 @@
package se.hagstedt.kvitt.feature.analytics
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import se.hagstedt.kvitt.model.AccountRepository
import se.hagstedt.kvitt.model.AnalyticsRepository
import se.hagstedt.kvitt.model.CategoryRepository
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).
*/
@HiltViewModel
class DrillDownViewModel(
args: DrillDownArgs,
analytics: AnalyticsRepository,
categories: CategoryRepository,
accounts: AccountRepository,
) : ViewModel() {
@Inject
constructor(
savedStateHandle: SavedStateHandle,
analytics: AnalyticsRepository,
categories: CategoryRepository,
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),
)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS), DrillDownUiState(args, null, null, null, null, null, 0))
private companion object {
const val STOP_TIMEOUT_MILLIS = 5_000L
}
}
@@ -0,0 +1,89 @@
package se.hagstedt.kvitt.feature.analytics
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.DateRangePicker
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.rememberDateRangePickerState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import se.hagstedt.kvitt.design.KvittTheme
import se.hagstedt.kvitt.design.component.ButtonKind
import se.hagstedt.kvitt.design.component.KvittButton
import se.hagstedt.kvitt.design.component.KvittMenuContent
import se.hagstedt.kvitt.design.component.KvittMenuItem
import se.hagstedt.kvitt.design.component.KvittTopAppBar
import se.hagstedt.kvitt.design.component.TopBarNavigation
import se.hagstedt.kvitt.design.icon.KvittIcon
import se.hagstedt.kvitt.design.theme.KvittDimens
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) {
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)
}
}
/** The menu's content: the selected preset carries a check (selection is never shown by colour alone). */
@Composable
internal fun PeriodMenu(selected: RangePreset, onPreset: (RangePreset) -> Unit, modifier: Modifier = Modifier) {
val items = RangePreset.entries.map { preset ->
val icon = when {
preset == selected -> KvittIcon.Check
preset == RangePreset.CUSTOM -> KvittIcon.Event
else -> KvittIcon.DateRange
}
KvittMenuItem(icon, stringResource(presetLabel(preset)), onClick = { onPreset(preset) })
}
KvittMenuContent(items, modifier)
}
@Composable
internal fun CustomRangeDialog(start: String?, end: String?, events: AnalyticsEvents) {
Dialog(onDismissRequest = events::onDismissPeriod, properties = DialogProperties(usePlatformDefaultWidth = false)) {
CustomRangePicker(start, end, events::onCustomRange, events::onDismissPeriod)
}
}
/**
* "Anpassad…" (AC-14.2): the Material date range picker full screen, with "Använd" enabled only when both dates are
* chosen, so a range with a missing date is never applied (§14.2.4).
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun CustomRangePicker(start: String?, end: String?, onApply: (String?, String?) -> Unit, onCancel: () -> Unit, modifier: Modifier = Modifier) {
val startMillis = start?.let(AnalyticsRules::millis)
val state = rememberDateRangePickerState(
initialSelectedStartDateMillis = startMillis,
initialSelectedEndDateMillis = end?.let(AnalyticsRules::millis),
initialDisplayedMonthMillis = startMillis,
)
Surface(modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {
Column {
KvittTopAppBar(stringResource(R.string.analytics_period), navigation = TopBarNavigation.Close, onNavigation = onCancel) {
KvittButton(
stringResource(R.string.analytics_range_apply),
onClick = {
onApply(state.selectedStartDateMillis?.let(AnalyticsRules::isoDate), state.selectedEndDateMillis?.let(AnalyticsRules::isoDate))
},
kind = ButtonKind.Text,
enabled = AnalyticsRules.canApply(state.selectedStartDateMillis, state.selectedEndDateMillis),
)
}
DateRangePicker(state, Modifier.weight(1f), showModeToggle = false)
}
}
}
@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- :feature:analytics strings (S-11, S-12). Source: docs/ux/strings.csv, module=analytics. -->
<resources>
<string name="analytics_title">Statistik</string>
<string name="analytics_period">Period</string>
<string name="analytics_period_spoken">Period: %1$s</string>
<string name="analytics_range_this_month">Denna månad</string>
<string name="analytics_range_last_3_months">Senaste 3 mån</string>
<string name="analytics_range_last_6_months">Senaste 6 mån</string>
<string name="analytics_range_last_12_months">Senaste 12 mån</string>
<string name="analytics_range_this_year">I år</string>
<string name="analytics_range_all_time">All tid</string>
<string name="analytics_range_custom">Anpassad…</string>
<string name="analytics_range_custom_dates">Anpassad · %1$s</string>
<string name="analytics_range_dates">%1$s – %2$s</string>
<string name="analytics_granularity_month">Månad</string>
<string name="analytics_granularity_week">Vecka</string>
<string name="analytics_measure_household">Hushållet</string>
<string name="analytics_measure_my_share">Min del</string>
<string name="analytics_group_by_category">Kategori</string>
<string name="analytics_group_by_account">Konto</string>
<string name="analytics_total">Totalt</string>
<plurals name="analytics_weeks">
<item quantity="one">%d vecka</item>
<item quantity="other">%d veckor</item>
</plurals>
<string name="analytics_over_time">Utgifter över tid</string>
<string name="analytics_chart_spoken">%1$s per %2$s och %3$s</string>
<string name="analytics_by_category">Per kategori</string>
<string name="analytics_by_account">Per konto</string>
<string name="analytics_week_label">vecka %1$d</string>
<string name="analytics_axis_thousands">%1$s tkr</string>
<string name="analytics_empty">Inga utgifter i den här perioden</string>
<string name="analytics_empty_body">Välj en annan period, eller lägg till en utgift med ett datum i perioden.</string>
<string name="analytics_change_period">Byt period</string>
<string name="analytics_summary_over_time">Utgifter över tid, %1$s: högst i %2$s med %3$s, lägst i %4$s med %5$s.</string>
<string name="analytics_summary_breakdown">%1$s: störst är %2$s med %3$s (%4$s).</string>
<string name="analytics_bar_spoken">%1$s: %2$s</string>
<string name="analytics_drilldown_empty">Inga utgifter längre</string>
<string name="analytics_drilldown_title">%1$s, %2$s</string>
<plurals name="analytics_drilldown_expenses">
<item quantity="one">%d utgift</item>
<item quantity="other">%d utgifter</item>
</plurals>
<!-- proposed (M7e): radio-group name of the month/week toggle -->
<string name="analytics_granularity">Indelning</string>
<!-- proposed (M7e): radio-group name of the measure toggle -->
<string name="analytics_measure">Mått</string>
<!-- proposed (M7e): radio-group name of the group-by toggle -->
<string name="analytics_group_by">Gruppera efter</string>
<!-- proposed (M7e): confirm button of the custom range picker -->
<string name="analytics_range_apply">Använd</string>
</resources>
@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- :feature:analytics strings (S-11, S-12). Source: docs/ux/strings.csv, module=analytics. -->
<resources>
<string name="analytics_title">Analytics</string>
<string name="analytics_period">Period</string>
<string name="analytics_period_spoken">Period: %1$s</string>
<string name="analytics_range_this_month">This month</string>
<string name="analytics_range_last_3_months">Last 3 months</string>
<string name="analytics_range_last_6_months">Last 6 months</string>
<string name="analytics_range_last_12_months">Last 12 months</string>
<string name="analytics_range_this_year">This year</string>
<string name="analytics_range_all_time">All time</string>
<string name="analytics_range_custom">Custom…</string>
<string name="analytics_range_custom_dates">Custom · %1$s</string>
<string name="analytics_range_dates">%1$s – %2$s</string>
<string name="analytics_granularity_month">Month</string>
<string name="analytics_granularity_week">Week</string>
<string name="analytics_measure_household">Household</string>
<string name="analytics_measure_my_share">My share</string>
<string name="analytics_group_by_category">Category</string>
<string name="analytics_group_by_account">Account</string>
<string name="analytics_total">Total</string>
<plurals name="analytics_weeks">
<item quantity="one">%d week</item>
<item quantity="other">%d weeks</item>
</plurals>
<string name="analytics_over_time">Spending over time</string>
<string name="analytics_chart_spoken">%1$s per %2$s and %3$s</string>
<string name="analytics_by_category">By category</string>
<string name="analytics_by_account">By account</string>
<string name="analytics_week_label">week %1$d</string>
<string name="analytics_axis_thousands">%1$sk</string>
<string name="analytics_empty">No expenses in this period</string>
<string name="analytics_empty_body">Choose another period, or add an expense dated within it.</string>
<string name="analytics_change_period">Change period</string>
<string name="analytics_summary_over_time">Spending over time, %1$s: highest in %2$s with %3$s, lowest in %4$s with %5$s.</string>
<string name="analytics_summary_breakdown">%1$s: largest is %2$s with %3$s (%4$s).</string>
<string name="analytics_bar_spoken">%1$s: %2$s</string>
<string name="analytics_drilldown_empty">No expenses any more</string>
<string name="analytics_drilldown_title">%1$s, %2$s</string>
<plurals name="analytics_drilldown_expenses">
<item quantity="one">%d expense</item>
<item quantity="other">%d expenses</item>
</plurals>
<!-- proposed (M7e): radio-group name of the month/week toggle -->
<string name="analytics_granularity">Interval</string>
<!-- proposed (M7e): radio-group name of the measure toggle -->
<string name="analytics_measure">Measure</string>
<!-- proposed (M7e): radio-group name of the group-by toggle -->
<string name="analytics_group_by">Group by</string>
<!-- proposed (M7e): confirm button of the custom range picker -->
<string name="analytics_range_apply">Apply</string>
</resources>
@@ -0,0 +1,90 @@
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.model.AccountId
import se.hagstedt.kvitt.model.AnalyticsFilter
import se.hagstedt.kvitt.model.AnalyticsState
import se.hagstedt.kvitt.model.CategoryId
import se.hagstedt.kvitt.model.Granularity
import se.hagstedt.kvitt.model.GroupBy
import se.hagstedt.kvitt.model.GroupKey
import se.hagstedt.kvitt.model.Measure
import se.hagstedt.kvitt.model.RangePreset
import se.hagstedt.kvitt.model.SyncSummary
class AnalyticsRulesTest {
private val defaults = AnalyticsRules.defaultFilter
@Test
fun defaultsAreThoseOfAc14() {
assertEquals(AnalyticsFilter(RangePreset.LAST_6_MONTHS, null, null, Granularity.MONTH, Measure.HOUSEHOLD, GroupBy.CATEGORY), defaults)
}
@Test
fun applyNeedsBothDates() {
assertFalse(AnalyticsRules.canApply(null, null))
assertFalse(AnalyticsRules.canApply(1L, null))
assertFalse(AnalyticsRules.canApply(null, 1L))
assertTrue(AnalyticsRules.canApply(1L, 1L))
}
@Test
fun customRangeWithAMissingDateIsNotApplied() {
assertNull(AnalyticsRules.withCustomRange(defaults, "2026-11-01", null))
assertNull(AnalyticsRules.withCustomRange(defaults, null, "2026-11-30"))
}
@Test
fun customRangeIsOrdered() {
val applied = AnalyticsRules.withCustomRange(defaults, "2026-11-30", "2026-11-01")
assertEquals(RangePreset.CUSTOM, applied?.preset)
assertEquals("2026-11-01", applied?.customStart)
assertEquals("2026-11-30", applied?.customEnd)
}
@Test
fun aPresetDropsTheCustomDatesAndKeepsTheToggles() {
val custom = AnalyticsFilter(RangePreset.CUSTOM, "2026-01-01", "2026-02-01", Granularity.WEEK, Measure.MY_SHARE, GroupBy.ACCOUNT)
assertEquals(AnalyticsFilter(RangePreset.THIS_YEAR, null, null, Granularity.WEEK, Measure.MY_SHARE, GroupBy.ACCOUNT), AnalyticsRules.withPreset(custom, RangePreset.THIS_YEAR))
assertEquals(custom, AnalyticsRules.withPreset(custom, RangePreset.CUSTOM))
}
@Test
fun pickerMillisRoundTripInUtc() {
assertEquals(0L, AnalyticsRules.millis("1970-01-01"))
assertEquals("2026-10-17", AnalyticsRules.isoDate(AnalyticsRules.millis("2026-10-17")))
assertEquals("2026-10-17", AnalyticsRules.isoDate(AnalyticsRules.millis("2026-10-17") + 86_399_999L))
}
@Test
fun emptyMeansNoSpendInTheRange() {
val empty = AnalyticsState(defaults, "2026-05-01", "2026-10-17", emptyList(), emptyList(), emptyList(), emptyList(), 0)
assertTrue(AnalyticsRules.isEmpty(empty))
val key = GroupKey("c", null, null)
assertFalse(AnalyticsRules.isEmpty(empty.copy(byCategory = listOf(key to 100L), totalOre = 100L)))
}
@Test
fun loadingOnlyBeforeTheFirstSync() {
assertTrue(AnalyticsRules.isFirstSync(SyncSummary(null, 0, 0, clockSkew = false, running = true)))
assertFalse(AnalyticsRules.isFirstSync(SyncSummary("2026-10-17T06:00:00.000Z", 3, 0, clockSkew = false, running = false)))
}
@Test
fun drillDownArgumentsRoundTrip() {
val args = DrillDownArgs(AnalyticsFilter(RangePreset.CUSTOM, "2026-08-24", "2026-10-17", Granularity.WEEK, Measure.MY_SHARE, GroupBy.ACCOUNT), "2026-W41", CategoryId("c1"), AccountId("a1"))
val map = args.toArguments()
assertTrue(map.keys.all { it in DrillDownArgs.KEYS })
assertEquals(args, DrillDownArgs.fromArguments { map[it] })
}
@Test
fun missingOrUnknownArgumentsFallBackToTheDefaults() {
val args = DrillDownArgs.fromArguments { if (it == DrillDownArgs.KEY_PRESET) "bogus" else null }
assertEquals(DrillDownArgs(defaults, null, null, null), args)
}
}
@@ -0,0 +1,105 @@
package se.hagstedt.kvitt.feature.analytics
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import se.hagstedt.kvitt.fakes.FakeRepositories
import se.hagstedt.kvitt.fakes.FakeScenario
import se.hagstedt.kvitt.fakes.analytics.AnalyticsScenarios
import se.hagstedt.kvitt.model.Granularity
import se.hagstedt.kvitt.model.GroupBy
import se.hagstedt.kvitt.model.Measure
import se.hagstedt.kvitt.model.RangePreset
@OptIn(ExperimentalCoroutinesApi::class)
class AnalyticsViewModelTest {
@Before
fun setUp() = Dispatchers.setMain(UnconfinedTestDispatcher())
@After
fun tearDown() = Dispatchers.resetMain()
private fun viewModel(scenario: FakeScenario): AnalyticsViewModel {
val fakes = FakeRepositories(scenario)
return AnalyticsViewModel(fakes.analytics, fakes.categories, fakes.accounts, fakes.sync)
}
private suspend fun AnalyticsViewModel.ready(): AnalyticsContent.Ready = state.first { it.content is AnalyticsContent.Ready }.content as AnalyticsContent.Ready
@Test
fun startsWithTheDefaultFilter() = runTest {
val vm = viewModel(AnalyticsScenarios.loaded)
val ready = vm.ready()
assertEquals(AnalyticsRules.defaultFilter, ready.data.filter)
assertEquals("2026-05-01", ready.data.start)
assertEquals(6, ready.data.buckets.size)
assertEquals(4, ready.accounts.size)
}
@Test
fun togglesReQueryAtOnce() = runTest {
val vm = viewModel(AnalyticsScenarios.loaded)
vm.ready()
vm.onGranularity(Granularity.WEEK)
vm.onMeasure(Measure.MY_SHARE)
vm.onGroupBy(GroupBy.ACCOUNT)
val data = vm.ready().data
assertEquals(Granularity.WEEK, data.filter.granularity)
assertEquals(Measure.MY_SHARE, data.filter.measure)
assertEquals(GroupBy.ACCOUNT, data.filter.groupBy)
assertTrue(data.buckets.all { "-W" in it.key })
}
@Test
fun customOpensThePickerAndAppliesOnlyWithBothDates() = runTest {
val vm = viewModel(AnalyticsScenarios.loaded)
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { vm.state.collect {} }
vm.onPeriod()
assertEquals(PeriodDialog.Menu, vm.state.value.dialog)
vm.onPreset(RangePreset.CUSTOM)
assertEquals(PeriodDialog.CustomPicker, vm.state.value.dialog)
vm.onCustomRange("2026-11-01", null)
assertEquals(PeriodDialog.CustomPicker, vm.state.value.dialog)
assertEquals(RangePreset.LAST_6_MONTHS, vm.state.value.filter.preset)
vm.onCustomRange("2026-11-01", "2026-11-30")
assertEquals(PeriodDialog.None, vm.state.value.dialog)
assertEquals(AnalyticsScenarios.emptyFilter, vm.state.value.filter)
assertTrue(AnalyticsRules.isEmpty(vm.ready().data))
}
@Test
fun aPresetClosesTheMenu() = runTest {
val vm = viewModel(AnalyticsScenarios.loaded)
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { vm.state.collect {} }
vm.onPeriod()
vm.onPreset(RangePreset.THIS_MONTH)
assertEquals(PeriodDialog.None, vm.state.value.dialog)
assertEquals("2026-10-01", vm.ready().data.start)
vm.onPeriod()
vm.onDismissPeriod()
assertEquals(PeriodDialog.None, vm.state.value.dialog)
}
@Test
fun firstSyncIsLoading() = runTest {
val vm = viewModel(AnalyticsScenarios.loading)
assertEquals(AnalyticsContent.Loading, vm.state.first { it.content != null }.content)
}
@Test
fun offlineWorksAndCountsPendingChanges() = runTest {
val online = viewModel(AnalyticsScenarios.loaded).ready().data.totalOre
val offline = viewModel(AnalyticsScenarios.offline).ready().data.totalOre
assertEquals(online + 46_900L, offline)
}
}
@@ -0,0 +1,55 @@
package se.hagstedt.kvitt.feature.analytics
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import se.hagstedt.kvitt.fakes.FakeRepositories
import se.hagstedt.kvitt.fakes.analytics.AnalyticsScenarios
@OptIn(ExperimentalCoroutinesApi::class)
class DrillDownViewModelTest {
@Before
fun setUp() = Dispatchers.setMain(UnconfinedTestDispatcher())
@After
fun tearDown() = Dispatchers.resetMain()
private suspend fun load(args: DrillDownArgs): DrillDownUiState {
val fakes = FakeRepositories(AnalyticsScenarios.loaded)
val vm = DrillDownViewModel(args, fakes.analytics, fakes.categories, fakes.accounts)
return vm.state.first { it.data != null && it.start != null }
}
@Test
fun categoryInABucketIsTheFourOctoberGroceries() = runTest {
val state = load(DrillDownArgs(AnalyticsRules.defaultFilter, "2026-10", AnalyticsScenarios.groceries.id, null))
val data = checkNotNull(state.data)
assertEquals(391_000L, data.totalOre)
assertEquals(listOf("ICA Maxi Lindhagen", "Willys Hornstull", "ICA Nära Hornstull", "Lidl Liljeholmen"), data.rows.map { it.description })
assertEquals(AnalyticsScenarios.groceries, state.category)
}
@Test
fun accountKeepsItsChartPosition() = runTest {
val state = load(DrillDownArgs(AnalyticsRules.defaultFilter, null, null, AnalyticsScenarios.japan.id))
assertEquals(AnalyticsScenarios.japan, state.account)
assertEquals(3, state.accountIndex) // created_at order: Vardag, Huset, Stugan, Japan 2027
assertEquals("2026-05-01", state.start)
assertEquals(1_384_000L + 412_600L + 960_000L, state.data?.totalOre)
}
@Test
fun bucketAloneListsEveryGroup() = runTest {
val state = load(DrillDownArgs(AnalyticsRules.defaultFilter, "2026-10", null, null))
assertEquals(null, state.category)
assertEquals(state.data?.rows?.sumOf { it.amountOre }, state.data?.totalOre)
}
}
@@ -0,0 +1,109 @@
package se.hagstedt.kvitt.feature.analytics.screenshot
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.junit4.v2.createComposeRule
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
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.fakes.FakeRepositories
import se.hagstedt.kvitt.fakes.FakeScenario
import se.hagstedt.kvitt.fakes.analytics.AnalyticsScenarios
import se.hagstedt.kvitt.feature.analytics.AnalyticsContent
import se.hagstedt.kvitt.feature.analytics.AnalyticsEvents
import se.hagstedt.kvitt.feature.analytics.AnalyticsRules
import se.hagstedt.kvitt.feature.analytics.AnalyticsScreen
import se.hagstedt.kvitt.feature.analytics.AnalyticsUiState
import se.hagstedt.kvitt.feature.analytics.CustomRangePicker
import se.hagstedt.kvitt.feature.analytics.PeriodDialog
import se.hagstedt.kvitt.feature.analytics.PeriodMenu
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
/**
* S-11 in every state of design-spec S-11 and PLAN-M7e §5, × light/dark × sv/en (plus 200 % for long content).
* Re-record with `tools/gradle-serial.sh :feature:analytics:recordRoborazziDebug` and open every changed PNG.
*/
@RunWith(ParameterizedRobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [36], qualifiers = "w411dp-h891dp-xhdpi")
class AnalyticsScreenshotTest(private val name: String) {
@get:Rule
val compose = createComposeRule()
@Test
fun screen() {
val state = states.getValue(name)
ScreenSnapshots.snap(compose, name, largeFont = name == "s11_long", content = state)
}
companion object {
private val defaults = AnalyticsRules.defaultFilter
private fun ui(scenario: FakeScenario, filter: AnalyticsFilter, dialog: PeriodDialog = PeriodDialog.None): AnalyticsUiState = runBlocking {
val fakes = FakeRepositories(scenario)
val sync = fakes.sync.status().first()
val content = if (AnalyticsRules.isFirstSync(sync.summary)) {
AnalyticsContent.Loading
} else {
AnalyticsContent.Ready(fakes.analytics.analytics(filter).first(), fakes.categories.all().first(), fakes.accounts.all().first())
}
AnalyticsUiState(filter, dialog, content)
}
private fun screen(state: AnalyticsUiState, scrolled: Boolean = false): @Composable () -> Unit = {
val scroll = remember { ScrollState(if (scrolled) Int.MAX_VALUE else 0) }
AnalyticsScreen(state, NoEvents, {}, {}, scrollState = scroll)
}
private val presets = listOf(RangePreset.THIS_MONTH, RangePreset.LAST_3_MONTHS, RangePreset.LAST_12_MONTHS, RangePreset.THIS_YEAR, RangePreset.ALL_TIME)
val states: Map<String, @Composable () -> Unit> = buildMap {
put("s11_loaded", screen(ui(AnalyticsScenarios.loaded, defaults)))
put("s11_scrolled", screen(ui(AnalyticsScenarios.loaded, defaults), scrolled = true))
put("s11_by_account", screen(ui(AnalyticsScenarios.loaded, defaults.copy(groupBy = GroupBy.ACCOUNT))))
put("s11_my_share", screen(ui(AnalyticsScenarios.loaded, defaults.copy(measure = Measure.MY_SHARE))))
put("s11_week", screen(ui(AnalyticsScenarios.loaded, AnalyticsScenarios.weekFilter)))
put("s11_week_by_category", screen(ui(AnalyticsScenarios.loaded, defaults.copy(preset = RangePreset.LAST_3_MONTHS, granularity = Granularity.WEEK))))
presets.forEach { preset -> put("s11_preset_${preset.name.lowercase()}", screen(ui(AnalyticsScenarios.loaded, defaults.copy(preset = preset)))) }
put("s11_period_menu") {
Box(Modifier.padding(KvittTheme.spacing.gutter)) { PeriodMenu(RangePreset.LAST_6_MONTHS, {}) }
}
put("s11_custom_picker") { CustomRangePicker(AnalyticsScenarios.customPickerStart, AnalyticsScenarios.customPickerEnd, { _, _ -> }, {}) }
put("s11_custom_picker_start_only") { CustomRangePicker(AnalyticsScenarios.customPickerStart, null, { _, _ -> }, {}) }
put("s11_empty", screen(ui(AnalyticsScenarios.loaded, AnalyticsScenarios.emptyFilter)))
put("s11_offline", screen(ui(AnalyticsScenarios.offline, defaults.copy(preset = RangePreset.THIS_MONTH))))
put("s11_loading", screen(ui(AnalyticsScenarios.loading, defaults)))
put("s11_long", screen(ui(AnalyticsScenarios.long, defaults)))
put("s11_long_scrolled", screen(ui(AnalyticsScenarios.long, defaults), scrolled = true))
}
@JvmStatic
@ParameterizedRobolectricTestRunner.Parameters(name = "{0}")
fun names(): List<Array<Any>> = states.keys.map { arrayOf<Any>(it) }
}
}
/** Events that do nothing: screenshots render a fixed state. */
internal object NoEvents : AnalyticsEvents {
override fun onPeriod() = Unit
override fun onDismissPeriod() = Unit
override fun onPreset(preset: RangePreset) = Unit
override fun onCustomRange(start: String?, end: String?) = Unit
override fun onGranularity(granularity: Granularity) = Unit
override fun onMeasure(measure: Measure) = Unit
override fun onGroupBy(groupBy: GroupBy) = Unit
}
@@ -0,0 +1,71 @@
package se.hagstedt.kvitt.feature.analytics.screenshot
import androidx.compose.runtime.Composable
import androidx.compose.ui.test.junit4.v2.createComposeRule
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
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.fakes.FakeRepositories
import se.hagstedt.kvitt.fakes.FakeScenario
import se.hagstedt.kvitt.fakes.analytics.AnalyticsScenarios
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.model.RangePreset
/** S-12 in every state of PLAN-M7e §5, × light/dark × sv/en (plus 200 % for long content). */
@RunWith(ParameterizedRobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [36], qualifiers = "w411dp-h891dp-xhdpi")
class DrillDownScreenshotTest(private val name: String) {
@get:Rule
val compose = createComposeRule()
@Test
fun screen() {
ScreenSnapshots.snap(compose, name, largeFont = name == "s12_long", content = states.getValue(name))
}
companion object {
private val defaults = AnalyticsRules.defaultFilter
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(
args = args,
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),
)
}
private fun screen(state: DrillDownUiState): @Composable () -> Unit = { DrillDownScreen(state, {}, {}) }
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_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))),
"s12_offline" to screen(ui(AnalyticsScenarios.offline, DrillDownArgs(defaults.copy(preset = RangePreset.THIS_MONTH), "2026-10", AnalyticsScenarios.groceries.id, null))),
"s12_long" to screen(ui(AnalyticsScenarios.long, DrillDownArgs(defaults, null, AnalyticsScenarios.longCategory.id, null))),
)
@JvmStatic
@ParameterizedRobolectricTestRunner.Parameters(name = "{0}")
fun names(): List<Array<Any>> = states.keys.map { arrayOf<Any>(it) }
}
}
@@ -0,0 +1,86 @@
package se.hagstedt.kvitt.feature.analytics.screenshot
import android.content.res.Configuration
import android.os.LocaleList
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.test.junit4.ComposeContentTestRule
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.unit.Density
import com.github.takahirom.roborazzi.captureRoboImage
import kotlinx.datetime.TimeZone
import se.hagstedt.kvitt.design.KvittTheme
import se.hagstedt.kvitt.design.component.KvittClock
import se.hagstedt.kvitt.design.component.LocalKvittClock
import se.hagstedt.kvitt.design.component.ProvideHousehold
import se.hagstedt.kvitt.fakes.FakeScenarios
import java.util.Locale
import kotlin.time.Instant
/**
* Captures a whole screen (411 × 891 dp, the Galaxy S26 of SPEC §13.1) once per language and theme:
* `src/test/screenshots/<name>_<sv|en>_<light|dark>.png`, plus `<name>_sv_light_200.png` with [largeFont] (200 %,
* like the design spec's 200 % frames). The clock is the fakes' today (17 October 2026, 14:02 in Stockholm) and the
* viewer is Jacob. Language, theme and font scale switch through a configuration context, as in :core:design's tests;
* the content is recreated per variant so remembered state (scroll, picker) starts fresh.
*/
object ScreenSnapshots {
val clock = KvittClock.fixed(Instant.parse("2026-10-17T12:02:00Z"), TimeZone.of("Europe/Stockholm"))
private data class Variant(val language: String, val dark: Boolean, val fontScale: Float) {
val suffix: String get() = "${language}_${if (dark) "dark" else "light"}" + if (fontScale > 1f) "_200" else ""
}
fun snap(compose: ComposeContentTestRule, name: String, largeFont: Boolean = false, content: @Composable () -> Unit) {
val variants = listOf("sv", "en").flatMap { l -> listOf(false, true).map { Variant(l, it, 1f) } } +
if (largeFont) listOf(Variant("sv", dark = false, fontScale = 2f)) else emptyList()
var current by mutableStateOf(variants.first())
compose.setContent {
val variant = current
val base = LocalContext.current
val baseConfiguration = LocalConfiguration.current
val context = remember(variant) {
val config = Configuration(baseConfiguration).apply {
setLocales(LocaleList(Locale.forLanguageTag(if (variant.language == "sv") "sv-SE" else "en-GB")))
fontScale = variant.fontScale
uiMode = (uiMode and Configuration.UI_MODE_NIGHT_MASK.inv()) or if (variant.dark) Configuration.UI_MODE_NIGHT_YES else Configuration.UI_MODE_NIGHT_NO
}
base.createConfigurationContext(config)
}
CompositionLocalProvider(
LocalContext provides context,
LocalConfiguration provides context.resources.configuration,
LocalResources provides context.resources,
LocalDensity provides Density(context),
LocalKvittClock provides clock,
) {
KvittTheme(darkTheme = variant.dark) {
ProvideHousehold(FakeScenarios.owner, FakeScenarios.partner) {
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {
key(variant) { Box(Modifier.fillMaxSize()) { content() } }
}
}
}
}
}
variants.forEach { variant ->
current = variant
compose.waitForIdle()
compose.onRoot().captureRoboImage("src/test/screenshots/${name}_${variant.suffix}.png")
}
}
}