S-12: line chart of a category or account over the range, own period and granularity, average overlays (D-128)
A drill-down opened from a breakdown row now shows its own period chip and Month/Week toggle (initialised from S-11, S-11 keeps its own filter), the header total, a line chart of the group's spend per bucket with the dashed range average, the trailing 3-bucket moving average and a legend, and a caption comparing the latest full bucket with the average; the expense rows follow. Bucket drill-downs are unchanged. The series is the group's column of AnalyticsState.buckets from spendSeries, so no contract change. :core:design chart: SpendLineChart and LineLegend beside StackedBarChart, shared axis/slot helpers (bar chart pixels unchanged), AnalyticsFilterBar measure and group-by toggles optional. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
+93
-3
@@ -13,6 +13,8 @@ import se.hagstedt.kvitt.model.GroupBy
|
||||
import se.hagstedt.kvitt.model.Measure
|
||||
import se.hagstedt.kvitt.model.RangePreset
|
||||
import se.hagstedt.kvitt.model.SyncSummary
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.sign
|
||||
|
||||
/** Which period control is open on S-11: none, the preset menu, or the custom range picker. */
|
||||
enum class PeriodDialog { None, Menu, CustomPicker }
|
||||
@@ -32,8 +34,8 @@ sealed interface AnalyticsContent {
|
||||
/** S-11 state: the [filter] the toggles show (applied at once), the open [dialog], and the [content] (null before the first emission). */
|
||||
data class AnalyticsUiState(val filter: AnalyticsFilter, val dialog: PeriodDialog, val content: AnalyticsContent?)
|
||||
|
||||
/** S-11 events (the ViewModel implements them; previews and screenshot tests pass their own). */
|
||||
interface AnalyticsEvents {
|
||||
/** The period and granularity events S-11 and S-12 share (D-128: S-12 has its own range and granularity). */
|
||||
interface PeriodEvents {
|
||||
fun onPeriod()
|
||||
fun onDismissPeriod()
|
||||
|
||||
@@ -43,6 +45,10 @@ interface AnalyticsEvents {
|
||||
/** The picker's "Använd": ignored unless both dates are set (§14.2.4: the editor never sends a missing date). */
|
||||
fun onCustomRange(start: String?, end: String?)
|
||||
fun onGranularity(granularity: Granularity)
|
||||
}
|
||||
|
||||
/** S-11 events (the ViewModel implements them; previews and screenshot tests pass their own). */
|
||||
interface AnalyticsEvents : PeriodEvents {
|
||||
fun onMeasure(measure: Measure)
|
||||
fun onGroupBy(groupBy: GroupBy)
|
||||
}
|
||||
@@ -96,9 +102,13 @@ data class DrillDownArgs(val filter: AnalyticsFilter, val bucketKey: String?, va
|
||||
}
|
||||
|
||||
/**
|
||||
* S-12 state: the [args], the repository's [data] (null until it emits), the range the filter resolves to ([start],
|
||||
* S-12 state: the [args], the repository's [data] (null until it emits), the range [filter] resolves to ([start],
|
||||
* [end], for the title of a category or account over the whole range), and the [category] or [account] behind the
|
||||
* title and header, with the account's chart position [accountIndex].
|
||||
*
|
||||
* A category or account over the range (D-128) has its own [filter] (range and granularity, initialised from S-11's;
|
||||
* measure and group-by stay S-11's), its open period [dialog], and the [series] of its line chart; a bucket drill-down
|
||||
* keeps `args.filter` and has no series.
|
||||
*/
|
||||
data class DrillDownUiState(
|
||||
val args: DrillDownArgs,
|
||||
@@ -108,8 +118,19 @@ data class DrillDownUiState(
|
||||
val category: Category?,
|
||||
val account: Account?,
|
||||
val accountIndex: Int,
|
||||
val filter: AnalyticsFilter = args.filter,
|
||||
val dialog: PeriodDialog = PeriodDialog.None,
|
||||
val series: List<SeriesPoint>? = null,
|
||||
)
|
||||
|
||||
/** One bucket of a group's series on S-12 (D-128): key, the bucket's dates clipped to the range, and the group's öre. */
|
||||
data class SeriesPoint(val key: String, val start: String, val end: String, val ore: Long)
|
||||
|
||||
/** How the latest full bucket compares with the range average (D-128): "Oktober 2026: 12 % över snittet". */
|
||||
data class AverageComparison(val key: String, val percent: Long, val direction: Direction) {
|
||||
enum class Direction { ABOVE, BELOW, ON }
|
||||
}
|
||||
|
||||
/** The input rules of S-11 and S-12, pure so the unit tests reach them. */
|
||||
object AnalyticsRules {
|
||||
/** AC-14.2 to AC-14.5: last 6 months, month, household, category. */
|
||||
@@ -137,6 +158,75 @@ object AnalyticsRules {
|
||||
/** Nothing has been synced yet: the first sync is the only loading state (SPEC §13.3). */
|
||||
fun isFirstSync(summary: SyncSummary): Boolean = summary.lastSuccessAt == null
|
||||
|
||||
/** D-128: the moving average on S-12 is trailing over this many buckets (the bucket and the two before it). */
|
||||
const val MOVING_AVERAGE_WINDOW = 3
|
||||
|
||||
private const val PERCENT = 100L
|
||||
private const val WEEK_LAST_DAY = 6L
|
||||
|
||||
/** S-12 shows its own range controls and the line chart for a category or account over the range, not for a bucket (D-128). */
|
||||
fun isOverRange(args: DrillDownArgs): Boolean = args.bucketKey == null && (args.categoryId != null || args.accountId != null)
|
||||
|
||||
/** The S-11 query whose buckets carry the drill-down group's totals: grouped by account for an account, else by category. */
|
||||
fun seriesFilter(args: DrillDownArgs, filter: AnalyticsFilter): AnalyticsFilter =
|
||||
filter.copy(groupBy = if (args.accountId != null) GroupBy.ACCOUNT else GroupBy.CATEGORY)
|
||||
|
||||
/** One point per bucket of [state] for [groupId], empty buckets as zero (AC-14.7); edge buckets are clipped by `spendSeries`. */
|
||||
fun groupSeries(state: AnalyticsState, groupId: String): List<SeriesPoint> =
|
||||
state.buckets.map { SeriesPoint(it.key, it.start, it.end, it.totals[groupId] ?: 0L) }
|
||||
|
||||
/** [a] / [b] rounded half-up (away from zero at exactly half). */
|
||||
fun divHalfUp(a: Long, b: Long): Long {
|
||||
require(b != 0L)
|
||||
val q = a / b
|
||||
val r = a % b
|
||||
return if (2 * abs(r) >= abs(b)) q + a.sign * b.sign else q
|
||||
}
|
||||
|
||||
/** The range average per bucket (D-128): total ÷ number of buckets, half-up to öre; 0 for no buckets. */
|
||||
fun rangeAverage(values: List<Long>): Long = if (values.isEmpty()) 0L else divHalfUp(values.sum(), values.size.toLong())
|
||||
|
||||
/** The average lines and the caption need spending in the range and at least two buckets (one bucket is its own average). */
|
||||
fun hasAverage(values: List<Long>): Boolean = values.size > 1 && rangeAverage(values) > 0L
|
||||
|
||||
/**
|
||||
* The trailing moving average (D-128): each bucket with the [window] − 1 before it, half-up to öre; the first
|
||||
* buckets average what exists (1, then 2 buckets).
|
||||
*/
|
||||
fun movingAverage(values: List<Long>, window: Int = MOVING_AVERAGE_WINDOW): List<Long> = values.indices.map { i ->
|
||||
val slice = values.subList((i - window + 1).coerceAtLeast(0), i + 1)
|
||||
divHalfUp(slice.sum(), slice.size.toLong())
|
||||
}
|
||||
|
||||
/** A bucket the range does not clip: a whole calendar month (1st to last day) or a whole ISO week (7 days). */
|
||||
fun isFullBucket(point: SeriesPoint): Boolean {
|
||||
val start = LocalDate.parse(point.start)
|
||||
val end = LocalDate.parse(point.end)
|
||||
return if (point.key.contains("-W")) {
|
||||
end.toEpochDays() - start.toEpochDays() == WEEK_LAST_DAY
|
||||
} else {
|
||||
start.day == 1 && LocalDate.fromEpochDays(end.toEpochDays() + 1).day == 1
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The caption under the S-12 chart (D-128): the latest full bucket against the range average, percent of the average
|
||||
* half-up to a whole number, "on average" when that rounds to 0. Null when [hasAverage] is false or no bucket is full.
|
||||
*/
|
||||
fun compareWithAverage(series: List<SeriesPoint>): AverageComparison? {
|
||||
if (!hasAverage(series.map { it.ore })) return null
|
||||
val average = rangeAverage(series.map { it.ore })
|
||||
val latest = series.lastOrNull(::isFullBucket) ?: return null
|
||||
val diff = latest.ore - average
|
||||
val percent = divHalfUp(abs(diff) * PERCENT, average)
|
||||
val direction = when {
|
||||
percent == 0L -> AverageComparison.Direction.ON
|
||||
diff > 0 -> AverageComparison.Direction.ABOVE
|
||||
else -> AverageComparison.Direction.BELOW
|
||||
}
|
||||
return AverageComparison(latest.key, percent, direction)
|
||||
}
|
||||
|
||||
/** The date picker works in UTC midnight milliseconds. */
|
||||
fun millis(isoDate: String): Long = LocalDate.parse(isoDate).toEpochDays() * MILLIS_PER_DAY
|
||||
|
||||
|
||||
+10
-5
@@ -49,6 +49,7 @@ import se.hagstedt.kvitt.design.icon.KvittIcon
|
||||
import se.hagstedt.kvitt.design.theme.KvittDimens
|
||||
import se.hagstedt.kvitt.model.Account
|
||||
import se.hagstedt.kvitt.model.AccountId
|
||||
import se.hagstedt.kvitt.model.AnalyticsFilter
|
||||
import se.hagstedt.kvitt.model.AnalyticsState
|
||||
import se.hagstedt.kvitt.model.Category
|
||||
import se.hagstedt.kvitt.model.CategoryId
|
||||
@@ -127,11 +128,7 @@ private fun Filters(state: AnalyticsUiState, events: AnalyticsEvents) {
|
||||
period = period,
|
||||
periodSpoken = stringResource(R.string.analytics_period_spoken, period),
|
||||
onPeriod = events::onPeriod,
|
||||
granularity = FilterToggle(
|
||||
stringResource(R.string.analytics_granularity),
|
||||
listOf(stringResource(R.string.analytics_granularity_month), stringResource(R.string.analytics_granularity_week)),
|
||||
Granularity.entries.indexOf(filter.granularity),
|
||||
) { events.onGranularity(Granularity.entries[it]) },
|
||||
granularity = granularityToggle(filter, events),
|
||||
measure = FilterToggle(
|
||||
stringResource(R.string.analytics_measure),
|
||||
listOf(stringResource(R.string.analytics_measure_household), stringResource(R.string.analytics_measure_my_share)),
|
||||
@@ -147,6 +144,14 @@ private fun Filters(state: AnalyticsUiState, events: AnalyticsEvents) {
|
||||
)
|
||||
}
|
||||
|
||||
/** "Månad | Vecka" (AC-14.3), shared by S-11 and S-12 (D-128). */
|
||||
@Composable
|
||||
internal fun granularityToggle(filter: AnalyticsFilter, events: PeriodEvents): FilterToggle = FilterToggle(
|
||||
stringResource(R.string.analytics_granularity),
|
||||
listOf(stringResource(R.string.analytics_granularity_month), stringResource(R.string.analytics_granularity_week)),
|
||||
Granularity.entries.indexOf(filter.granularity),
|
||||
) { events.onGranularity(Granularity.entries[it]) }
|
||||
|
||||
/** The range caption: "maj–okt 2026 · Hushållet", or "24 aug.–17 okt. · 8 veckor" in week granularity. */
|
||||
@Composable
|
||||
private fun totalCaption(data: AnalyticsState): String {
|
||||
|
||||
+128
-6
@@ -31,6 +31,13 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import se.hagstedt.kvitt.design.KvittTheme
|
||||
import se.hagstedt.kvitt.design.chart.AnalyticsFilterBar
|
||||
import se.hagstedt.kvitt.design.chart.ChartCard
|
||||
import se.hagstedt.kvitt.design.chart.LineKind
|
||||
import se.hagstedt.kvitt.design.chart.LineLegend
|
||||
import se.hagstedt.kvitt.design.chart.LineLegendItem
|
||||
import se.hagstedt.kvitt.design.chart.LinePoint
|
||||
import se.hagstedt.kvitt.design.chart.SpendLineChart
|
||||
import se.hagstedt.kvitt.design.component.AccountTile
|
||||
import se.hagstedt.kvitt.design.component.AmountStyle
|
||||
import se.hagstedt.kvitt.design.component.AmountTone
|
||||
@@ -49,7 +56,10 @@ import se.hagstedt.kvitt.design.component.kvittLanguage
|
||||
import se.hagstedt.kvitt.design.component.spokenMoney
|
||||
import se.hagstedt.kvitt.design.icon.KvittIcon
|
||||
import se.hagstedt.kvitt.design.theme.KvittDimens
|
||||
import se.hagstedt.kvitt.model.AnalyticsFilter
|
||||
import se.hagstedt.kvitt.model.ExpenseId
|
||||
import se.hagstedt.kvitt.model.Granularity
|
||||
import se.hagstedt.kvitt.model.RangePreset
|
||||
import se.hagstedt.kvitt.core.design.R as DesignR
|
||||
|
||||
/**
|
||||
@@ -65,13 +75,18 @@ fun DrillDownScreen(
|
||||
viewModel: DrillDownViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
DrillDownScreen(state, onOpenExpense, onBack, modifier, snackbarHostState = snackbarHostState)
|
||||
DrillDownScreen(state, viewModel, onOpenExpense, onBack, modifier, snackbarHostState = snackbarHostState)
|
||||
}
|
||||
|
||||
/** The stateless S-12 that previews and screenshot tests render. */
|
||||
/**
|
||||
* The stateless S-12 that previews and screenshot tests render. A category or account over the range (D-128) shows its
|
||||
* own period and granularity controls ([events]) above the header, and the line chart of its spending between the header
|
||||
* and the expense rows; a bucket drill-down shows the header and the rows only.
|
||||
*/
|
||||
@Composable
|
||||
fun DrillDownScreen(
|
||||
state: DrillDownUiState,
|
||||
events: PeriodEvents,
|
||||
onOpenExpense: (ExpenseId) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -94,10 +109,19 @@ fun DrillDownScreen(
|
||||
verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.detailGap),
|
||||
) {
|
||||
val data = state.data ?: return@Column
|
||||
val series = state.series
|
||||
if (series != null) {
|
||||
Box {
|
||||
RangeFilters(state.filter, events)
|
||||
if (state.dialog == PeriodDialog.Menu) PeriodMenuPopup(state.filter.preset, events)
|
||||
}
|
||||
}
|
||||
Header(state, title, data.totalOre, data.rows.size)
|
||||
if (series != null) OverTimeLine(state, series)
|
||||
if (data.rows.isEmpty()) {
|
||||
Text(
|
||||
stringResource(R.string.analytics_drilldown_empty),
|
||||
// A range with no spending reads like S-11's empty state; a bucket emptied since S-11 "Inga utgifter längre".
|
||||
stringResource(if (series != null) R.string.analytics_empty else R.string.analytics_drilldown_empty),
|
||||
Modifier.fillMaxWidth().padding(KvittTheme.spacing.s4),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
@@ -111,6 +135,105 @@ fun DrillDownScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (state.dialog == PeriodDialog.CustomPicker && state.series != null) {
|
||||
CustomRangeDialog(state.filter.customStart ?: state.start, state.filter.customEnd ?: state.end, events)
|
||||
}
|
||||
}
|
||||
|
||||
/** S-12's own period chip and "Månad | Vecka" (D-128); measure and group-by stay S-11's and are not shown. */
|
||||
@Composable
|
||||
private fun RangeFilters(filter: AnalyticsFilter, events: PeriodEvents) {
|
||||
val period = periodLabel(filter)
|
||||
AnalyticsFilterBar(
|
||||
period = period,
|
||||
periodSpoken = stringResource(R.string.analytics_period_spoken, period),
|
||||
onPeriod = events::onPeriod,
|
||||
granularity = granularityToggle(filter, events),
|
||||
measure = null,
|
||||
groupBy = null,
|
||||
periodOwnRow = filter.preset == RangePreset.CUSTOM,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* "Utgifter över tid" for one category or account (D-128): the line of its spending per bucket, the dashed range
|
||||
* average and the trailing moving average over [AnalyticsRules.MOVING_AVERAGE_WINDOW] buckets, a legend naming the
|
||||
* three lines, and the caption comparing the latest full bucket with the average.
|
||||
*/
|
||||
@Composable
|
||||
private fun OverTimeLine(state: DrillDownUiState, series: List<SeriesPoint>) {
|
||||
val colors = KvittTheme.colors
|
||||
val category = state.category
|
||||
val color = when {
|
||||
category != null -> colors.category(category.color)
|
||||
state.account != null -> colors.account(state.accountIndex)
|
||||
else -> MaterialTheme.colorScheme.primary
|
||||
}
|
||||
val name = category?.let { categoryName(it) } ?: state.account?.name.orEmpty()
|
||||
val values = series.map { it.ore }
|
||||
val average = AnalyticsRules.rangeAverage(values)
|
||||
val withOverlays = AnalyticsRules.hasAverage(values)
|
||||
val window = AnalyticsRules.MOVING_AVERAGE_WINDOW
|
||||
val legend = listOfNotNull(
|
||||
LineLegendItem(name, LineKind.Series),
|
||||
LineLegendItem(stringResource(R.string.analytics_average), LineKind.Average).takeIf { withOverlays },
|
||||
LineLegendItem(stringResource(R.string.analytics_moving_average, window), LineKind.MovingAverage).takeIf { withOverlays },
|
||||
)
|
||||
ChartCard(stringResource(R.string.analytics_over_time)) {
|
||||
SpendLineChart(
|
||||
points = series.map { LinePoint(it.key, it.ore) },
|
||||
color = color,
|
||||
summary = lineSummary(state, name, series, average),
|
||||
averageOre = average.takeIf { withOverlays },
|
||||
movingAverage = if (withOverlays) AnalyticsRules.movingAverage(values, window) else emptyList(),
|
||||
xCaption = if (state.filter.granularity == Granularity.WEEK) stringResource(R.string.analytics_granularity_week) else null,
|
||||
)
|
||||
LineLegend(legend, color)
|
||||
AnalyticsRules.compareWithAverage(series)?.let { comparison ->
|
||||
Text(comparisonText(comparison), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The chart's TalkBack summary (D-128): "Resor över tid, maj–okt 2026: från 860 kronor i maj 2026 till 9 600 kronor i
|
||||
* oktober 2026, totalt 30 237 kronor, snitt 5 040 kronor."; one bucket: "…: totalt 9 600 kronor."
|
||||
*/
|
||||
@Composable
|
||||
private fun lineSummary(state: DrillDownUiState, name: String, series: List<SeriesPoint>, average: Long): String {
|
||||
val start = state.start
|
||||
val end = state.end
|
||||
val range = if (start != null && end != null) rangeText(start, end, state.filter, LocalKvittClock.current.today, kvittLanguage(), long = true) else ""
|
||||
val first = series.firstOrNull()
|
||||
val last = series.lastOrNull()
|
||||
val total = spokenMoney(series.sumOf { it.ore })
|
||||
return if (first == null || last == null || series.size == 1) {
|
||||
stringResource(R.string.analytics_summary_line_single, name, range, total)
|
||||
} else {
|
||||
stringResource(
|
||||
R.string.analytics_summary_line,
|
||||
name,
|
||||
range,
|
||||
spokenMoney(first.ore),
|
||||
bucketName(first.key, capitalized = false),
|
||||
spokenMoney(last.ore),
|
||||
bucketName(last.key, capitalized = false),
|
||||
total,
|
||||
spokenMoney(average),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** "September 2026: 12 % över snittet", "… under snittet" or "…: på snittet" (D-128). */
|
||||
@Composable
|
||||
private fun comparisonText(comparison: AverageComparison): String {
|
||||
val bucket = bucketName(comparison.key, capitalized = true)
|
||||
val percent = stringResource(DesignR.string.common_percent, comparison.percent.toString())
|
||||
return when (comparison.direction) {
|
||||
AverageComparison.Direction.ABOVE -> stringResource(R.string.analytics_vs_average_above, bucket, percent)
|
||||
AverageComparison.Direction.BELOW -> stringResource(R.string.analytics_vs_average_below, bucket, percent)
|
||||
AverageComparison.Direction.ON -> stringResource(R.string.analytics_vs_average_on, bucket)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -119,15 +242,14 @@ fun DrillDownScreen(
|
||||
*/
|
||||
@Composable
|
||||
internal fun drillDownTitle(state: DrillDownUiState): String {
|
||||
val args = state.args
|
||||
val name = state.category?.let { categoryName(it) } ?: state.account?.name
|
||||
val bucket = args.bucketKey
|
||||
val bucket = state.args.bucketKey
|
||||
val start = state.start
|
||||
val end = state.end
|
||||
return when {
|
||||
name != null && bucket != null -> stringResource(R.string.analytics_drilldown_title, name, bucketName(bucket, capitalized = false))
|
||||
name != null && start != null && end != null ->
|
||||
stringResource(R.string.analytics_drilldown_title, name, rangeText(start, end, args.filter, LocalKvittClock.current.today, kvittLanguage()))
|
||||
stringResource(R.string.analytics_drilldown_title, name, rangeText(start, end, state.filter, LocalKvittClock.current.today, kvittLanguage()))
|
||||
name != null -> name
|
||||
bucket != null -> bucketName(bucket, capitalized = true)
|
||||
else -> ""
|
||||
|
||||
+80
-18
@@ -4,26 +4,42 @@ import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import se.hagstedt.kvitt.model.Account
|
||||
import se.hagstedt.kvitt.model.AccountRepository
|
||||
import se.hagstedt.kvitt.model.AnalyticsFilter
|
||||
import se.hagstedt.kvitt.model.AnalyticsRepository
|
||||
import se.hagstedt.kvitt.model.AnalyticsState
|
||||
import se.hagstedt.kvitt.model.Category
|
||||
import se.hagstedt.kvitt.model.CategoryRepository
|
||||
import se.hagstedt.kvitt.model.DrillDownState
|
||||
import se.hagstedt.kvitt.model.Granularity
|
||||
import se.hagstedt.kvitt.model.RangePreset
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* S-12 (AC-14.8): the expenses behind one number of S-11. The arguments come from the navigation route
|
||||
* ([DrillDownArgs.KEYS]); the title is formatted by the screen from them, not from `DrillDownState.title` (§14.2.4).
|
||||
*
|
||||
* A category or account over the range (D-128) keeps its own range and granularity, starting from S-11's; changing them
|
||||
* re-queries the total, the list and the line chart here only (S-11 keeps its own filter). The series is the group's
|
||||
* column of S-11's own analytics query (`spendSeries`, §14.5.4), so it is computed on the phone from Room (AC-14.1).
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@HiltViewModel
|
||||
class DrillDownViewModel(
|
||||
args: DrillDownArgs,
|
||||
private val args: DrillDownArgs,
|
||||
analytics: AnalyticsRepository,
|
||||
categories: CategoryRepository,
|
||||
accounts: AccountRepository,
|
||||
) : ViewModel() {
|
||||
) : ViewModel(), PeriodEvents {
|
||||
@Inject
|
||||
constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
@@ -32,25 +48,71 @@ class DrillDownViewModel(
|
||||
accounts: AccountRepository,
|
||||
) : this(DrillDownArgs.fromArguments { savedStateHandle.get<String>(it) }, analytics, categories, accounts)
|
||||
|
||||
val state: StateFlow<DrillDownUiState> = combine(
|
||||
analytics.drillDown(args.filter, args.bucketKey, args.categoryId, args.accountId),
|
||||
analytics.analytics(args.filter),
|
||||
categories.all(),
|
||||
accounts.all(),
|
||||
) { data, range, cats, accs ->
|
||||
val index = accs.indexOfFirst { it.id == args.accountId }
|
||||
DrillDownUiState(
|
||||
args = args,
|
||||
data = data,
|
||||
start = range.start,
|
||||
end = range.end,
|
||||
category = cats.firstOrNull { it.id == args.categoryId },
|
||||
account = accs.getOrNull(index),
|
||||
accountIndex = index.coerceAtLeast(0),
|
||||
)
|
||||
private val filter = MutableStateFlow(args.filter)
|
||||
private val dialog = MutableStateFlow(PeriodDialog.None)
|
||||
|
||||
private val queried = filter.flatMapLatest { f ->
|
||||
combine(
|
||||
analytics.drillDown(f, args.bucketKey, args.categoryId, args.accountId),
|
||||
analytics.analytics(AnalyticsRules.seriesFilter(args, f)),
|
||||
) { data, range -> Triple(f, data, range) }
|
||||
}
|
||||
|
||||
val state: StateFlow<DrillDownUiState> = combine(queried, dialog, categories.all(), accounts.all()) { (f, data, range), d, cats, accs ->
|
||||
drillDownUiState(args, f, d, data, range, cats, accs)
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS), DrillDownUiState(args, null, null, null, null, null, 0))
|
||||
|
||||
override fun onPeriod() = dialog.update { PeriodDialog.Menu }
|
||||
|
||||
override fun onDismissPeriod() = dialog.update { PeriodDialog.None }
|
||||
|
||||
override fun onPreset(preset: RangePreset) {
|
||||
if (preset == RangePreset.CUSTOM) {
|
||||
dialog.update { PeriodDialog.CustomPicker }
|
||||
} else {
|
||||
filter.update { AnalyticsRules.withPreset(it, preset) }
|
||||
dialog.update { PeriodDialog.None }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCustomRange(start: String?, end: String?) {
|
||||
val applied = AnalyticsRules.withCustomRange(filter.value, start, end) ?: return
|
||||
filter.update { applied }
|
||||
dialog.update { PeriodDialog.None }
|
||||
}
|
||||
|
||||
override fun onGranularity(granularity: Granularity) = filter.update { it.copy(granularity = granularity) }
|
||||
|
||||
private companion object {
|
||||
const val STOP_TIMEOUT_MILLIS = 5_000L
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* S-12's state from one round of queries: the drill-down [data], the [range] of the current [filter] (whose buckets
|
||||
* carry the group's series, D-128), and the categories and accounts behind the title and header.
|
||||
*/
|
||||
internal fun drillDownUiState(
|
||||
args: DrillDownArgs,
|
||||
filter: AnalyticsFilter,
|
||||
dialog: PeriodDialog,
|
||||
data: DrillDownState,
|
||||
range: AnalyticsState,
|
||||
categories: List<Category>,
|
||||
accounts: List<Account>,
|
||||
): DrillDownUiState {
|
||||
val index = accounts.indexOfFirst { it.id == args.accountId }
|
||||
val groupId = args.categoryId?.value ?: args.accountId?.value
|
||||
return DrillDownUiState(
|
||||
args = args,
|
||||
data = data,
|
||||
start = range.start,
|
||||
end = range.end,
|
||||
category = categories.firstOrNull { it.id == args.categoryId },
|
||||
account = accounts.getOrNull(index),
|
||||
accountIndex = index.coerceAtLeast(0),
|
||||
filter = filter,
|
||||
dialog = dialog,
|
||||
series = if (AnalyticsRules.isOverRange(args) && groupId != null) AnalyticsRules.groupSeries(range, groupId) else null,
|
||||
)
|
||||
}
|
||||
|
||||
+2
-2
@@ -30,7 +30,7 @@ import se.hagstedt.kvitt.model.RangePreset
|
||||
|
||||
/** The period menu below the chip (design-spec S-11: the presets of AC-14.2, then "Anpassad…"). */
|
||||
@Composable
|
||||
internal fun PeriodMenuPopup(selected: RangePreset, events: AnalyticsEvents) {
|
||||
internal fun PeriodMenuPopup(selected: RangePreset, events: PeriodEvents) {
|
||||
val below = with(LocalDensity.current) { (KvittDimens.filterButtonMin + KvittTheme.spacing.s1).roundToPx() }
|
||||
Popup(alignment = Alignment.TopStart, offset = IntOffset(0, below), onDismissRequest = events::onDismissPeriod, properties = PopupProperties(focusable = true)) {
|
||||
PeriodMenu(selected, events::onPreset)
|
||||
@@ -52,7 +52,7 @@ internal fun PeriodMenu(selected: RangePreset, onPreset: (RangePreset) -> Unit,
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun CustomRangeDialog(start: String?, end: String?, events: AnalyticsEvents) {
|
||||
internal fun CustomRangeDialog(start: String?, end: String?, events: PeriodEvents) {
|
||||
Dialog(onDismissRequest = events::onDismissPeriod, properties = DialogProperties(usePlatformDefaultWidth = false)) {
|
||||
CustomRangePicker(start, end, events::onCustomRange, events::onDismissPeriod)
|
||||
}
|
||||
|
||||
@@ -43,4 +43,11 @@
|
||||
<string name="analytics_measure">Mått</string>
|
||||
<string name="analytics_group_by">Gruppera efter</string>
|
||||
<string name="analytics_range_apply">Använd</string>
|
||||
<string name="analytics_average">Snitt</string>
|
||||
<string name="analytics_moving_average">Glidande snitt (%1$d)</string>
|
||||
<string name="analytics_vs_average_above">%1$s: %2$s över snittet</string>
|
||||
<string name="analytics_vs_average_below">%1$s: %2$s under snittet</string>
|
||||
<string name="analytics_vs_average_on">%1$s: på snittet</string>
|
||||
<string name="analytics_summary_line">%1$s över tid, %2$s: från %3$s i %4$s till %5$s i %6$s, totalt %7$s, snitt %8$s.</string>
|
||||
<string name="analytics_summary_line_single">%1$s över tid, %2$s: totalt %3$s.</string>
|
||||
</resources>
|
||||
|
||||
@@ -43,4 +43,11 @@
|
||||
<string name="analytics_measure">Measure</string>
|
||||
<string name="analytics_group_by">Group by</string>
|
||||
<string name="analytics_range_apply">Apply</string>
|
||||
<string name="analytics_average">Average</string>
|
||||
<string name="analytics_moving_average">Moving average (%1$d)</string>
|
||||
<string name="analytics_vs_average_above">%1$s: %2$s above average</string>
|
||||
<string name="analytics_vs_average_below">%1$s: %2$s below average</string>
|
||||
<string name="analytics_vs_average_on">%1$s: on average</string>
|
||||
<string name="analytics_summary_line">%1$s over time, %2$s: from %3$s in %4$s to %5$s in %6$s, %7$s in total, average %8$s.</string>
|
||||
<string name="analytics_summary_line_single">%1$s over time, %2$s: %3$s in total.</string>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user