M7b: feature home and account (S-4, S-5, S-6, S-16, S-17)

:feature:home: Home with balance hero, sync pill, accounts, archived section,
empty and first-sync states, update banner and "Tillåt installation" dialog;
sync status with pending rows, rejections of every §14.4.6 reason and the
clock-skew banner. :feature:account: account detail with header, ledger by
month, overflow menu, archive/unarchive/delete and their errors; account
editor with name, percent fields (two decimals), slider, presets, preview,
name_taken/validation errors and offline state. Fake scenarios in
fakes/home and fakes/account; full-screen Roborazzi goldens for every state
in sv/en × light/dark (200 % for long states); ViewModel unit tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
2026-10-18 10:49:31 +02:00
parent 7380e50ae3
commit 0e1d99df93
26 changed files with 3027 additions and 28 deletions
@@ -0,0 +1,40 @@
package se.hagstedt.kvitt.fakes.account
import se.hagstedt.kvitt.fakes.FakeScenario
import se.hagstedt.kvitt.fakes.FakeScenarios
import se.hagstedt.kvitt.fakes.home.HomeScenarios
import se.hagstedt.kvitt.model.Account
import se.hagstedt.kvitt.model.AccountId
/**
* Scenarios of the account screens (S-5, S-6; M7b) on the household of [HomeScenarios]. Open S-5 with the account ids
* named here; S-6 creates or edits against the same household.
*/
object AccountScenarios {
/** Vardag: "Anna är skyldig dig 1 184,50 kr", October and September, the SL-kort row and a payment pending. */
val loaded: FakeScenario = HomeScenarios.pending
val vardag: AccountId = HomeScenarios.vardag.id
/** Huset: "Du är skyldig Anna 3 084,50 kr" (negative balance). */
val huset: AccountId = HomeScenarios.huset.id
/** Gotland 2026: archived, settled up ("Kvitt"), no FAB. */
val gotland: AccountId = HomeScenarios.gotland.id
/** The new account "Sommarstugan" without expenses: settled, deletable (AC-6.5). */
val sommarstugan = Account(AccountId("01920000-0000-7000-8000-b00000000007"), "Sommarstugan", mapOf(FakeScenarios.owner.id to 6_000, FakeScenarios.partner.id to 4_000), "2026-10-17T11:00:00.000Z", archived = false)
val empty: FakeScenario = HomeScenarios.household.copy(accounts = HomeScenarios.household.accounts + sommarstugan)
/** [loaded] offline: every menu item needs a connection; rows, settle and the FAB still work. */
val offline: FakeScenario = HomeScenarios.pending.copy(online = false)
/** "Sommarstugan i Österåker 2027" with long descriptions, 1 234 567,89 kr and a 7-digit month total. */
val long: FakeScenario = HomeScenarios.long
val longAccount: AccountId = HomeScenarios.sommarstugan.id
/** S-6: the household without the account being created, online. */
val editor: FakeScenario = HomeScenarios.household
/** S-6 offline: "Kräver anslutning". */
val editorOffline: FakeScenario = HomeScenarios.household.copy(online = false)
}
@@ -0,0 +1,198 @@
package se.hagstedt.kvitt.fakes.home
import se.hagstedt.kvitt.fakes.FakeExpense
import se.hagstedt.kvitt.fakes.FakeScenario
import se.hagstedt.kvitt.fakes.FakeScenarios
import se.hagstedt.kvitt.fakes.FakeSettlement
import se.hagstedt.kvitt.model.Account
import se.hagstedt.kvitt.model.AccountId
import se.hagstedt.kvitt.model.CategoryId
import se.hagstedt.kvitt.model.DefaultCategoryKey
import se.hagstedt.kvitt.model.EntityType
import se.hagstedt.kvitt.model.ExpenseId
import se.hagstedt.kvitt.model.Rejection
import se.hagstedt.kvitt.model.SettlementId
import se.hagstedt.kvitt.model.Share
import se.hagstedt.kvitt.model.SplitMode
import se.hagstedt.kvitt.model.SyncRejectionReason
import se.hagstedt.kvitt.model.UpdateInfo
import se.hagstedt.kvitt.model.UpdateState
import se.hagstedt.kvitt.model.UserId
/**
* The household of the design spec's frames (S-4, S-5, S-16, S-17; M7b): Jacob (the viewer) and Anna, the accounts
* Vardag, Huset, Japan 2027 and Gotland 2026 (archived). The phone's clock is 17 October 2026 14:02 in Stockholm, the
* last sync three minutes earlier. Balances come from the expenses through `:domain`: Vardag +1 184,50 kr, Huset
* −3 084,50 kr, Japan 2027 −1 350,00 kr, Gotland 2026 0, globally "Du är skyldig Anna 3 250,00 kr".
*/
object HomeScenarios {
private val jacob = FakeScenarios.owner.id
private val anna = FakeScenarios.partner.id
private fun id(prefix: String, n: Int) = "01920000-0000-7000-8000-$prefix" + n.toString(16).padStart(12 - prefix.length, '0')
fun category(key: DefaultCategoryKey): CategoryId = FakeScenarios.defaultCategories.first { it.defaultKey == key }.id
private val half = mapOf(jacob to 5_000, anna to 5_000)
val vardag = Account(AccountId(id("b", 1)), "Vardag", half, "2026-01-04T09:00:00.000Z", archived = false)
val huset = Account(AccountId(id("b", 2)), "Huset", mapOf(jacob to 6_000, anna to 4_000), "2026-01-05T09:00:00.000Z", archived = false)
val japan = Account(AccountId(id("b", 3)), "Japan 2027", half, "2026-03-01T09:00:00.000Z", archived = false)
val gotland = Account(AccountId(id("b", 4)), "Gotland 2026", half, "2026-05-20T09:00:00.000Z", archived = true)
/** An expense split in two exact halves (or on [fullOn] alone). */
fun expense(
n: Int,
account: Account,
key: DefaultCategoryKey,
amountOre: Long,
description: String,
date: String,
payer: UserId,
jacobShareOre: Long = amountOre / 2,
pending: Boolean = false,
) = FakeExpense(
id = ExpenseId(id("e", n)),
accountId = account.id,
categoryId = category(key),
amountOre = amountOre,
description = description,
notes = "",
date = date,
payer = payer,
mode = if (jacobShareOre * 2 == amountOre) SplitMode.DEFAULT else SplitMode.EXACT,
shares = listOf(Share(jacob, jacobShareOre, null), Share(anna, amountOre - jacobShareOre, null)),
createdAt = date + "T10:00:00.000Z",
pending = pending,
)
fun settlement(n: Int, from: UserId, allocations: Map<AccountId, Long>, date: String, pending: Boolean = false) = FakeSettlement(
id = SettlementId(id("5", n)),
from = from,
to = if (from == jacob) anna else jacob,
amountOre = allocations.values.sum(),
date = date,
note = "",
allocations = allocations,
createdAt = date + "T11:00:00.000Z",
pending = pending,
)
val icaMaxi = expense(1, vardag, DefaultCategoryKey.GROCERIES, 124_760, "ICA Maxi Lindhagen", "2026-10-16", anna)
val systembolaget = expense(2, vardag, DefaultCategoryKey.ENTERTAINMENT, 41_200, "Systembolaget", "2026-10-15", jacob)
val slKort = expense(3, vardag, DefaultCategoryKey.TRANSPORT, 106_000, "SL-kort 30 dagar, Anna", "2026-10-14", jacob, jacobShareOre = 0)
val hemkop = expense(4, vardag, DefaultCategoryKey.GROCERIES, 38_440, "Hemköp Odenplan", "2026-10-10", jacob)
val willys = expense(5, vardag, DefaultCategoryKey.GROCERIES, 52_300, "Willys Hornstull", "2026-09-28", anna)
val bauhaus = expense(6, vardag, DefaultCategoryKey.HOME, 282_320, "Bauhaus Barkarby", "2026-09-20", jacob)
val rent = expense(7, huset, DefaultCategoryKey.HOUSING, 500_000, "Hyra oktober", "2026-10-01", anna, jacobShareOre = 300_000)
val power = expense(8, huset, DefaultCategoryKey.UTILITIES, 16_900, "Elräkning september", "2026-10-05", anna)
val flight = expense(9, japan, DefaultCategoryKey.TRAVEL, 270_000, "Flyg Stockholm–Tokyo", "2026-10-12", anna)
val ferry = expense(10, gotland, DefaultCategoryKey.TRAVEL, 98_000, "Färja Visby", "2026-06-14", jacob)
val vardagPayment = settlement(1, anna, mapOf(vardag.id to 80_000), "2026-10-06")
val gotlandPayment = settlement(2, anna, mapOf(gotland.id to 49_000), "2026-07-01")
/** Owner and partner, four accounts, synced three minutes ago, nothing pending; "Du är skyldig Anna 3 250,00 kr". */
val household: FakeScenario = FakeScenarios.empty.copy(
accounts = listOf(vardag, huset, japan, gotland),
expenses = listOf(icaMaxi, systembolaget, slKort, hemkop, willys, bauhaus, rent, power, flight, ferry),
settlements = listOf(vardagPayment, gotlandPayment),
lastSuccessAt = "2026-10-17T11:59:00.000Z",
today = "2026-10-17",
now = "2026-10-17T12:02:00.000Z",
defaultAccountId = vardag.id,
)
/** [household] with two changes waiting to sync: the SL-kort expense and the Vardag payment. */
val pending: FakeScenario = household.copy(
expenses = household.expenses.map { if (it.id == slKort.id) it.copy(pending = true) else it },
settlements = household.settlements.map { if (it.id == vardagPayment.id) it.copy(pending = true) else it },
)
/** [household] plus a hotel Jacob paid in Japan 2027: "Anna är skyldig dig 750,00 kr". */
val owesYou: FakeScenario = household.copy(
expenses = household.expenses + expense(11, japan, DefaultCategoryKey.TRAVEL, 800_000, "Hotell Kyoto", "2026-10-13", jacob),
)
/** [household] settled up today in every account: "Ni är kvitt". */
val settled: FakeScenario = household.copy(
settlements = household.settlements +
settlement(3, anna, mapOf(vardag.id to 118_450), "2026-10-17") +
settlement(4, jacob, mapOf(huset.id to 308_450, japan.id to 135_000), "2026-10-17"),
)
/** A `stale` rejection of Anna's newer Babette expense (S-16 loaded). */
val staleRejection = Rejection("m-1", EntityType.EXPENSE, id("e", 12), SyncRejectionReason.STALE, "Babette", "2026-10-17T06:31:00.000Z")
/** S-16 loaded: two changes waiting and one `stale` rejection. */
val syncLoaded: FakeScenario = pending.copy(rejections = listOf(staleRejection))
/** [household] with one change that was not synced. */
val rejected: FakeScenario = household.copy(rejections = listOf(staleRejection))
/** [pending] with the phone's clock more than ten minutes ahead; the last successful sync two hours ago. */
val clockSkew: FakeScenario = pending.copy(clockSkew = true, lastSuccessAt = "2026-10-17T10:02:00.000Z")
/** [pending] while a sync runs. */
val syncing: FakeScenario = pending.copy(syncRunning = true)
val update = UpdateInfo(versionCode = 2, versionName = "1.2", sizeBytes = 8_400_000)
/** [household] with version 1.2 available. */
val updateAvailable: FakeScenario = household.copy(update = UpdateState.Available(update))
/** [household] before the first install: "Uppdatera" opens the "Tillåt installation" dialog (S-17). */
val updateNeedsPermission: FakeScenario = household.copy(update = UpdateState.NeedsInstallPermission(update))
/** [household] while version 1.2 downloads, at 64 %. */
val updateDownloading: FakeScenario = household.copy(update = UpdateState.Downloading(update, 0.64f))
/** [household] after a failed download. */
val updateFailed: FakeScenario = household.copy(update = UpdateState.Failed(update))
/** Offline with two changes waiting and version 1.2 available (S-4-home-offline). */
val offline: FakeScenario = pending.copy(online = false, update = UpdateState.Available(update))
/** The first sync after pairing: nothing synced yet, the sync running. */
val loading: FakeScenario = FakeScenarios.empty.copy(lastSuccessAt = null, syncRunning = true, today = "2026-10-17", now = "2026-10-17T12:02:00.000Z")
/** Partner joined, no accounts yet (S-4-home-empty). */
val empty: FakeScenario = FakeScenarios.empty.copy(lastSuccessAt = "2026-10-17T11:59:00.000Z", today = "2026-10-17", now = "2026-10-17T12:02:00.000Z")
/** Only archived accounts: no FAB (UX-6). */
val onlyArchived: FakeScenario = household.copy(
accounts = listOf(gotland),
expenses = listOf(ferry),
settlements = listOf(gotlandPayment),
)
val sommarstugan = Account(AccountId(id("b", 5)), "Sommarstugan i Österåker 2027", mapOf(jacob to 6_000, anna to 4_000), "2026-09-01T09:00:00.000Z", archived = false)
val bills = Account(AccountId(id("b", 6)), "Gemensamma räkningar och försäkringar", half, "2026-09-02T09:00:00.000Z", archived = false)
/** Long Swedish account names and 7-digit amounts (S-4 long, S-5 long). */
val long: FakeScenario = household.copy(
accounts = household.accounts + sommarstugan + bills,
expenses = household.expenses +
expense(20, sommarstugan, DefaultCategoryKey.HOUSING, 246_913_578, "Handpenning för sommarstugan vid Åkersberga och lagfart", "2026-10-11", anna, jacobShareOre = 123_456_789) +
expense(21, sommarstugan, DefaultCategoryKey.HOME, 1_456_000, "Snickeriarbete på verandan och nytt staket runt tomten", "2026-10-09", jacob) +
expense(22, bills, DefaultCategoryKey.INSURANCE, 4_312_350, "Hemförsäkring, bilförsäkring och olycksfallsförsäkring helår", "2026-10-02", jacob),
)
/** [pending] with one rejection of every §14.4.6 reason. */
val allRejections: FakeScenario = pending.copy(
rejections = listOf(
staleRejection,
Rejection("m-2", EntityType.EXPENSE, id("e", 13), SyncRejectionReason.DELETED, "Pressbyrån Odenplan", "2026-10-17T07:10:00.000Z"),
Rejection("m-3", EntityType.SETTLEMENT, id("5", 9), SyncRejectionReason.VALIDATION_FAILED, "Betalning till Anna", "2026-10-17T07:42:00.000Z"),
Rejection("m-4", EntityType.EXPENSE, id("e", 14), SyncRejectionReason.REFERENCE_NOT_FOUND, "Glass i Visby hamn", "2026-10-16T18:05:00.000Z"),
Rejection("m-5", EntityType.EXPENSE, id("e", 15), SyncRejectionReason.CLOCK_SKEW, "Biobiljetter Rigoletto", "2026-10-16T17:20:00.000Z"),
),
)
/** S-16 long: long descriptions and 7-digit amounts waiting, and a long rejection summary. */
val longSync: FakeScenario = long.copy(
expenses = long.expenses.map { if (it.accountId == sommarstugan.id || it.accountId == bills.id) it.copy(pending = true) else it },
rejections = listOf(staleRejection.copy(summary = "Middag på Restaurang Frantzén med Annas föräldrar och syskon")),
)
/** [pending] offline: "Synka nu" needs a connection. */
val syncOffline: FakeScenario = pending.copy(online = false)
}