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:
+40
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user