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 {
|
plugins {
|
||||||
id("kvitt.android.feature")
|
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