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:
+194
@@ -0,0 +1,194 @@
|
||||
package se.hagstedt.kvitt.fakes.analytics
|
||||
|
||||
import se.hagstedt.kvitt.fakes.FakeExpense
|
||||
import se.hagstedt.kvitt.fakes.FakeScenario
|
||||
import se.hagstedt.kvitt.fakes.FakeScenarios
|
||||
import se.hagstedt.kvitt.model.Account
|
||||
import se.hagstedt.kvitt.model.AccountId
|
||||
import se.hagstedt.kvitt.model.AnalyticsFilter
|
||||
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.ExpenseId
|
||||
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.Share
|
||||
import se.hagstedt.kvitt.model.SplitMode
|
||||
import se.hagstedt.kvitt.model.UserId
|
||||
|
||||
/**
|
||||
* Scenarios of S-11 (analytics) and S-12 (drill-down), M7e. All share FakeScenarios.empty's household and clock
|
||||
* (today 2026-10-17); the expense data is plain and deterministic so every screenshot is reproducible.
|
||||
*
|
||||
* - [loaded]: May to October 2026, eleven default categories, four accounts (one archived), settlements none.
|
||||
* - [offline]: [loaded] while offline, with pending local changes that analytics already include (AC-14.1).
|
||||
* - [loading]: the first sync has not finished: nothing synced yet, no categories, no accounts, no expenses.
|
||||
* - [long]: long Swedish category and account names, 7-digit totals, many groups.
|
||||
*
|
||||
* The filters of the named S-11 states are [defaultFilter], [weekFilter] and [emptyFilter]; [customPickerStart] and
|
||||
* [customPickerEnd] are the custom range shown in the picker frame (a past month, so the picker never marks today).
|
||||
*/
|
||||
object AnalyticsScenarios {
|
||||
private val me = FakeScenarios.owner.id
|
||||
private val partner = FakeScenarios.partner.id
|
||||
|
||||
val vardag = account(0x01, "Vardag", "2025-01-10T09:00:00.000Z")
|
||||
val huset = account(0x02, "Huset", "2025-03-02T09:00:00.000Z")
|
||||
val japan = account(0x03, "Japan 2027", "2026-06-01T09:00:00.000Z")
|
||||
val stugan = account(0x04, "Stugan", "2025-05-20T09:00:00.000Z", archived = true)
|
||||
|
||||
private fun category(key: DefaultCategoryKey): Category = FakeScenarios.defaultCategories.first { it.defaultKey == key }
|
||||
|
||||
val groceries = category(DefaultCategoryKey.GROCERIES)
|
||||
val restaurants = category(DefaultCategoryKey.RESTAURANTS)
|
||||
val housing = category(DefaultCategoryKey.HOUSING)
|
||||
val home = category(DefaultCategoryKey.HOME)
|
||||
val utilities = category(DefaultCategoryKey.UTILITIES)
|
||||
val insurance = category(DefaultCategoryKey.INSURANCE)
|
||||
val transport = category(DefaultCategoryKey.TRANSPORT)
|
||||
val travel = category(DefaultCategoryKey.TRAVEL)
|
||||
val entertainment = category(DefaultCategoryKey.ENTERTAINMENT)
|
||||
val health = category(DefaultCategoryKey.HEALTH)
|
||||
val shopping = category(DefaultCategoryKey.SHOPPING)
|
||||
|
||||
/** AC-14.2 to AC-14.5 defaults: last 6 months, month, household, category. */
|
||||
val defaultFilter = AnalyticsFilter(RangePreset.LAST_6_MONTHS, null, null, Granularity.MONTH, Measure.HOUSEHOLD, GroupBy.CATEGORY)
|
||||
|
||||
/** S-11-analytics-week: custom 24 Aug to 17 Oct, week, my share, by account. */
|
||||
val weekFilter = AnalyticsFilter(RangePreset.CUSTOM, "2026-08-24", "2026-10-17", Granularity.WEEK, Measure.MY_SHARE, GroupBy.ACCOUNT)
|
||||
|
||||
/** S-11-analytics-empty: custom 1 to 30 November, a period without expenses. */
|
||||
val emptyFilter = AnalyticsFilter(RangePreset.CUSTOM, "2026-11-01", "2026-11-30", Granularity.MONTH, Measure.HOUSEHOLD, GroupBy.CATEGORY)
|
||||
|
||||
const val customPickerStart = "2025-03-03"
|
||||
const val customPickerEnd = "2025-03-23"
|
||||
|
||||
private class Line(val day: Int, val description: String, val category: Category, val account: Account, val ore: Long, val paidByMe: Boolean)
|
||||
|
||||
/** Recurring monthly spending; travel and the archived account vary by month. */
|
||||
private fun month(month: Int): List<Line> {
|
||||
val m = month.toLong()
|
||||
val lines = mutableListOf(
|
||||
Line(3, "Hyra", housing, huset, 1_033_300, paidByMe = month % 2 == 0),
|
||||
Line(5, "Hemförsäkring", insurance, huset, 32_900, paidByMe = true),
|
||||
Line(9, "Elräkning", utilities, huset, 98_000 + m * 4_150, paidByMe = false),
|
||||
Line(4, "Willys Hornstull", groceries, vardag, 152_045 + m * 3_120, paidByMe = true),
|
||||
Line(11, "ICA Maxi Lindhagen", groceries, vardag, 118_760 + m * 2_480, paidByMe = false),
|
||||
Line(19, "Coop Liljeholmen", groceries, vardag, 136_510 + m * 1_970, paidByMe = true),
|
||||
Line(25, "Hemköp Odenplan", groceries, vardag, 97_300 + m * 2_210, paidByMe = false),
|
||||
Line(8, "Pizzeria Mamma Mia", restaurants, vardag, 48_400 + m * 1_230, paidByMe = false),
|
||||
Line(22, "Café Pascal", restaurants, vardag, 21_600 + m * 980, paidByMe = true),
|
||||
Line(14, "SL-kort", transport, vardag, 98_000, paidByMe = month % 2 == 1),
|
||||
Line(13, "Biljetter Dramaten", entertainment, vardag, 42_000 + m * 2_300, paidByMe = true),
|
||||
Line(17, "Apotek Hjärtat", health, vardag, 18_950 + m * 1_440, paidByMe = false),
|
||||
Line(27, "Clas Ohlson", home, vardag, 38_990 + m * 3_330, paidByMe = true),
|
||||
Line(21, "Åhléns City", shopping, vardag, 64_500 + m * 2_750, paidByMe = false),
|
||||
)
|
||||
when (month) {
|
||||
5 -> lines += Line(16, "Färja till stugan", travel, stugan, 86_000, paidByMe = true)
|
||||
6 -> lines += Line(20, "Midsommar i stugan", travel, stugan, 312_400, paidByMe = false)
|
||||
7 -> lines += Line(6, "Tåg till Göteborg", travel, vardag, 412_800, paidByMe = true)
|
||||
8 -> lines += Line(12, "Hotell i Visby", travel, vardag, 156_000, paidByMe = false)
|
||||
9 -> {
|
||||
lines += Line(15, "Flyg till Tokyo", travel, japan, 1_384_000, paidByMe = true)
|
||||
lines += Line(23, "Ryokan i Kyoto", travel, japan, 412_600, paidByMe = false)
|
||||
}
|
||||
10 -> lines += Line(2, "Japan Rail Pass", travel, japan, 960_000, paidByMe = true)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
/** October's groceries are the four rows of the S-12 frame (3 910,00 kr in total). */
|
||||
private val october = listOf(
|
||||
Line(16, "ICA Maxi Lindhagen", groceries, vardag, 124_760, paidByMe = false),
|
||||
Line(12, "Willys Hornstull", groceries, vardag, 86_435, paidByMe = true),
|
||||
Line(7, "ICA Nära Hornstull", groceries, vardag, 78_560, paidByMe = false),
|
||||
Line(1, "Lidl Liljeholmen", groceries, vardag, 101_245, paidByMe = true),
|
||||
)
|
||||
|
||||
private fun lines(): List<Pair<String, Line>> {
|
||||
val earlier = (5..9).flatMap { m -> month(m).map { date(2026, m, it.day) to it } }
|
||||
val oct = month(10).filter { it.category != groceries && it.day <= 17 }.map { date(2026, 10, it.day) to it } +
|
||||
october.map { date(2026, 10, it.day) to it }
|
||||
return earlier + oct
|
||||
}
|
||||
|
||||
private val expenses: List<FakeExpense> = lines().mapIndexed { index, (date, line) -> expense(index + 1, date, line) }
|
||||
|
||||
val loaded: FakeScenario = FakeScenarios.empty.copy(
|
||||
accounts = listOf(vardag, huset, japan, stugan),
|
||||
expenses = expenses,
|
||||
defaultAccountId = vardag.id,
|
||||
)
|
||||
|
||||
/** Offline with two pending changes: an October expense edited offline and a new one, both counted (AC-14.1). */
|
||||
val offline: FakeScenario = loaded.copy(
|
||||
online = false,
|
||||
expenses = expenses.map { if (it.description == "ICA Maxi Lindhagen" && it.date == "2026-10-16") it.copy(pending = true) else it } +
|
||||
expense(900, "2026-10-17", Line(17, "Systembolaget", groceries, vardag, 46_900, paidByMe = true)).copy(pending = true),
|
||||
)
|
||||
|
||||
/** First sync running: nothing on the phone yet (SPEC §13.3 "loading (first sync only)"). */
|
||||
val loading: FakeScenario = FakeScenarios.empty.copy(
|
||||
categories = emptyList(),
|
||||
lastSuccessAt = null,
|
||||
syncRunning = true,
|
||||
)
|
||||
|
||||
private val longGroceries = Category(CategoryId(id(0xc1)), null, "Livsmedel och hushållsartiklar till sommarstugan", "shopping_cart", ColorToken.GREEN, 110, archived = false)
|
||||
private val longRenovation = Category(CategoryId(id(0xc2)), null, "Renoveringsprojektet i källarförrådet", "build", ColorToken.AMBER, 120, archived = false)
|
||||
private val longChildren = Category(CategoryId(id(0xc3)), null, "Barnens fritidsaktiviteter och utrustning", "child_care", ColorToken.LIME, 130, archived = true)
|
||||
private val longAccount = account(0x05, "Sommarstugan i Österåker 2027", "2025-02-01T09:00:00.000Z")
|
||||
private val longArchived = account(0x06, "Gemensamma sparkontot för bilen", "2025-04-01T09:00:00.000Z", archived = true)
|
||||
|
||||
/** Long Swedish names and 7-digit totals over every default category and three long custom ones. */
|
||||
val long: FakeScenario = run {
|
||||
val categories = FakeScenarios.defaultCategories + listOf(longGroceries, longRenovation, longChildren)
|
||||
val accounts = listOf(vardag, longAccount, longArchived, huset)
|
||||
val rows = (5..10).flatMap { m ->
|
||||
categories.mapIndexed { i, category ->
|
||||
val account = accounts[(i + m) % accounts.size]
|
||||
val day = ((i * 3 + m) % 17) + 1
|
||||
val ore = 4_812_345L + i * 1_377_000L + m * 213_500L
|
||||
date(2026, m, day) to Line(day, "Inköp ${i + 1} för ${category.customName ?: category.defaultKey?.name?.lowercase()}", category, account, ore, paidByMe = (i + m) % 2 == 0)
|
||||
}
|
||||
}
|
||||
FakeScenarios.empty.copy(
|
||||
accounts = accounts,
|
||||
categories = categories,
|
||||
expenses = rows.mapIndexed { index, (date, line) -> expense(2000 + index, date, line) },
|
||||
)
|
||||
}
|
||||
|
||||
/** The long category of [long] used for its drill-down. */
|
||||
val longCategory: Category get() = longGroceries
|
||||
|
||||
private fun expense(n: Int, date: String, line: Line): FakeExpense {
|
||||
val payer = if (line.paidByMe) me else partner
|
||||
val other = if (line.paidByMe) partner else me
|
||||
val otherShare = line.ore / 2 // §9.1: the payer gets the extra öre
|
||||
return FakeExpense(
|
||||
id = ExpenseId("01920000-0000-7000-8000-e" + n.toString(16).padStart(11, '0')),
|
||||
accountId = line.account.id,
|
||||
categoryId = line.category.id,
|
||||
amountOre = line.ore,
|
||||
description = line.description,
|
||||
notes = "",
|
||||
date = date,
|
||||
payer = payer,
|
||||
mode = SplitMode.DEFAULT,
|
||||
shares = listOf(Share(payer, line.ore - otherShare, 5000), Share(other, otherShare, 5000)),
|
||||
createdAt = "${date}T12:00:00.000Z",
|
||||
)
|
||||
}
|
||||
|
||||
private fun account(n: Int, name: String, createdAt: String, archived: Boolean = false) =
|
||||
Account(AccountId(id(0xa00 + n)), name, mapOf(me to 5000, partner to 5000), createdAt, archived)
|
||||
|
||||
private fun id(n: Int): String = "01920000-0000-7000-8000-" + n.toString(16).padStart(12, '0')
|
||||
|
||||
private fun date(year: Int, month: Int, day: Int): String = "$year-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}"
|
||||
}
|
||||
@@ -1,4 +1,20 @@
|
||||
// :feature:analytics — Analytics and drill-down (S-11, S-12; M7e). Convention: android/README.md.
|
||||
// :feature:analytics — Analytics (S-11) and drill-down (S-12), M7e. Convention: android/README.md. :core:model,
|
||||
// :core:design and (tests and debug only) :core:fakes come from the kvitt.android.feature plugin.
|
||||
plugins {
|
||||
id("kvitt.android.feature")
|
||||
alias(libs.plugins.roborazzi)
|
||||
}
|
||||
|
||||
roborazzi {
|
||||
outputDir.set(file("src/test/screenshots"))
|
||||
}
|
||||
|
||||
dependencies {
|
||||
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
testImplementation(libs.androidx.compose.ui.test.junit4)
|
||||
testImplementation(libs.robolectric)
|
||||
testImplementation(libs.roborazzi)
|
||||
testImplementation(libs.roborazzi.compose)
|
||||
testImplementation(libs.roborazzi.junit.rule)
|
||||
}
|
||||
|
||||
-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>
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
package se.hagstedt.kvitt.feature.analytics
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import se.hagstedt.kvitt.model.AccountId
|
||||
import se.hagstedt.kvitt.model.AnalyticsFilter
|
||||
import se.hagstedt.kvitt.model.AnalyticsState
|
||||
import se.hagstedt.kvitt.model.CategoryId
|
||||
import se.hagstedt.kvitt.model.Granularity
|
||||
import se.hagstedt.kvitt.model.GroupBy
|
||||
import se.hagstedt.kvitt.model.GroupKey
|
||||
import se.hagstedt.kvitt.model.Measure
|
||||
import se.hagstedt.kvitt.model.RangePreset
|
||||
import se.hagstedt.kvitt.model.SyncSummary
|
||||
|
||||
class AnalyticsRulesTest {
|
||||
private val defaults = AnalyticsRules.defaultFilter
|
||||
|
||||
@Test
|
||||
fun defaultsAreThoseOfAc14() {
|
||||
assertEquals(AnalyticsFilter(RangePreset.LAST_6_MONTHS, null, null, Granularity.MONTH, Measure.HOUSEHOLD, GroupBy.CATEGORY), defaults)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun applyNeedsBothDates() {
|
||||
assertFalse(AnalyticsRules.canApply(null, null))
|
||||
assertFalse(AnalyticsRules.canApply(1L, null))
|
||||
assertFalse(AnalyticsRules.canApply(null, 1L))
|
||||
assertTrue(AnalyticsRules.canApply(1L, 1L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun customRangeWithAMissingDateIsNotApplied() {
|
||||
assertNull(AnalyticsRules.withCustomRange(defaults, "2026-11-01", null))
|
||||
assertNull(AnalyticsRules.withCustomRange(defaults, null, "2026-11-30"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun customRangeIsOrdered() {
|
||||
val applied = AnalyticsRules.withCustomRange(defaults, "2026-11-30", "2026-11-01")
|
||||
assertEquals(RangePreset.CUSTOM, applied?.preset)
|
||||
assertEquals("2026-11-01", applied?.customStart)
|
||||
assertEquals("2026-11-30", applied?.customEnd)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aPresetDropsTheCustomDatesAndKeepsTheToggles() {
|
||||
val custom = AnalyticsFilter(RangePreset.CUSTOM, "2026-01-01", "2026-02-01", Granularity.WEEK, Measure.MY_SHARE, GroupBy.ACCOUNT)
|
||||
assertEquals(AnalyticsFilter(RangePreset.THIS_YEAR, null, null, Granularity.WEEK, Measure.MY_SHARE, GroupBy.ACCOUNT), AnalyticsRules.withPreset(custom, RangePreset.THIS_YEAR))
|
||||
assertEquals(custom, AnalyticsRules.withPreset(custom, RangePreset.CUSTOM))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pickerMillisRoundTripInUtc() {
|
||||
assertEquals(0L, AnalyticsRules.millis("1970-01-01"))
|
||||
assertEquals("2026-10-17", AnalyticsRules.isoDate(AnalyticsRules.millis("2026-10-17")))
|
||||
assertEquals("2026-10-17", AnalyticsRules.isoDate(AnalyticsRules.millis("2026-10-17") + 86_399_999L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyMeansNoSpendInTheRange() {
|
||||
val empty = AnalyticsState(defaults, "2026-05-01", "2026-10-17", emptyList(), emptyList(), emptyList(), emptyList(), 0)
|
||||
assertTrue(AnalyticsRules.isEmpty(empty))
|
||||
val key = GroupKey("c", null, null)
|
||||
assertFalse(AnalyticsRules.isEmpty(empty.copy(byCategory = listOf(key to 100L), totalOre = 100L)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadingOnlyBeforeTheFirstSync() {
|
||||
assertTrue(AnalyticsRules.isFirstSync(SyncSummary(null, 0, 0, clockSkew = false, running = true)))
|
||||
assertFalse(AnalyticsRules.isFirstSync(SyncSummary("2026-10-17T06:00:00.000Z", 3, 0, clockSkew = false, running = false)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun drillDownArgumentsRoundTrip() {
|
||||
val args = DrillDownArgs(AnalyticsFilter(RangePreset.CUSTOM, "2026-08-24", "2026-10-17", Granularity.WEEK, Measure.MY_SHARE, GroupBy.ACCOUNT), "2026-W41", CategoryId("c1"), AccountId("a1"))
|
||||
val map = args.toArguments()
|
||||
assertTrue(map.keys.all { it in DrillDownArgs.KEYS })
|
||||
assertEquals(args, DrillDownArgs.fromArguments { map[it] })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun missingOrUnknownArgumentsFallBackToTheDefaults() {
|
||||
val args = DrillDownArgs.fromArguments { if (it == DrillDownArgs.KEY_PRESET) "bogus" else null }
|
||||
assertEquals(DrillDownArgs(defaults, null, null, null), args)
|
||||
}
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
package se.hagstedt.kvitt.feature.analytics
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import se.hagstedt.kvitt.fakes.FakeRepositories
|
||||
import se.hagstedt.kvitt.fakes.FakeScenario
|
||||
import se.hagstedt.kvitt.fakes.analytics.AnalyticsScenarios
|
||||
import se.hagstedt.kvitt.model.Granularity
|
||||
import se.hagstedt.kvitt.model.GroupBy
|
||||
import se.hagstedt.kvitt.model.Measure
|
||||
import se.hagstedt.kvitt.model.RangePreset
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class AnalyticsViewModelTest {
|
||||
@Before
|
||||
fun setUp() = Dispatchers.setMain(UnconfinedTestDispatcher())
|
||||
|
||||
@After
|
||||
fun tearDown() = Dispatchers.resetMain()
|
||||
|
||||
private fun viewModel(scenario: FakeScenario): AnalyticsViewModel {
|
||||
val fakes = FakeRepositories(scenario)
|
||||
return AnalyticsViewModel(fakes.analytics, fakes.categories, fakes.accounts, fakes.sync)
|
||||
}
|
||||
|
||||
private suspend fun AnalyticsViewModel.ready(): AnalyticsContent.Ready = state.first { it.content is AnalyticsContent.Ready }.content as AnalyticsContent.Ready
|
||||
|
||||
@Test
|
||||
fun startsWithTheDefaultFilter() = runTest {
|
||||
val vm = viewModel(AnalyticsScenarios.loaded)
|
||||
val ready = vm.ready()
|
||||
assertEquals(AnalyticsRules.defaultFilter, ready.data.filter)
|
||||
assertEquals("2026-05-01", ready.data.start)
|
||||
assertEquals(6, ready.data.buckets.size)
|
||||
assertEquals(4, ready.accounts.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun togglesReQueryAtOnce() = runTest {
|
||||
val vm = viewModel(AnalyticsScenarios.loaded)
|
||||
vm.ready()
|
||||
vm.onGranularity(Granularity.WEEK)
|
||||
vm.onMeasure(Measure.MY_SHARE)
|
||||
vm.onGroupBy(GroupBy.ACCOUNT)
|
||||
val data = vm.ready().data
|
||||
assertEquals(Granularity.WEEK, data.filter.granularity)
|
||||
assertEquals(Measure.MY_SHARE, data.filter.measure)
|
||||
assertEquals(GroupBy.ACCOUNT, data.filter.groupBy)
|
||||
assertTrue(data.buckets.all { "-W" in it.key })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun customOpensThePickerAndAppliesOnlyWithBothDates() = runTest {
|
||||
val vm = viewModel(AnalyticsScenarios.loaded)
|
||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { vm.state.collect {} }
|
||||
vm.onPeriod()
|
||||
assertEquals(PeriodDialog.Menu, vm.state.value.dialog)
|
||||
vm.onPreset(RangePreset.CUSTOM)
|
||||
assertEquals(PeriodDialog.CustomPicker, vm.state.value.dialog)
|
||||
vm.onCustomRange("2026-11-01", null)
|
||||
assertEquals(PeriodDialog.CustomPicker, vm.state.value.dialog)
|
||||
assertEquals(RangePreset.LAST_6_MONTHS, vm.state.value.filter.preset)
|
||||
vm.onCustomRange("2026-11-01", "2026-11-30")
|
||||
assertEquals(PeriodDialog.None, vm.state.value.dialog)
|
||||
assertEquals(AnalyticsScenarios.emptyFilter, vm.state.value.filter)
|
||||
assertTrue(AnalyticsRules.isEmpty(vm.ready().data))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aPresetClosesTheMenu() = runTest {
|
||||
val vm = viewModel(AnalyticsScenarios.loaded)
|
||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { vm.state.collect {} }
|
||||
vm.onPeriod()
|
||||
vm.onPreset(RangePreset.THIS_MONTH)
|
||||
assertEquals(PeriodDialog.None, vm.state.value.dialog)
|
||||
assertEquals("2026-10-01", vm.ready().data.start)
|
||||
vm.onPeriod()
|
||||
vm.onDismissPeriod()
|
||||
assertEquals(PeriodDialog.None, vm.state.value.dialog)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun firstSyncIsLoading() = runTest {
|
||||
val vm = viewModel(AnalyticsScenarios.loading)
|
||||
assertEquals(AnalyticsContent.Loading, vm.state.first { it.content != null }.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun offlineWorksAndCountsPendingChanges() = runTest {
|
||||
val online = viewModel(AnalyticsScenarios.loaded).ready().data.totalOre
|
||||
val offline = viewModel(AnalyticsScenarios.offline).ready().data.totalOre
|
||||
assertEquals(online + 46_900L, offline)
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package se.hagstedt.kvitt.feature.analytics
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import se.hagstedt.kvitt.fakes.FakeRepositories
|
||||
import se.hagstedt.kvitt.fakes.analytics.AnalyticsScenarios
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class DrillDownViewModelTest {
|
||||
@Before
|
||||
fun setUp() = Dispatchers.setMain(UnconfinedTestDispatcher())
|
||||
|
||||
@After
|
||||
fun tearDown() = Dispatchers.resetMain()
|
||||
|
||||
private suspend fun load(args: DrillDownArgs): DrillDownUiState {
|
||||
val fakes = FakeRepositories(AnalyticsScenarios.loaded)
|
||||
val vm = DrillDownViewModel(args, fakes.analytics, fakes.categories, fakes.accounts)
|
||||
return vm.state.first { it.data != null && it.start != null }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun categoryInABucketIsTheFourOctoberGroceries() = runTest {
|
||||
val state = load(DrillDownArgs(AnalyticsRules.defaultFilter, "2026-10", AnalyticsScenarios.groceries.id, null))
|
||||
val data = checkNotNull(state.data)
|
||||
assertEquals(391_000L, data.totalOre)
|
||||
assertEquals(listOf("ICA Maxi Lindhagen", "Willys Hornstull", "ICA Nära Hornstull", "Lidl Liljeholmen"), data.rows.map { it.description })
|
||||
assertEquals(AnalyticsScenarios.groceries, state.category)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun accountKeepsItsChartPosition() = runTest {
|
||||
val state = load(DrillDownArgs(AnalyticsRules.defaultFilter, null, null, AnalyticsScenarios.japan.id))
|
||||
assertEquals(AnalyticsScenarios.japan, state.account)
|
||||
assertEquals(3, state.accountIndex) // created_at order: Vardag, Huset, Stugan, Japan 2027
|
||||
assertEquals("2026-05-01", state.start)
|
||||
assertEquals(1_384_000L + 412_600L + 960_000L, state.data?.totalOre)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bucketAloneListsEveryGroup() = runTest {
|
||||
val state = load(DrillDownArgs(AnalyticsRules.defaultFilter, "2026-10", null, null))
|
||||
assertEquals(null, state.category)
|
||||
assertEquals(state.data?.rows?.sumOf { it.amountOre }, state.data?.totalOre)
|
||||
}
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
package se.hagstedt.kvitt.feature.analytics.screenshot
|
||||
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.ParameterizedRobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
import se.hagstedt.kvitt.design.KvittTheme
|
||||
import se.hagstedt.kvitt.fakes.FakeRepositories
|
||||
import se.hagstedt.kvitt.fakes.FakeScenario
|
||||
import se.hagstedt.kvitt.fakes.analytics.AnalyticsScenarios
|
||||
import se.hagstedt.kvitt.feature.analytics.AnalyticsContent
|
||||
import se.hagstedt.kvitt.feature.analytics.AnalyticsEvents
|
||||
import se.hagstedt.kvitt.feature.analytics.AnalyticsRules
|
||||
import se.hagstedt.kvitt.feature.analytics.AnalyticsScreen
|
||||
import se.hagstedt.kvitt.feature.analytics.AnalyticsUiState
|
||||
import se.hagstedt.kvitt.feature.analytics.CustomRangePicker
|
||||
import se.hagstedt.kvitt.feature.analytics.PeriodDialog
|
||||
import se.hagstedt.kvitt.feature.analytics.PeriodMenu
|
||||
import se.hagstedt.kvitt.model.AnalyticsFilter
|
||||
import se.hagstedt.kvitt.model.Granularity
|
||||
import se.hagstedt.kvitt.model.GroupBy
|
||||
import se.hagstedt.kvitt.model.Measure
|
||||
import se.hagstedt.kvitt.model.RangePreset
|
||||
|
||||
/**
|
||||
* S-11 in every state of design-spec S-11 and PLAN-M7e §5, × light/dark × sv/en (plus 200 % for long content).
|
||||
* Re-record with `tools/gradle-serial.sh :feature:analytics:recordRoborazziDebug` and open every changed PNG.
|
||||
*/
|
||||
@RunWith(ParameterizedRobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [36], qualifiers = "w411dp-h891dp-xhdpi")
|
||||
class AnalyticsScreenshotTest(private val name: String) {
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun screen() {
|
||||
val state = states.getValue(name)
|
||||
ScreenSnapshots.snap(compose, name, largeFont = name == "s11_long", content = state)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val defaults = AnalyticsRules.defaultFilter
|
||||
|
||||
private fun ui(scenario: FakeScenario, filter: AnalyticsFilter, dialog: PeriodDialog = PeriodDialog.None): AnalyticsUiState = runBlocking {
|
||||
val fakes = FakeRepositories(scenario)
|
||||
val sync = fakes.sync.status().first()
|
||||
val content = if (AnalyticsRules.isFirstSync(sync.summary)) {
|
||||
AnalyticsContent.Loading
|
||||
} else {
|
||||
AnalyticsContent.Ready(fakes.analytics.analytics(filter).first(), fakes.categories.all().first(), fakes.accounts.all().first())
|
||||
}
|
||||
AnalyticsUiState(filter, dialog, content)
|
||||
}
|
||||
|
||||
private fun screen(state: AnalyticsUiState, scrolled: Boolean = false): @Composable () -> Unit = {
|
||||
val scroll = remember { ScrollState(if (scrolled) Int.MAX_VALUE else 0) }
|
||||
AnalyticsScreen(state, NoEvents, {}, {}, scrollState = scroll)
|
||||
}
|
||||
|
||||
private val presets = listOf(RangePreset.THIS_MONTH, RangePreset.LAST_3_MONTHS, RangePreset.LAST_12_MONTHS, RangePreset.THIS_YEAR, RangePreset.ALL_TIME)
|
||||
|
||||
val states: Map<String, @Composable () -> Unit> = buildMap {
|
||||
put("s11_loaded", screen(ui(AnalyticsScenarios.loaded, defaults)))
|
||||
put("s11_scrolled", screen(ui(AnalyticsScenarios.loaded, defaults), scrolled = true))
|
||||
put("s11_by_account", screen(ui(AnalyticsScenarios.loaded, defaults.copy(groupBy = GroupBy.ACCOUNT))))
|
||||
put("s11_my_share", screen(ui(AnalyticsScenarios.loaded, defaults.copy(measure = Measure.MY_SHARE))))
|
||||
put("s11_week", screen(ui(AnalyticsScenarios.loaded, AnalyticsScenarios.weekFilter)))
|
||||
put("s11_week_by_category", screen(ui(AnalyticsScenarios.loaded, defaults.copy(preset = RangePreset.LAST_3_MONTHS, granularity = Granularity.WEEK))))
|
||||
presets.forEach { preset -> put("s11_preset_${preset.name.lowercase()}", screen(ui(AnalyticsScenarios.loaded, defaults.copy(preset = preset)))) }
|
||||
put("s11_period_menu") {
|
||||
Box(Modifier.padding(KvittTheme.spacing.gutter)) { PeriodMenu(RangePreset.LAST_6_MONTHS, {}) }
|
||||
}
|
||||
put("s11_custom_picker") { CustomRangePicker(AnalyticsScenarios.customPickerStart, AnalyticsScenarios.customPickerEnd, { _, _ -> }, {}) }
|
||||
put("s11_custom_picker_start_only") { CustomRangePicker(AnalyticsScenarios.customPickerStart, null, { _, _ -> }, {}) }
|
||||
put("s11_empty", screen(ui(AnalyticsScenarios.loaded, AnalyticsScenarios.emptyFilter)))
|
||||
put("s11_offline", screen(ui(AnalyticsScenarios.offline, defaults.copy(preset = RangePreset.THIS_MONTH))))
|
||||
put("s11_loading", screen(ui(AnalyticsScenarios.loading, defaults)))
|
||||
put("s11_long", screen(ui(AnalyticsScenarios.long, defaults)))
|
||||
put("s11_long_scrolled", screen(ui(AnalyticsScenarios.long, defaults), scrolled = true))
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@ParameterizedRobolectricTestRunner.Parameters(name = "{0}")
|
||||
fun names(): List<Array<Any>> = states.keys.map { arrayOf<Any>(it) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Events that do nothing: screenshots render a fixed state. */
|
||||
internal object NoEvents : AnalyticsEvents {
|
||||
override fun onPeriod() = Unit
|
||||
override fun onDismissPeriod() = Unit
|
||||
override fun onPreset(preset: RangePreset) = Unit
|
||||
override fun onCustomRange(start: String?, end: String?) = Unit
|
||||
override fun onGranularity(granularity: Granularity) = Unit
|
||||
override fun onMeasure(measure: Measure) = Unit
|
||||
override fun onGroupBy(groupBy: GroupBy) = Unit
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
package se.hagstedt.kvitt.feature.analytics.screenshot
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.ParameterizedRobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
import se.hagstedt.kvitt.fakes.FakeRepositories
|
||||
import se.hagstedt.kvitt.fakes.FakeScenario
|
||||
import se.hagstedt.kvitt.fakes.analytics.AnalyticsScenarios
|
||||
import se.hagstedt.kvitt.feature.analytics.AnalyticsRules
|
||||
import se.hagstedt.kvitt.feature.analytics.DrillDownArgs
|
||||
import se.hagstedt.kvitt.feature.analytics.DrillDownScreen
|
||||
import se.hagstedt.kvitt.feature.analytics.DrillDownUiState
|
||||
import se.hagstedt.kvitt.model.RangePreset
|
||||
|
||||
/** S-12 in every state of PLAN-M7e §5, × light/dark × sv/en (plus 200 % for long content). */
|
||||
@RunWith(ParameterizedRobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [36], qualifiers = "w411dp-h891dp-xhdpi")
|
||||
class DrillDownScreenshotTest(private val name: String) {
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun screen() {
|
||||
ScreenSnapshots.snap(compose, name, largeFont = name == "s12_long", content = states.getValue(name))
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val defaults = AnalyticsRules.defaultFilter
|
||||
|
||||
private fun ui(scenario: FakeScenario, args: DrillDownArgs): DrillDownUiState = runBlocking {
|
||||
val fakes = FakeRepositories(scenario)
|
||||
val accounts = fakes.accounts.all().first()
|
||||
val range = fakes.analytics.analytics(args.filter).first()
|
||||
val index = accounts.indexOfFirst { it.id == args.accountId }
|
||||
DrillDownUiState(
|
||||
args = args,
|
||||
data = fakes.analytics.drillDown(args.filter, args.bucketKey, args.categoryId, args.accountId).first(),
|
||||
start = range.start,
|
||||
end = range.end,
|
||||
category = fakes.categories.all().first().firstOrNull { it.id == args.categoryId },
|
||||
account = accounts.getOrNull(index),
|
||||
accountIndex = index.coerceAtLeast(0),
|
||||
)
|
||||
}
|
||||
|
||||
private fun screen(state: DrillDownUiState): @Composable () -> Unit = { DrillDownScreen(state, {}, {}) }
|
||||
|
||||
val states: Map<String, @Composable () -> Unit> = mapOf(
|
||||
"s12_category_in_bucket" to screen(ui(AnalyticsScenarios.loaded, DrillDownArgs(defaults, "2026-10", AnalyticsScenarios.groceries.id, null))),
|
||||
"s12_bucket" to screen(ui(AnalyticsScenarios.loaded, DrillDownArgs(defaults, "2026-10", null, null))),
|
||||
"s12_category" to screen(ui(AnalyticsScenarios.loaded, DrillDownArgs(defaults, null, AnalyticsScenarios.travel.id, null))),
|
||||
"s12_account" to screen(ui(AnalyticsScenarios.loaded, DrillDownArgs(defaults, null, null, AnalyticsScenarios.japan.id))),
|
||||
"s12_account_week" to screen(ui(AnalyticsScenarios.loaded, DrillDownArgs(AnalyticsScenarios.weekFilter, "2026-W41", null, AnalyticsScenarios.vardag.id))),
|
||||
"s12_empty" to screen(ui(AnalyticsScenarios.loaded, DrillDownArgs(AnalyticsScenarios.emptyFilter, "2026-11", AnalyticsScenarios.groceries.id, null))),
|
||||
"s12_offline" to screen(ui(AnalyticsScenarios.offline, DrillDownArgs(defaults.copy(preset = RangePreset.THIS_MONTH), "2026-10", AnalyticsScenarios.groceries.id, null))),
|
||||
"s12_long" to screen(ui(AnalyticsScenarios.long, DrillDownArgs(defaults, null, AnalyticsScenarios.longCategory.id, null))),
|
||||
)
|
||||
|
||||
@JvmStatic
|
||||
@ParameterizedRobolectricTestRunner.Parameters(name = "{0}")
|
||||
fun names(): List<Array<Any>> = states.keys.map { arrayOf<Any>(it) }
|
||||
}
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
package se.hagstedt.kvitt.feature.analytics.screenshot
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.os.LocaleList
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalResources
|
||||
import androidx.compose.ui.test.junit4.ComposeContentTestRule
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.unit.Density
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import kotlinx.datetime.TimeZone
|
||||
import se.hagstedt.kvitt.design.KvittTheme
|
||||
import se.hagstedt.kvitt.design.component.KvittClock
|
||||
import se.hagstedt.kvitt.design.component.LocalKvittClock
|
||||
import se.hagstedt.kvitt.design.component.ProvideHousehold
|
||||
import se.hagstedt.kvitt.fakes.FakeScenarios
|
||||
import java.util.Locale
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Captures a whole screen (411 × 891 dp, the Galaxy S26 of SPEC §13.1) once per language and theme:
|
||||
* `src/test/screenshots/<name>_<sv|en>_<light|dark>.png`, plus `<name>_sv_light_200.png` with [largeFont] (200 %,
|
||||
* like the design spec's 200 % frames). The clock is the fakes' today (17 October 2026, 14:02 in Stockholm) and the
|
||||
* viewer is Jacob. Language, theme and font scale switch through a configuration context, as in :core:design's tests;
|
||||
* the content is recreated per variant so remembered state (scroll, picker) starts fresh.
|
||||
*/
|
||||
object ScreenSnapshots {
|
||||
val clock = KvittClock.fixed(Instant.parse("2026-10-17T12:02:00Z"), TimeZone.of("Europe/Stockholm"))
|
||||
|
||||
private data class Variant(val language: String, val dark: Boolean, val fontScale: Float) {
|
||||
val suffix: String get() = "${language}_${if (dark) "dark" else "light"}" + if (fontScale > 1f) "_200" else ""
|
||||
}
|
||||
|
||||
fun snap(compose: ComposeContentTestRule, name: String, largeFont: Boolean = false, content: @Composable () -> Unit) {
|
||||
val variants = listOf("sv", "en").flatMap { l -> listOf(false, true).map { Variant(l, it, 1f) } } +
|
||||
if (largeFont) listOf(Variant("sv", dark = false, fontScale = 2f)) else emptyList()
|
||||
var current by mutableStateOf(variants.first())
|
||||
compose.setContent {
|
||||
val variant = current
|
||||
val base = LocalContext.current
|
||||
val baseConfiguration = LocalConfiguration.current
|
||||
val context = remember(variant) {
|
||||
val config = Configuration(baseConfiguration).apply {
|
||||
setLocales(LocaleList(Locale.forLanguageTag(if (variant.language == "sv") "sv-SE" else "en-GB")))
|
||||
fontScale = variant.fontScale
|
||||
uiMode = (uiMode and Configuration.UI_MODE_NIGHT_MASK.inv()) or if (variant.dark) Configuration.UI_MODE_NIGHT_YES else Configuration.UI_MODE_NIGHT_NO
|
||||
}
|
||||
base.createConfigurationContext(config)
|
||||
}
|
||||
CompositionLocalProvider(
|
||||
LocalContext provides context,
|
||||
LocalConfiguration provides context.resources.configuration,
|
||||
LocalResources provides context.resources,
|
||||
LocalDensity provides Density(context),
|
||||
LocalKvittClock provides clock,
|
||||
) {
|
||||
KvittTheme(darkTheme = variant.dark) {
|
||||
ProvideHousehold(FakeScenarios.owner, FakeScenarios.partner) {
|
||||
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {
|
||||
key(variant) { Box(Modifier.fillMaxSize()) { content() } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
variants.forEach { variant ->
|
||||
current = variant
|
||||
compose.waitForIdle()
|
||||
compose.onRoot().captureRoboImage("src/test/screenshots/${name}_${variant.suffix}.png")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user