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
@@ -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>