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