From ea7d494ffadcc8d6b5d99a90170bdec45b8da78a Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Sun, 18 Oct 2026 10:41:03 +0200 Subject: [PATCH] M7e: feature analytics (S-11, S-12) with fake scenarios and screenshot tests Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B --- .../fakes/analytics/AnalyticsScenarios.kt | 194 +++++++++++ android/feature/analytics/build.gradle.kts | 18 +- .../kvitt/feature/analytics/AnalyticsEntry.kt | 14 - .../kvitt/feature/analytics/AnalyticsModel.kt | 144 +++++++++ .../feature/analytics/AnalyticsScreen.kt | 305 ++++++++++++++++++ .../feature/analytics/AnalyticsViewModel.kt | 75 +++++ .../kvitt/feature/analytics/AnalyticsWords.kt | 63 ++++ .../feature/analytics/DrillDownScreen.kt | 173 ++++++++++ .../feature/analytics/DrillDownViewModel.kt | 56 ++++ .../kvitt/feature/analytics/PeriodPicker.kt | 89 +++++ .../src/main/res/values-sv/strings.xml | 53 +++ .../analytics/src/main/res/values/strings.xml | 53 +++ .../feature/analytics/AnalyticsRulesTest.kt | 90 ++++++ .../analytics/AnalyticsViewModelTest.kt | 105 ++++++ .../analytics/DrillDownViewModelTest.kt | 55 ++++ .../screenshot/AnalyticsScreenshotTest.kt | 109 +++++++ .../screenshot/DrillDownScreenshotTest.kt | 71 ++++ .../analytics/screenshot/ScreenSnapshots.kt | 86 +++++ 18 files changed, 1738 insertions(+), 15 deletions(-) create mode 100644 android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/analytics/AnalyticsScenarios.kt delete mode 100644 android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/AnalyticsEntry.kt create mode 100644 android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/AnalyticsModel.kt create mode 100644 android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/AnalyticsScreen.kt create mode 100644 android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/AnalyticsViewModel.kt create mode 100644 android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/AnalyticsWords.kt create mode 100644 android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/DrillDownScreen.kt create mode 100644 android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/DrillDownViewModel.kt create mode 100644 android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/PeriodPicker.kt create mode 100644 android/feature/analytics/src/main/res/values-sv/strings.xml create mode 100644 android/feature/analytics/src/main/res/values/strings.xml create mode 100644 android/feature/analytics/src/test/kotlin/se/hagstedt/kvitt/feature/analytics/AnalyticsRulesTest.kt create mode 100644 android/feature/analytics/src/test/kotlin/se/hagstedt/kvitt/feature/analytics/AnalyticsViewModelTest.kt create mode 100644 android/feature/analytics/src/test/kotlin/se/hagstedt/kvitt/feature/analytics/DrillDownViewModelTest.kt create mode 100644 android/feature/analytics/src/test/kotlin/se/hagstedt/kvitt/feature/analytics/screenshot/AnalyticsScreenshotTest.kt create mode 100644 android/feature/analytics/src/test/kotlin/se/hagstedt/kvitt/feature/analytics/screenshot/DrillDownScreenshotTest.kt create mode 100644 android/feature/analytics/src/test/kotlin/se/hagstedt/kvitt/feature/analytics/screenshot/ScreenSnapshots.kt diff --git a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/analytics/AnalyticsScenarios.kt b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/analytics/AnalyticsScenarios.kt new file mode 100644 index 0000000..00589a6 --- /dev/null +++ b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/analytics/AnalyticsScenarios.kt @@ -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 { + 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> { + 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 = 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')}" +} diff --git a/android/feature/analytics/build.gradle.kts b/android/feature/analytics/build.gradle.kts index d8a9f9b..dc70739 100644 --- a/android/feature/analytics/build.gradle.kts +++ b/android/feature/analytics/build.gradle.kts @@ -1,4 +1,20 @@ -// :feature:analytics — Analytics and drill-down (S-11, S-12; M7e). Convention: android/README.md. +// :feature:analytics — Analytics (S-11) and drill-down (S-12), M7e. Convention: android/README.md. :core:model, +// :core:design and (tests and debug only) :core:fakes come from the kvitt.android.feature plugin. plugins { id("kvitt.android.feature") + alias(libs.plugins.roborazzi) +} + +roborazzi { + outputDir.set(file("src/test/screenshots")) +} + +dependencies { + debugImplementation(libs.androidx.compose.ui.test.manifest) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.androidx.compose.ui.test.junit4) + testImplementation(libs.robolectric) + testImplementation(libs.roborazzi) + testImplementation(libs.roborazzi.compose) + testImplementation(libs.roborazzi.junit.rule) } diff --git a/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/AnalyticsEntry.kt b/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/AnalyticsEntry.kt deleted file mode 100644 index ef01e0a..0000000 --- a/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/AnalyticsEntry.kt +++ /dev/null @@ -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) -} diff --git a/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/AnalyticsModel.kt b/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/AnalyticsModel.kt new file mode 100644 index 0000000..470c2b8 --- /dev/null +++ b/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/AnalyticsModel.kt @@ -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, val accounts: List) : 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 = 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 > read(key: String, values: Array, 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() +} diff --git a/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/AnalyticsScreen.kt b/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/AnalyticsScreen.kt new file mode 100644 index 0000000..96002f8 --- /dev/null +++ b/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/AnalyticsScreen.kt @@ -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, accounts: List): List { + 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, category: Category): String = + groups.firstOrNull { it.id == category.id.value }?.label ?: categoryName(category) + +@Composable +private fun categoryRows(totals: List>, categories: List): List { + 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>, accounts: List): List { + 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, 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 + diff --git a/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/AnalyticsViewModel.kt b/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/AnalyticsViewModel.kt new file mode 100644 index 0000000..041440c --- /dev/null +++ b/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/AnalyticsViewModel.kt @@ -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 = 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 + } +} diff --git a/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/AnalyticsWords.kt b/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/AnalyticsWords.kt new file mode 100644 index 0000000..8463ede --- /dev/null +++ b/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/AnalyticsWords.kt @@ -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 +} diff --git a/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/DrillDownScreen.kt b/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/DrillDownScreen.kt new file mode 100644 index 0000000..3235e4d --- /dev/null +++ b/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/DrillDownScreen.kt @@ -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) + } +} diff --git a/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/DrillDownViewModel.kt b/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/DrillDownViewModel.kt new file mode 100644 index 0000000..88ab6f8 --- /dev/null +++ b/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/DrillDownViewModel.kt @@ -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(it) }, analytics, categories, accounts) + + val state: StateFlow = 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 + } +} diff --git a/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/PeriodPicker.kt b/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/PeriodPicker.kt new file mode 100644 index 0000000..1671481 --- /dev/null +++ b/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/PeriodPicker.kt @@ -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) + } + } +} diff --git a/android/feature/analytics/src/main/res/values-sv/strings.xml b/android/feature/analytics/src/main/res/values-sv/strings.xml new file mode 100644 index 0000000..1a0013d --- /dev/null +++ b/android/feature/analytics/src/main/res/values-sv/strings.xml @@ -0,0 +1,53 @@ + + + + Statistik + Period + Period: %1$s + Denna månad + Senaste 3 mån + Senaste 6 mån + Senaste 12 mån + I år + All tid + Anpassad… + Anpassad · %1$s + %1$s – %2$s + Månad + Vecka + Hushållet + Min del + Kategori + Konto + Totalt + + %d vecka + %d veckor + + Utgifter över tid + %1$s per %2$s och %3$s + Per kategori + Per konto + vecka %1$d + %1$s tkr + Inga utgifter i den här perioden + Välj en annan period, eller lägg till en utgift med ett datum i perioden. + Byt period + Utgifter över tid, %1$s: högst i %2$s med %3$s, lägst i %4$s med %5$s. + %1$s: störst är %2$s med %3$s (%4$s). + %1$s: %2$s + Inga utgifter längre + %1$s, %2$s + + %d utgift + %d utgifter + + + Indelning + + Mått + + Gruppera efter + + Använd + diff --git a/android/feature/analytics/src/main/res/values/strings.xml b/android/feature/analytics/src/main/res/values/strings.xml new file mode 100644 index 0000000..4fe6b65 --- /dev/null +++ b/android/feature/analytics/src/main/res/values/strings.xml @@ -0,0 +1,53 @@ + + + + Analytics + Period + Period: %1$s + This month + Last 3 months + Last 6 months + Last 12 months + This year + All time + Custom… + Custom · %1$s + %1$s – %2$s + Month + Week + Household + My share + Category + Account + Total + + %d week + %d weeks + + Spending over time + %1$s per %2$s and %3$s + By category + By account + week %1$d + %1$sk + No expenses in this period + Choose another period, or add an expense dated within it. + Change period + Spending over time, %1$s: highest in %2$s with %3$s, lowest in %4$s with %5$s. + %1$s: largest is %2$s with %3$s (%4$s). + %1$s: %2$s + No expenses any more + %1$s, %2$s + + %d expense + %d expenses + + + Interval + + Measure + + Group by + + Apply + diff --git a/android/feature/analytics/src/test/kotlin/se/hagstedt/kvitt/feature/analytics/AnalyticsRulesTest.kt b/android/feature/analytics/src/test/kotlin/se/hagstedt/kvitt/feature/analytics/AnalyticsRulesTest.kt new file mode 100644 index 0000000..159cb0d --- /dev/null +++ b/android/feature/analytics/src/test/kotlin/se/hagstedt/kvitt/feature/analytics/AnalyticsRulesTest.kt @@ -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) + } +} diff --git a/android/feature/analytics/src/test/kotlin/se/hagstedt/kvitt/feature/analytics/AnalyticsViewModelTest.kt b/android/feature/analytics/src/test/kotlin/se/hagstedt/kvitt/feature/analytics/AnalyticsViewModelTest.kt new file mode 100644 index 0000000..809ea2b --- /dev/null +++ b/android/feature/analytics/src/test/kotlin/se/hagstedt/kvitt/feature/analytics/AnalyticsViewModelTest.kt @@ -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) + } +} diff --git a/android/feature/analytics/src/test/kotlin/se/hagstedt/kvitt/feature/analytics/DrillDownViewModelTest.kt b/android/feature/analytics/src/test/kotlin/se/hagstedt/kvitt/feature/analytics/DrillDownViewModelTest.kt new file mode 100644 index 0000000..6e9fe2d --- /dev/null +++ b/android/feature/analytics/src/test/kotlin/se/hagstedt/kvitt/feature/analytics/DrillDownViewModelTest.kt @@ -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) + } +} diff --git a/android/feature/analytics/src/test/kotlin/se/hagstedt/kvitt/feature/analytics/screenshot/AnalyticsScreenshotTest.kt b/android/feature/analytics/src/test/kotlin/se/hagstedt/kvitt/feature/analytics/screenshot/AnalyticsScreenshotTest.kt new file mode 100644 index 0000000..7331465 --- /dev/null +++ b/android/feature/analytics/src/test/kotlin/se/hagstedt/kvitt/feature/analytics/screenshot/AnalyticsScreenshotTest.kt @@ -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 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> = states.keys.map { arrayOf(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 +} diff --git a/android/feature/analytics/src/test/kotlin/se/hagstedt/kvitt/feature/analytics/screenshot/DrillDownScreenshotTest.kt b/android/feature/analytics/src/test/kotlin/se/hagstedt/kvitt/feature/analytics/screenshot/DrillDownScreenshotTest.kt new file mode 100644 index 0000000..83d8fbe --- /dev/null +++ b/android/feature/analytics/src/test/kotlin/se/hagstedt/kvitt/feature/analytics/screenshot/DrillDownScreenshotTest.kt @@ -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 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> = states.keys.map { arrayOf(it) } + } +} diff --git a/android/feature/analytics/src/test/kotlin/se/hagstedt/kvitt/feature/analytics/screenshot/ScreenSnapshots.kt b/android/feature/analytics/src/test/kotlin/se/hagstedt/kvitt/feature/analytics/screenshot/ScreenSnapshots.kt new file mode 100644 index 0000000..b3c4ba0 --- /dev/null +++ b/android/feature/analytics/src/test/kotlin/se/hagstedt/kvitt/feature/analytics/screenshot/ScreenSnapshots.kt @@ -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/__.png`, plus `_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") + } + } +}