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:
-14
@@ -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)
|
||||
}
|
||||
+144
@@ -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()
|
||||
}
|
||||
+305
@@ -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
|
||||
|
||||
+75
@@ -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
|
||||
}
|
||||
}
|
||||
+63
@@ -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
|
||||
}
|
||||
+173
@@ -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)
|
||||
}
|
||||
}
|
||||
+56
@@ -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
|
||||
}
|
||||
}
|
||||
+89
@@ -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>
|
||||
Reference in New Issue
Block a user