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)
}
+16
View File
@@ -1,4 +1,20 @@
// :feature:account — Account detail and account editor (S-5, S-6; M7b). Convention: android/README.md.
// Dependencies: :core:model and :core:design; :core:fakes for tests and debug only (all three from kvitt.android.feature).
plugins {
id("kvitt.android.feature")
alias(libs.plugins.roborazzi)
}
roborazzi {
outputDir.set(file("src/test/screenshots"))
}
dependencies {
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)
debugImplementation(libs.androidx.compose.ui.test.manifest)
}
@@ -0,0 +1,289 @@
package se.hagstedt.kvitt.feature.account
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.ReadOnlyComposable
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.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import se.hagstedt.kvitt.design.KvittTheme
import se.hagstedt.kvitt.design.component.AccountBalanceHeader
import se.hagstedt.kvitt.design.component.AccountHeaderWords
import se.hagstedt.kvitt.design.component.AddExpenseFab
import se.hagstedt.kvitt.design.component.ConfirmDialog
import se.hagstedt.kvitt.design.component.EmptyState
import se.hagstedt.kvitt.design.component.KvittIconImage
import se.hagstedt.kvitt.design.component.KvittMenuContent
import se.hagstedt.kvitt.design.component.KvittMenuItem
import se.hagstedt.kvitt.design.component.KvittScaffold
import se.hagstedt.kvitt.design.component.KvittTopAppBar
import se.hagstedt.kvitt.design.component.LedgerMonthGroup
import se.hagstedt.kvitt.design.component.ProvideHousehold
import se.hagstedt.kvitt.design.component.TopBarNavigation
import se.hagstedt.kvitt.design.component.kvittLanguage
import se.hagstedt.kvitt.design.component.ratioSpoken
import se.hagstedt.kvitt.design.format.MoneyFormatter
import se.hagstedt.kvitt.design.icon.KvittIcon
import se.hagstedt.kvitt.model.AccountId
import se.hagstedt.kvitt.model.ExpenseId
import se.hagstedt.kvitt.model.SettlementId
import se.hagstedt.kvitt.core.design.R as DesignR
/** Navigation and actions of the account screen; `:app` wires the navigation lambdas (D-017). */
data class AccountDetailActions(
val onBack: () -> Unit = {},
val onOpenExpense: (ExpenseId) -> Unit = {},
val onOpenSettlement: (SettlementId) -> Unit = {},
val onAddExpense: (AccountId) -> Unit = {},
val onSettle: (AccountId) -> Unit = {},
val onEdit: (AccountId) -> Unit = {},
val onMenu: (Boolean) -> Unit = {},
val onArchive: () -> Unit = {},
val onUnarchive: () -> Unit = {},
val onAskDelete: () -> Unit = {},
val onConfirmDelete: () -> Unit = {},
val onDismissDelete: () -> Unit = {},
val onRetry: (AccountAction) -> Unit = {},
val onMessageShown: () -> Unit = {},
)
/**
* Account detail (S-5). The account id is the navigation argument [AccountDetailViewModel.ARG_ACCOUNT_ID].
* [onArchived] is called after archiving with the account's name: `:app` returns to Home and shows
* "Vardag arkiverades" with "Ångra" there (design-spec S-5). [onDeleted] after deleting, or when the account is gone.
*/
@Composable
fun AccountDetailScreen(
onBack: () -> Unit,
onOpenExpense: (ExpenseId) -> Unit,
onOpenSettlement: (SettlementId) -> Unit,
onAddExpense: (AccountId) -> Unit,
onSettle: (AccountId) -> Unit,
onEdit: (AccountId) -> Unit,
onArchived: (AccountId, String) -> Unit,
onDeleted: () -> Unit,
modifier: Modifier = Modifier,
viewModel: AccountDetailViewModel = hiltViewModel(),
) {
val ui by viewModel.ui.collectAsStateWithLifecycle()
val event = when (val current = ui) {
is AccountDetailUi.Ready -> current.event
is AccountDetailUi.Gone -> current.event
AccountDetailUi.Starting -> null
}
LaunchedEffect(event) {
when (event) {
is AccountEvent.Archived -> onArchived(viewModel.id, event.name)
AccountEvent.Deleted, AccountEvent.Gone -> onDeleted()
null -> return@LaunchedEffect
}
viewModel.eventHandled()
}
val actions = AccountDetailActions(
onBack = onBack,
onOpenExpense = onOpenExpense,
onOpenSettlement = onOpenSettlement,
onAddExpense = onAddExpense,
onSettle = onSettle,
onEdit = onEdit,
onMenu = viewModel::setMenuOpen,
onArchive = viewModel::archive,
onUnarchive = viewModel::unarchive,
onAskDelete = viewModel::askDelete,
onConfirmDelete = viewModel::confirmDelete,
onDismissDelete = viewModel::dismissDelete,
onRetry = viewModel::retry,
onMessageShown = viewModel::messageShown,
)
AccountDetailContent(ui, actions, modifier)
}
/** The stateless account screen, rendered by [AccountDetailScreen], previews and screenshot tests. */
@Composable
fun AccountDetailContent(
ui: AccountDetailUi,
actions: AccountDetailActions,
modifier: Modifier = Modifier,
listState: LazyListState = rememberLazyListState(),
) {
val ready = ui as? AccountDetailUi.Ready
val snackbar = remember { SnackbarHostState() }
val scrolled by remember { derivedStateOf { listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0 } }
val fabExpanded by remember { derivedStateOf { !listState.lastScrolledForward } }
ready?.message?.let { message -> MessageEffect(message, snackbar, actions) }
KvittScaffold(
modifier = modifier,
topBar = {
KvittTopAppBar(
ready?.detail?.account?.name.orEmpty(),
navigation = TopBarNavigation.Back,
onNavigation = actions.onBack,
scrolled = scrolled,
actions = { if (ready != null) OverflowMenu(ready, actions) },
)
},
floatingActionButton = {
if (ready?.showFab == true) AddExpenseFab({ actions.onAddExpense(ready.detail.account.id) }, expanded = fabExpanded)
},
snackbarHostState = snackbar,
) { padding ->
if (ready == null) {
Box(Modifier.fillMaxSize().padding(padding))
} else {
ProvideHousehold(ready.me, ready.partner) { AccountList(ready, actions, padding, listState) }
if (ready.deleteDialog) DeleteDialog(ready.detail.account.name, actions)
}
}
}
@Composable
private fun AccountList(ui: AccountDetailUi.Ready, actions: AccountDetailActions, padding: PaddingValues, listState: LazyListState) {
val spacing = KvittTheme.spacing
val account = ui.detail.account
val words = AccountHeaderWords(
defaultSplit = defaultSplit(ui),
defaultSplitSpoken = stringResource(R.string.account_default_split_spoken, ratioSpoken(ui.me, viewerBp(ui), ui.partner)),
settleAccount = stringResource(R.string.account_settle_this),
unarchive = stringResource(R.string.account_unarchive),
)
val typeLabel = stringResource(DesignR.string.ledger_settlement_label)
LazyColumn(
Modifier.fillMaxSize().padding(padding),
state = listState,
contentPadding = PaddingValues(start = spacing.gutter, end = spacing.gutter, top = spacing.s1, bottom = spacing.fabClearance),
verticalArrangement = Arrangement.spacedBy(spacing.sectionGap),
) {
item(key = "header") {
AccountBalanceHeader(ui.detail, ui.colorIndex, ui.viewerPercent, words, { actions.onSettle(account.id) }, actions.onUnarchive)
}
if (ui.detail.months.isEmpty()) {
item(key = "empty") {
EmptyState(stringResource(R.string.account_empty_title), stringResource(R.string.account_empty_body, account.name))
}
}
ui.detail.months.forEach { month ->
item(key = month.period) {
LedgerMonthGroup(
month.period,
month.householdSpendOre,
month.rows,
typeLabel,
onExpense = { actions.onOpenExpense(it.id) },
onSettlement = { actions.onOpenSettlement(it.id) },
)
}
}
}
}
private fun viewerBp(ui: AccountDetailUi.Ready): Int = ui.detail.account.defaultShares[ui.me.id] ?: HALF_BP
/** "60/40 · Jacob och Anna": default shares in percent, viewer first (`account_default_split`). */
@Composable
@ReadOnlyComposable
private fun defaultSplit(ui: AccountDetailUi.Ready): String {
val language = kvittLanguage()
val bp = viewerBp(ui)
val ratio = stringResource(DesignR.string.common_ratio, MoneyFormatter.basisPoints(bp, language), MoneyFormatter.basisPoints(BP_TOTAL - bp, language))
val names = stringResource(DesignR.string.common_and, ui.me.displayName, ui.partner.displayName)
return stringResource(R.string.account_default_split, ratio, names)
}
internal const val BP_TOTAL = 10_000
/** `more_vert` with the S-5 menu; disabled items carry their reason as a second line (design-spec §3.11). */
@Composable
private fun OverflowMenu(ui: AccountDetailUi.Ready, actions: AccountDetailActions) {
val needsConnection = stringResource(DesignR.string.common_needs_connection)
val offlineReason = if (ui.online) null else needsConnection
val archived = ui.detail.account.archived
Box {
IconButton({ actions.onMenu(true) }) {
KvittIconImage(KvittIcon.MoreVert, stringResource(DesignR.string.common_more_options), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
if (ui.menuOpen) {
val offset = with(LocalDensity.current) { KvittTheme.spacing.touchTarget.roundToPx() }
Popup(
alignment = Alignment.TopEnd,
offset = IntOffset(0, offset),
onDismissRequest = { actions.onMenu(false) },
properties = PopupProperties(focusable = true),
) {
KvittMenuContent(
listOf(
KvittMenuItem(KvittIcon.Edit, stringResource(R.string.account_edit), ui.canEdit, offlineReason) {
actions.onMenu(false)
actions.onEdit(ui.detail.account.id)
},
if (archived) {
KvittMenuItem(KvittIcon.Unarchive, stringResource(R.string.account_unarchive), ui.canEdit, offlineReason, actions.onUnarchive)
} else {
KvittMenuItem(KvittIcon.Inventory2, stringResource(R.string.account_archive), ui.canEdit, offlineReason, actions.onArchive)
},
KvittMenuItem(
KvittIcon.Delete,
stringResource(R.string.account_delete),
ui.canDelete,
offlineReason ?: stringResource(R.string.account_delete_blocked).takeIf { !ui.detail.deletable },
actions.onAskDelete,
),
),
)
}
}
}
}
@Composable
private fun DeleteDialog(name: String, actions: AccountDetailActions) {
ConfirmDialog(
icon = KvittIcon.Delete,
title = stringResource(R.string.account_delete_confirm_title, name),
body = stringResource(R.string.account_delete_confirm_body),
confirm = stringResource(DesignR.string.common_delete),
dismiss = stringResource(DesignR.string.common_cancel),
onConfirm = actions.onConfirmDelete,
onDismiss = actions.onDismissDelete,
destructive = true,
)
}
/** Shows [message] once (4 s, 10 s with an action, design-spec §3.11); "Försök igen" repeats the failed action. */
@Composable
private fun MessageEffect(message: AccountMessage, host: SnackbarHostState, actions: AccountDetailActions) {
val text = when (message) {
is AccountMessage.CouldNotReach -> stringResource(DesignR.string.common_could_not_reach_server)
is AccountMessage.Restored -> stringResource(R.string.account_unarchived_snackbar, message.name)
AccountMessage.DeleteBlocked -> stringResource(R.string.account_delete_blocked)
AccountMessage.NeedsConnection -> stringResource(DesignR.string.common_needs_connection)
is AccountMessage.Failed -> errorText(message.code)
}
val action = if (message is AccountMessage.CouldNotReach) stringResource(DesignR.string.common_retry) else null
LaunchedEffect(message, text, action) {
val result = host.showSnackbar(text, action, duration = if (action != null) SnackbarDuration.Long else SnackbarDuration.Short)
if (result == SnackbarResult.ActionPerformed && message is AccountMessage.CouldNotReach) actions.onRetry(message.retry)
actions.onMessageShown()
}
}
@@ -0,0 +1,179 @@
package se.hagstedt.kvitt.feature.account
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import se.hagstedt.kvitt.model.AccountDetailState
import se.hagstedt.kvitt.model.AccountId
import se.hagstedt.kvitt.model.AccountRepository
import se.hagstedt.kvitt.model.ConnectivityRepository
import se.hagstedt.kvitt.model.OnlineResult
import se.hagstedt.kvitt.model.User
import javax.inject.Inject
/** The online actions of S-5 (AC-6.4, AC-6.5). */
enum class AccountAction { Archive, Unarchive, Delete }
/** A snackbar of S-5. */
sealed interface AccountMessage {
/** "Kunde inte nå servern" with "Försök igen" (§9.9). */
data class CouldNotReach(val retry: AccountAction) : AccountMessage
/** "Vardag återställdes". */
data class Restored(val name: String) : AccountMessage
/** `account_not_empty` (AC-6.5). */
data object DeleteBlocked : AccountMessage
/** The action was tried while offline (§9.9). */
data object NeedsConnection : AccountMessage
/** Any other error code of §14.3.2. */
data class Failed(val code: String) : AccountMessage
}
/** A one-off navigation of S-5, handled by the screen and then cleared. */
sealed interface AccountEvent {
/** Archived: back to Home with "Vardag arkiverades" (design-spec S-5). */
data class Archived(val name: String) : AccountEvent
/** Deleted: back to Home. */
data object Deleted : AccountEvent
/** The account no longer exists (deleted elsewhere). */
data object Gone : AccountEvent
}
/** What the account screen (S-5) shows. */
sealed interface AccountDetailUi {
data object Starting : AccountDetailUi
data class Ready(
val detail: AccountDetailState,
val online: Boolean,
val colorIndex: Int,
val menuOpen: Boolean = false,
val deleteDialog: Boolean = false,
val busy: Boolean = false,
val message: AccountMessage? = null,
val event: AccountEvent? = null,
) : AccountDetailUi {
val partner: User get() = detail.balance.partner
val me: User get() = detail.users.firstOrNull { it.id != partner.id } ?: partner
/** The viewer's default share in percent, for the split bar. */
val viewerPercent: Float get() = (detail.account.defaultShares[me.id] ?: HALF_BP) / BP_PER_PERCENT
/** Archive, unarchive, edit and delete need the server (AC-6.7, §9.9). */
val canEdit: Boolean get() = online && !busy
val canDelete: Boolean get() = canEdit && detail.deletable
/** Archived accounts hide the FAB (AC-6.4, UX-6). */
val showFab: Boolean get() = !detail.account.archived
}
data class Gone(val event: AccountEvent?) : AccountDetailUi
}
private data class AccountLocal(
val menuOpen: Boolean = false,
val deleteDialog: Boolean = false,
val busy: Boolean = false,
val message: AccountMessage? = null,
val event: AccountEvent? = null,
)
internal const val HALF_BP = 5_000
internal const val BP_PER_PERCENT = 100f
/** State holder of the account screen (S-5). The account id comes from the navigation argument [ARG_ACCOUNT_ID]. */
@HiltViewModel
class AccountDetailViewModel @Inject constructor(
savedState: SavedStateHandle,
private val accounts: AccountRepository,
connectivity: ConnectivityRepository,
) : ViewModel() {
val id = AccountId(checkNotNull(savedState.get<String>(ARG_ACCOUNT_ID)) { "missing $ARG_ACCOUNT_ID" })
private val local = MutableStateFlow(AccountLocal())
val ui: StateFlow<AccountDetailUi> = combine(accounts.detail(id), accounts.all(), connectivity.online, local) { detail, all, online, l ->
if (detail == null) {
AccountDetailUi.Gone(l.event ?: AccountEvent.Gone)
} else {
AccountDetailUi.Ready(
detail = detail,
online = online,
colorIndex = all.indexOfFirst { it.id == id }.coerceAtLeast(0),
menuOpen = l.menuOpen,
deleteDialog = l.deleteDialog,
busy = l.busy,
message = l.message,
event = l.event,
)
}
}.stateIn(viewModelScope, SharingStarted.Eagerly, AccountDetailUi.Starting)
fun setMenuOpen(open: Boolean) = local.update { it.copy(menuOpen = open) }
fun archive() = run(AccountAction.Archive)
fun unarchive() = run(AccountAction.Unarchive)
/** "Ta bort konto": asks first (design-spec S-5). */
fun askDelete() = local.update { it.copy(menuOpen = false, deleteDialog = true) }
fun dismissDelete() = local.update { it.copy(deleteDialog = false) }
fun confirmDelete() = run(AccountAction.Delete)
fun retry(action: AccountAction) = run(action)
fun messageShown() = local.update { it.copy(message = null) }
fun eventHandled() = local.update { it.copy(event = null) }
private fun run(action: AccountAction) {
val name = (ui.value as? AccountDetailUi.Ready)?.detail?.account?.name.orEmpty()
local.update { it.copy(menuOpen = false, deleteDialog = false, busy = true, message = null) }
viewModelScope.launch {
val result = when (action) {
AccountAction.Archive -> accounts.setArchived(id, true)
AccountAction.Unarchive -> accounts.setArchived(id, false)
AccountAction.Delete -> accounts.delete(id)
}
local.update { l ->
when (result) {
is OnlineResult.Ok -> when (action) {
AccountAction.Archive -> l.copy(busy = false, event = AccountEvent.Archived(name))
AccountAction.Unarchive -> l.copy(busy = false, message = AccountMessage.Restored(name))
AccountAction.Delete -> l.copy(busy = false, event = AccountEvent.Deleted)
}
OnlineResult.Offline -> l.copy(busy = false, message = AccountMessage.NeedsConnection)
is OnlineResult.Failed -> l.copy(busy = false, message = failureMessage(result.code, action))
}
}
}
}
companion object {
/** The navigation argument holding the account id (`:app` names its route argument so). */
const val ARG_ACCOUNT_ID = "accountId"
}
}
private fun failureMessage(code: String, action: AccountAction): AccountMessage = when (code) {
NETWORK -> AccountMessage.CouldNotReach(action)
"account_not_empty" -> AccountMessage.DeleteBlocked
else -> AccountMessage.Failed(code)
}
/** `OnlineResult.Failed` code of a network failure (design-spec §1, §9.9). */
internal const val NETWORK = "network"
@@ -0,0 +1,307 @@
package se.hagstedt.kvitt.feature.account
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.FlowRow
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.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalDensity
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.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import se.hagstedt.kvitt.design.KvittTheme
import se.hagstedt.kvitt.design.component.ConfirmDialog
import se.hagstedt.kvitt.design.component.ConnectedButtonGroup
import se.hagstedt.kvitt.design.component.KvittButton
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.KvittTextField
import se.hagstedt.kvitt.design.component.KvittTopAppBar
import se.hagstedt.kvitt.design.component.NeedsConnectionBanner
import se.hagstedt.kvitt.design.component.PersonAvatar
import se.hagstedt.kvitt.design.component.ProvideHousehold
import se.hagstedt.kvitt.design.component.SplitSlider
import se.hagstedt.kvitt.design.component.TopBarNavigation
import se.hagstedt.kvitt.design.component.kvittLanguage
import se.hagstedt.kvitt.design.component.money
import se.hagstedt.kvitt.design.component.ratioSpoken
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.AccountId
import se.hagstedt.kvitt.model.User
import se.hagstedt.kvitt.core.design.R as DesignR
/** Actions of the account editor; `:app` wires the navigation lambdas (D-017). */
data class AccountEditorActions(
val onClose: () -> Unit = {},
val onName: (String) -> Unit = {},
val onPercent: (PercentField, String) -> Unit = { _, _ -> },
val onViewerPercent: (Int) -> Unit = {},
val onSave: () -> Unit = {},
val onDiscard: () -> Unit = {},
val onKeepEditing: () -> Unit = {},
val onMessageShown: () -> Unit = {},
)
/**
* Account editor (S-6): a new account without [AccountDetailViewModel.ARG_ACCOUNT_ID], else that account. [onSaved]
* gets the saved account's id (new: open S-5 of it; edit: back to S-5). [onClose] closes without saving.
*/
@Composable
fun AccountEditorScreen(
onSaved: (AccountId) -> Unit,
onClose: () -> Unit,
modifier: Modifier = Modifier,
viewModel: AccountEditorViewModel = hiltViewModel(),
) {
val ui by viewModel.ui.collectAsStateWithLifecycle()
LaunchedEffect(ui.saved) {
val saved = ui.saved ?: return@LaunchedEffect
viewModel.savedHandled()
onSaved(saved)
}
val actions = AccountEditorActions(
onClose = { if (viewModel.requestClose()) onClose() },
onName = viewModel::onName,
onPercent = viewModel::onPercent,
onViewerPercent = viewModel::onViewerPercent,
onSave = viewModel::save,
onDiscard = onClose,
onKeepEditing = viewModel::keepEditing,
onMessageShown = viewModel::messageShown,
)
AccountEditorContent(ui, actions, modifier)
}
/** The stateless account editor, rendered by [AccountEditorScreen], previews and screenshot tests. */
@Composable
fun AccountEditorContent(ui: AccountEditorUi, actions: AccountEditorActions, modifier: Modifier = Modifier) {
val snackbar = remember { SnackbarHostState() }
ui.message?.let { EditorMessageEffect(it, snackbar, actions) }
KvittScaffold(
modifier = modifier,
topBar = {
KvittTopAppBar(
stringResource(if (ui.editing == null) R.string.account_editor_new else R.string.account_editor_edit),
navigation = TopBarNavigation.Close,
onNavigation = actions.onClose,
actions = { SaveAction(ui, actions) },
)
},
snackbarHostState = snackbar,
) { padding ->
val me = ui.me
val partner = ui.partner
if (me == null || partner == null) {
Box(Modifier.fillMaxSize().padding(padding))
} else {
ProvideHousehold(me, partner) { EditorForm(ui, me, partner, actions, padding) }
}
if (ui.discardDialog) {
ConfirmDialog(
icon = KvittIcon.Delete,
title = stringResource(DesignR.string.common_discard_title),
body = "",
confirm = stringResource(DesignR.string.common_discard),
dismiss = stringResource(DesignR.string.common_keep_editing),
onConfirm = actions.onDiscard,
onDismiss = actions.onKeepEditing,
destructive = true,
)
}
}
}
/** "Spara" (small filled); a progress indicator in its place while saving (design-spec S-6). */
@Composable
private fun SaveAction(ui: AccountEditorUi, actions: AccountEditorActions) {
Row(Modifier.padding(end = KvittTheme.spacing.s2), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2)) {
if (ui.saving) CircularProgressIndicator(Modifier.size(KvittDimens.iconButton), strokeWidth = KvittDimens.placeholderBorder)
KvittButton(stringResource(DesignR.string.common_save), actions.onSave, enabled = ui.canSave)
}
}
@Composable
private fun EditorForm(ui: AccountEditorUi, me: User, partner: User, actions: AccountEditorActions, padding: PaddingValues) {
val spacing = KvittTheme.spacing
val focus = remember { FocusRequester() }
LaunchedEffect(ui.loaded) {
if (ui.loaded && ui.editing == null && ui.online) focus.requestFocus()
}
Column(
Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(horizontal = spacing.gutter, vertical = spacing.s1),
verticalArrangement = Arrangement.spacedBy(KvittDimens.dialogGap),
) {
if (!ui.online) {
NeedsConnectionBanner(stringResource(if (ui.editing == null) R.string.account_editor_offline_body else R.string.account_editor_offline_body_edit))
}
KvittTextField(
value = ui.name,
onValueChange = actions.onName,
label = stringResource(DesignR.string.common_name),
modifier = Modifier.focusRequester(focus),
error = nameErrorText(ui.nameError),
capitalization = KeyboardCapitalization.Sentences,
enabled = ui.enabled,
)
SplitCard(ui, me, partner, actions)
}
}
@Composable
private fun nameErrorText(error: NameError?): String? = when (error) {
null -> null
NameError.Required -> stringResource(DesignR.string.common_name_required)
NameError.TooLong -> pluralStringResource(DesignR.plurals.error_too_long, MAX_NAME, MAX_NAME)
is NameError.Taken -> stringResource(R.string.account_editor_name_taken, error.name)
NameError.Invalid -> stringResource(DesignR.string.error_validation)
}
/** The "Standarddelning" card: people, slider, percent fields, presets 40/60 · 50/50 · 60/40 and the preview. */
@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class)
@Composable
private fun SplitCard(ui: AccountEditorUi, me: User, partner: User, actions: AccountEditorActions) {
val language = kvittLanguage()
val spacing = KvittTheme.spacing
val large = LocalDensity.current.fontScale >= KvittDimens.LARGE_FONT_SCALE
val bp = ui.viewerBp
fun text(field: PercentField, value: Int?): String = when {
ui.typed == field -> ui.typedText
value == null -> ""
else -> MoneyFormatter.basisPoints(value, language)
}
val viewerText = text(PercentField.Viewer, bp)
val partnerText = text(PercentField.Partner, bp?.let { BP_TOTAL - it })
val ratioError = if (ui.splitError || (bp == null && ui.typedText.isNotEmpty())) stringResource(DesignR.string.error_ratio_invalid) else null
KvittCard(
Modifier.fillMaxWidth().alpha(if (ui.enabled) 1f else KvittDimens.DISABLED_REASON_ALPHA),
contentPadding = PaddingValues(KvittDimens.cardPaddingLarge),
verticalArrangement = Arrangement.spacedBy(KvittDimens.dialogGap),
) {
Column(verticalArrangement = Arrangement.spacedBy(spacing.s1)) {
Text(stringResource(R.string.account_editor_default_split), style = MaterialTheme.typography.titleMedium)
Text(stringResource(R.string.account_editor_split_hint), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Row(verticalAlignment = Alignment.CenterVertically) {
PersonAvatar(me, KvittDimens.avatarSplit)
Text(me.displayName, Modifier.weight(1f).padding(start = spacing.s2), style = MaterialTheme.typography.titleSmall)
Text(partner.displayName, Modifier.padding(end = spacing.s2), style = MaterialTheme.typography.titleSmall)
PersonAvatar(partner, KvittDimens.avatarSplit)
}
SplitSlider(me, partner, (bp ?: HALF_BP) / BP_PER_PERCENT.toInt(), actions.onViewerPercent)
val viewerField = @Composable { m: Modifier ->
PercentInput(me.displayName, viewerText, ratioError, ui.enabled, m) { actions.onPercent(PercentField.Viewer, it) }
}
val partnerField = @Composable { m: Modifier ->
PercentInput(partner.displayName, partnerText, null, ui.enabled, m) { actions.onPercent(PercentField.Partner, it) }
}
if (large) {
viewerField(Modifier.fillMaxWidth())
partnerField(Modifier.fillMaxWidth())
} else {
Row(horizontalArrangement = Arrangement.spacedBy(spacing.s3)) {
viewerField(Modifier.weight(1f))
partnerField(Modifier.weight(1f))
}
}
val presets = PRESETS
val selected = presets.indexOfFirst { bp == it * BP_PER_PERCENT.toInt() }
ConnectedButtonGroup(
options = presets.map { stringResource(DesignR.string.common_ratio, it.toString(), (100 - it).toString()) },
selected = selected,
onSelect = { actions.onViewerPercent(presets[it]) },
groupLabel = stringResource(R.string.account_editor_default_split),
modifier = Modifier.fillMaxWidth(),
spoken = presets.map { ratioSpoken(me, it * BP_PER_PERCENT.toInt(), partner) },
)
if (bp != null) Preview(bp, me, partner)
}
}
private val PRESETS = listOf(40, 50, 60)
@Composable
private fun PercentInput(name: String, text: String, error: String?, enabled: Boolean, modifier: Modifier, onChange: (String) -> Unit) {
KvittTextField(
value = text,
onValueChange = onChange,
label = name,
modifier = modifier,
error = error,
keyboardType = KeyboardType.Decimal,
enabled = enabled,
suffix = stringResource(DesignR.string.editor_percent_suffix),
)
}
/**
* "Av 100,00 kr: Jacob 60,00 kr, Anna 40,00 kr" (`account_editor_preview`). Of 100,00 kr (10 000 öre) a share of
* `bp` basis points is exactly `bp` öre, so no rounding rule is involved.
*/
@Composable
private fun Preview(viewerBp: Int, me: User, partner: User) {
val scheme = MaterialTheme.colorScheme
val parts = listOf(me to viewerBp.toLong(), partner to (BP_TOTAL - viewerBp).toLong())
.map { (user, ore) -> stringResource(DesignR.string.common_user_split, user.displayName, money(ore)) }
.joinToString(stringResource(DesignR.string.common_separator_comma))
val text = stringResource(R.string.account_editor_preview, money(PREVIEW_ORE), parts)
FlowRow(
Modifier
.fillMaxWidth()
.background(scheme.surfaceContainerHigh, KvittTheme.shapes.field)
.padding(horizontal = KvittDimens.directionPadding, vertical = KvittTheme.spacing.s3)
.clearAndSetSemantics { contentDescription = text },
horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3),
itemVerticalAlignment = Alignment.CenterVertically,
) {
KvittIconImage(KvittIcon.Calculate, null, tint = scheme.onSurfaceVariant)
Text(text, Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium)
}
}
private const val PREVIEW_ORE = BP_TOTAL.toLong()
@Composable
private fun EditorMessageEffect(message: EditorMessage, host: SnackbarHostState, actions: AccountEditorActions) {
val text = when (message) {
EditorMessage.CouldNotReach -> stringResource(DesignR.string.common_could_not_reach_server)
EditorMessage.NeedsConnection -> stringResource(DesignR.string.common_needs_connection)
is EditorMessage.Failed -> errorText(message.code)
}
val action = if (message == EditorMessage.CouldNotReach) stringResource(DesignR.string.common_retry) else null
LaunchedEffect(message, text, action) {
val result = host.showSnackbar(text, action, duration = if (action != null) SnackbarDuration.Long else SnackbarDuration.Short)
if (result == SnackbarResult.ActionPerformed) actions.onSave()
actions.onMessageShown()
}
}
@@ -0,0 +1,201 @@
package se.hagstedt.kvitt.feature.account
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import se.hagstedt.kvitt.model.AccountId
import se.hagstedt.kvitt.model.AccountRepository
import se.hagstedt.kvitt.model.ConnectivityRepository
import se.hagstedt.kvitt.model.OnlineResult
import se.hagstedt.kvitt.model.SessionRepository
import se.hagstedt.kvitt.model.SessionState
import se.hagstedt.kvitt.model.User
import javax.inject.Inject
/** Which percent field the user typed in last (the other one follows as 100 minus it, AC-6.1). */
enum class PercentField { Viewer, Partner }
/** Why the name field is in error (S-6). */
sealed interface NameError {
data object Required : NameError
/** More than [MAX_NAME] code points (§9.6). */
data object TooLong : NameError
/** `name_taken` (AC-6.2) for [name]. */
data class Taken(val name: String) : NameError
/** `validation_failed` on the name from the server. */
data object Invalid : NameError
}
/** A snackbar of S-6. */
sealed interface EditorMessage {
data object CouldNotReach : EditorMessage
data object NeedsConnection : EditorMessage
data class Failed(val code: String) : EditorMessage
}
/**
* What the account editor (S-6) shows.
*
* @property viewerBp the viewer's default share in bp, null while the typed percent is not a valid 0–100 with at most
* two decimals; the partner's is always `10 000 − viewerBp` (AC-6.1).
* @property typed the field typed in and its raw [typedText]; the other field shows the formatted complement.
* @property saved the account id after a successful save (new: the new account), consumed by the screen.
*/
data class AccountEditorUi(
val editing: AccountId?,
val me: User? = null,
val partner: User? = null,
val loaded: Boolean = false,
val name: String = "",
val viewerBp: Int? = HALF_BP,
val typed: PercentField? = null,
val typedText: String = "",
val online: Boolean = true,
val saving: Boolean = false,
val serverNameError: NameError? = null,
val splitError: Boolean = false,
val message: EditorMessage? = null,
val discardDialog: Boolean = false,
val saved: AccountId? = null,
val initialName: String = "",
val initialBp: Int = HALF_BP,
) {
/** The name error shown: a live length check, else the server's answer to the last save. */
val nameError: NameError? get() = if (name.trim().codePointCount() > MAX_NAME) NameError.TooLong else serverNameError
/** "Spara": a non-blank name within the limit, percentages that sum to 100, online, not already saving (S-6). */
val canSave: Boolean get() = loaded && online && !saving && name.isNotBlank() && viewerBp != null && nameError != NameError.TooLong
/** Field, card and "Spara" are disabled offline (AC-6.7). */
val enabled: Boolean get() = online && !saving
val dirty: Boolean get() = name != initialName || viewerBp != initialBp
}
private fun String.codePointCount(): Int = codePointCount(0, length)
/** §9.6: account names are 1–60 code points. */
internal const val MAX_NAME = 60
/** State holder of the account editor (S-6). [AccountDetailViewModel.ARG_ACCOUNT_ID] absent: a new account. */
@HiltViewModel
class AccountEditorViewModel @Inject constructor(
savedState: SavedStateHandle,
private val accounts: AccountRepository,
session: SessionRepository,
connectivity: ConnectivityRepository,
) : ViewModel() {
private val editing: AccountId? = savedState.get<String>(AccountDetailViewModel.ARG_ACCOUNT_ID)?.let(::AccountId)
private val form = MutableStateFlow(AccountEditorUi(editing))
val ui: StateFlow<AccountEditorUi> = combine(form, connectivity.online) { f, online -> f.copy(online = online) }
.stateIn(viewModelScope, SharingStarted.Eagerly, AccountEditorUi(editing))
init {
viewModelScope.launch {
val paired = session.session.filterIsInstance<SessionState.Paired>().first()
val account = editing?.let { id -> accounts.all().first().firstOrNull { it.id == id } }
val name = account?.name.orEmpty()
val bp = account?.defaultShares?.get(paired.me.id) ?: HALF_BP
form.update {
it.copy(me = paired.me, partner = paired.partner, loaded = true, name = name, viewerBp = bp, initialName = name, initialBp = bp)
}
}
}
fun onName(name: String) = form.update { it.copy(name = name, serverNameError = null) }
/** A typed percent in [field]: digits with `,` or `.` and at most two decimals (AC-6.1). */
fun onPercent(field: PercentField, text: String) {
if (!PERCENT_INPUT.matches(text)) return
val bp = parsePercentBp(text)
form.update {
it.copy(
typed = field,
typedText = text,
viewerBp = bp?.let { v -> if (field == PercentField.Viewer) v else BP_TOTAL - v },
splitError = false,
)
}
}
/** The slider and the presets set whole percents for the viewer. */
fun onViewerPercent(percent: Int) = form.update {
it.copy(viewerBp = percent.coerceIn(0, 100) * BP_PER_PERCENT.toInt(), typed = null, typedText = "", splitError = false)
}
fun save() {
val f = form.value
val me = f.me ?: return
val partner = f.partner ?: return
val bp = f.viewerBp ?: return
if (!ui.value.canSave) return
form.update { it.copy(saving = true, message = null, serverNameError = null, splitError = false) }
viewModelScope.launch {
val shares = mapOf(me.id to bp, partner.id to BP_TOTAL - bp)
val result: OnlineResult<AccountId> = if (editing == null) {
accounts.create(f.name, shares)
} else {
when (val r = accounts.update(editing, f.name, shares)) {
is OnlineResult.Ok -> OnlineResult.Ok(editing)
OnlineResult.Offline -> OnlineResult.Offline
is OnlineResult.Failed -> r
}
}
form.update { s ->
when (result) {
is OnlineResult.Ok -> s.copy(saving = false, saved = result.value)
OnlineResult.Offline -> s.copy(saving = false, message = EditorMessage.NeedsConnection)
is OnlineResult.Failed -> s.failed(result.code, result.field)
}
}
}
}
/** Close: with changes the discard dialog first (design-spec S-6); returns true when the screen may close now. */
fun requestClose(): Boolean {
if (!form.value.dirty) return true
form.update { it.copy(discardDialog = true) }
return false
}
fun keepEditing() = form.update { it.copy(discardDialog = false) }
fun messageShown() = form.update { it.copy(message = null) }
fun savedHandled() = form.update { it.copy(saved = null) }
}
private fun AccountEditorUi.failed(code: String, field: String?): AccountEditorUi = when {
code == "name_taken" -> copy(saving = false, serverNameError = NameError.Taken(name.trim()))
code == "validation_failed" && field == "name" -> copy(saving = false, serverNameError = NameError.Invalid)
code == "validation_failed" && field == "default_shares" -> copy(saving = false, splitError = true)
code == NETWORK -> copy(saving = false, message = EditorMessage.CouldNotReach)
else -> copy(saving = false, message = EditorMessage.Failed(code))
}
/** Digits with an optional `,` or `.` and at most two decimals, up to "100". */
private val PERCENT_INPUT = Regex("""^\d{0,3}([.,]\d{0,2})?$""")
/** "60,5" -> 6050; null when empty or outside 0–100 (AC-6.1: percent with up to two decimals, stored as bp). */
internal fun parsePercentBp(text: String): Int? {
val normalized = text.replace(',', '.')
if (normalized.isEmpty() || normalized == ".") return null
val whole = normalized.substringBefore('.').ifEmpty { "0" }.toIntOrNull() ?: return null
val decimals = normalized.substringAfter('.', "").padEnd(2, '0').toIntOrNull() ?: return null
val bp = whole * 100 + decimals
return bp.takeIf { it in 0..BP_TOTAL }
}
@@ -1,14 +0,0 @@
package se.hagstedt.kvitt.feature.account
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 AccountEntry(modifier: Modifier = Modifier) {
Box(modifier)
}
@@ -0,0 +1,24 @@
package se.hagstedt.kvitt.feature.account
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.res.stringResource
import se.hagstedt.kvitt.core.design.R as DesignR
/** The snackbar text of an `OnlineResult.Failed` code without a field at fault (§14.3.2, strings.csv `error_*`). */
@Composable
@ReadOnlyComposable
internal fun errorText(code: String): String = stringResource(
when (code) {
NETWORK -> DesignR.string.common_could_not_reach_server
"household_incomplete" -> DesignR.string.error_household_incomplete
"not_found" -> DesignR.string.error_not_found
"forbidden" -> DesignR.string.error_forbidden
"rate_limited" -> DesignR.string.error_rate_limited
"protocol_unsupported" -> DesignR.string.error_app_too_old
"internal", "unavailable", "not_implemented" -> DesignR.string.error_server
"validation_failed" -> DesignR.string.error_validation
"account_not_empty" -> R.string.account_delete_blocked
else -> DesignR.string.error_generic_invalid
},
)
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Strings of :feature:account, from docs/ux/strings.csv (module=account). -->
<resources>
<!-- "50 / 50 · Jacob och Anna"; %1$s ratio, %2$s names with common_and -->
<string name="account_default_split">%1$s · %2$s</string>
<!-- TalkBack; %1$s editor_ratio_spoken -->
<string name="account_default_split_spoken">Standarddelning: %1$s</string>
<string name="account_settle_this">Gör upp det här kontot</string>
<string name="account_edit">Ändra konto</string>
<string name="account_archive">Arkivera</string>
<string name="account_unarchive">Återställ från arkiv</string>
<string name="account_delete">Ta bort konto</string>
<!-- AC-6.5; also the error account_not_empty -->
<string name="account_delete_blocked">Kontot kan inte tas bort så länge det har utgifter, betalningar eller återkommande utgifter</string>
<string name="account_delete_confirm_title">Ta bort kontot %1$s?</string>
<string name="account_delete_confirm_body">Det går inte att ångra.</string>
<string name="account_archived_snackbar">%1$s arkiverades</string>
<string name="account_unarchived_snackbar">%1$s återställdes</string>
<string name="account_empty_title">Inga utgifter än</string>
<string name="account_empty_body">Lägg till den första utgiften i %1$s.</string>
<string name="account_editor_new">Nytt konto</string>
<string name="account_editor_edit">Ändra konto</string>
<string name="account_editor_default_split">Standarddelning</string>
<!-- "Av 100,00 kr: Jacob 60,00 kr, Anna 40,00 kr" -->
<string name="account_editor_preview">Av %1$s: %2$s</string>
<!-- AC-6.3; S-6 under the split control (new and edit) -->
<string name="account_editor_split_hint">Gäller nya utgifter i kontot. Varje utgift kan ändra sin egen delning.</string>
<!-- §9.9; under the title common_needs_connection; new account -->
<string name="account_editor_offline_body">Konton sparas direkt på servern. Anslut telefonen till internet för att skapa kontot.</string>
<!-- §9.9; edit mode -->
<string name="account_editor_offline_body_edit">Konton sparas direkt på servern. Anslut telefonen till internet för att spara ändringen.</string>
<!-- name_taken -->
<string name="account_editor_name_taken">Det finns redan ett konto som heter %1$s</string>
</resources>
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Strings of :feature:account, from docs/ux/strings.csv (module=account). -->
<resources>
<!-- "50 / 50 · Jacob och Anna"; %1$s ratio, %2$s names with common_and -->
<string name="account_default_split">%1$s · %2$s</string>
<!-- TalkBack; %1$s editor_ratio_spoken -->
<string name="account_default_split_spoken">Default split: %1$s</string>
<string name="account_settle_this">Settle this account</string>
<string name="account_edit">Edit account</string>
<string name="account_archive">Archive</string>
<string name="account_unarchive">Restore from archive</string>
<string name="account_delete">Delete account</string>
<!-- AC-6.5; also the error account_not_empty -->
<string name="account_delete_blocked">The account cannot be deleted while it has expenses, payments or recurring expenses</string>
<string name="account_delete_confirm_title">Delete the account %1$s?</string>
<string name="account_delete_confirm_body">This cannot be undone.</string>
<string name="account_archived_snackbar">%1$s was archived</string>
<string name="account_unarchived_snackbar">%1$s was restored</string>
<string name="account_empty_title">No expenses yet</string>
<string name="account_empty_body">Add the first expense in %1$s.</string>
<string name="account_editor_new">New account</string>
<string name="account_editor_edit">Edit account</string>
<string name="account_editor_default_split">Default split</string>
<!-- "Av 100,00 kr: Jacob 60,00 kr, Anna 40,00 kr" -->
<string name="account_editor_preview">Of %1$s: %2$s</string>
<!-- AC-6.3; S-6 under the split control (new and edit) -->
<string name="account_editor_split_hint">Applies to new expenses in the account. Each expense can change its own split.</string>
<!-- §9.9; under the title common_needs_connection; new account -->
<string name="account_editor_offline_body">Accounts are saved straight to the server. Connect the phone to the internet to create the account.</string>
<!-- §9.9; edit mode -->
<string name="account_editor_offline_body_edit">Accounts are saved straight to the server. Connect the phone to the internet to save the change.</string>
<!-- name_taken -->
<string name="account_editor_name_taken">There is already an account called %1$s</string>
</resources>
@@ -0,0 +1,86 @@
package se.hagstedt.kvitt.feature.account
import androidx.compose.runtime.Composable
import androidx.compose.ui.test.junit4.v2.createComposeRule
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.FakeScenario
import se.hagstedt.kvitt.fakes.account.AccountScenarios
import se.hagstedt.kvitt.model.AccountId
/** One state of a screen: its golden name, whether a 200 % capture is taken, and its content. */
class ScreenState(val name: String, val largeFont: Boolean = false, val content: @Composable () -> Unit)
private const val LONG_NAME = "Gemensamma utgifter för sommarstugan i Österåker och båten i Vaxholm"
/** Every state of S-5 and S-6 (design-spec §4 and PLAN-M7b §5) × light/dark × sv/en. */
val accountStates: List<ScreenState> = listOf(
detail("S-5-account", AccountScenarios.loaded, AccountScenarios.vardag),
detail("S-5-account-negative", AccountScenarios.loaded, AccountScenarios.huset),
detail("S-5-account-archived", AccountScenarios.loaded, AccountScenarios.gotland),
detail("S-5-account-menu", AccountScenarios.loaded, AccountScenarios.vardag) { it.setMenuOpen(true) },
detail("S-5-account-menu-deletable", AccountScenarios.empty, AccountScenarios.sommarstugan.id) { it.setMenuOpen(true) },
detail("S-5-account-delete-confirm", AccountScenarios.empty, AccountScenarios.sommarstugan.id) { it.askDelete() },
detail("S-5-account-empty", AccountScenarios.empty, AccountScenarios.sommarstugan.id),
detail("S-5-account-offline", AccountScenarios.offline, AccountScenarios.vardag) { it.setMenuOpen(true) },
detail("S-5-account-error", AccountScenarios.loaded, AccountScenarios.vardag, failWith = NETWORK) { it.archive() },
detail("S-5-account-long", AccountScenarios.long, AccountScenarios.longAccount, largeFont = true),
editor("S-6-account-editor-create", AccountScenarios.editor),
editor("S-6-account-editor", AccountScenarios.editor, largeFont = true) { it.onName("Sommarstugan"); it.onViewerPercent(60) },
editor("S-6-account-editor-edit", AccountScenarios.editor, id = AccountScenarios.huset),
editor("S-6-account-editor-decimals", AccountScenarios.editor) { it.onName("Sommarstugan"); it.onPercent(PercentField.Viewer, "62,75") },
editor("S-6-account-editor-error", AccountScenarios.editor) { it.onName("Vardag"); it.save() },
editor("S-6-account-editor-validation", AccountScenarios.editor) { it.onName(LONG_NAME) },
editor("S-6-account-editor-network", AccountScenarios.editor, failWith = NETWORK) { it.onName("Sommarstugan"); it.save() },
editor("S-6-account-editor-offline", AccountScenarios.editorOffline) { it.onName("Sommarstugan") },
saving(),
)
private fun saving(): ScreenState {
val ui = editorState(AccountScenarios.editor) { it.onName("Sommarstugan"); it.onViewerPercent(60) }.copy(saving = true)
return ScreenState("S-6-account-editor-saving") { AccountEditorContent(ui, AccountEditorActions()) }
}
private fun detail(name: String, scenario: FakeScenario, id: AccountId, failWith: String? = null, largeFont: Boolean = false, act: (AccountDetailViewModel) -> Unit = {}): ScreenState {
val ui = detailState(scenario, id, failWith, act)
return ScreenState(name, largeFont) { AccountDetailContent(ui, AccountDetailActions()) }
}
private fun editor(name: String, scenario: FakeScenario, id: AccountId? = null, failWith: String? = null, largeFont: Boolean = false, act: (AccountEditorViewModel) -> Unit = {}): ScreenState {
val ui = editorState(scenario, id, failWith, act = act)
return ScreenState(name, largeFont) { AccountEditorContent(ui, AccountEditorActions()) }
}
/**
* Goldens of every account detail and account editor state. `check` verifies them; re-record an intended change with
* `tools/gradle-serial.sh :feature:account:recordRoborazziDebug` and open every changed PNG.
*/
@RunWith(ParameterizedRobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [36], qualifiers = ScreenSnapshots.QUALIFIERS)
class AccountScreenshotTest(private val state: String) {
@get:Rule
val compose = createComposeRule()
@Test
fun screen() {
val screen = accountStates.first { it.name == state }
ScreenSnapshots.snap(compose, screen.name, screen.largeFont, screen.content)
}
companion object {
@JvmStatic
@ParameterizedRobolectricTestRunner.Parameters(name = "{0}")
fun states(): List<Array<Any>> = listOf(
"S-5-account", "S-5-account-negative", "S-5-account-archived", "S-5-account-menu", "S-5-account-menu-deletable",
"S-5-account-delete-confirm", "S-5-account-empty", "S-5-account-offline", "S-5-account-error", "S-5-account-long",
"S-6-account-editor-create", "S-6-account-editor", "S-6-account-editor-edit", "S-6-account-editor-decimals",
"S-6-account-editor-error", "S-6-account-editor-validation", "S-6-account-editor-network", "S-6-account-editor-offline",
"S-6-account-editor-saving",
).map { arrayOf<Any>(it) }
}
}
@@ -0,0 +1,43 @@
package se.hagstedt.kvitt.feature.account
import androidx.lifecycle.SavedStateHandle
import se.hagstedt.kvitt.fakes.FakeRepositories
import se.hagstedt.kvitt.fakes.FakeScenario
import se.hagstedt.kvitt.model.AccountId
import se.hagstedt.kvitt.model.AccountRepository
import se.hagstedt.kvitt.model.OnlineResult
import se.hagstedt.kvitt.model.UserId
/** The fake accounts with every online call failing with [code] (a REST error, §9.9); reads are the fakes'. */
class FailingAccounts(private val delegate: AccountRepository, private val code: String, private val field: String? = null) : AccountRepository by delegate {
override suspend fun create(name: String, defaultShares: Map<UserId, Int>): OnlineResult<AccountId> = OnlineResult.Failed(code, field)
override suspend fun update(id: AccountId, name: String, defaultShares: Map<UserId, Int>): OnlineResult<Unit> = OnlineResult.Failed(code, field)
override suspend fun setArchived(id: AccountId, archived: Boolean): OnlineResult<Unit> = OnlineResult.Failed(code, field)
override suspend fun delete(id: AccountId): OnlineResult<Unit> = OnlineResult.Failed(code, field)
}
fun args(id: AccountId?): SavedStateHandle = SavedStateHandle(if (id == null) emptyMap() else mapOf(AccountDetailViewModel.ARG_ACCOUNT_ID to id.value))
/** S-5 for [id] in [scenario] after [act]; [failWith] makes the online calls fail with that code. */
fun detailState(scenario: FakeScenario, id: AccountId, failWith: String? = null, act: (AccountDetailViewModel) -> Unit = {}): AccountDetailUi = withMain {
val fakes = FakeRepositories(scenario)
val accounts = if (failWith == null) fakes.accounts else FailingAccounts(fakes.accounts, failWith)
val vm = AccountDetailViewModel(args(id), accounts, fakes.connectivity)
act(vm)
vm.ui.value
}
/** S-6 in [scenario], editing [id] or new, after [act]. */
fun editorState(
scenario: FakeScenario,
id: AccountId? = null,
failWith: String? = null,
field: String? = null,
act: (AccountEditorViewModel) -> Unit = {},
): AccountEditorUi = withMain {
val fakes = FakeRepositories(scenario)
val accounts = if (failWith == null) fakes.accounts else FailingAccounts(fakes.accounts, failWith, field)
val vm = AccountEditorViewModel(args(id), accounts, fakes.session, fakes.connectivity)
act(vm)
vm.ui.value
}
@@ -0,0 +1,215 @@
package se.hagstedt.kvitt.feature.account
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.fakes.FakeRepositories
import se.hagstedt.kvitt.fakes.FakeScenarios
import se.hagstedt.kvitt.fakes.account.AccountScenarios
import se.hagstedt.kvitt.model.AccountId
class AccountDetailViewModelTest {
private fun ready(ui: AccountDetailUi) = ui as AccountDetailUi.Ready
@Test
fun loadedAccountShowsBalanceMonthsAndFab() {
val ui = ready(detailState(AccountScenarios.loaded, AccountScenarios.vardag))
assertEquals(118_450L, ui.detail.balance.netOre)
assertEquals(listOf("2026-10", "2026-09"), ui.detail.months.map { it.period })
assertEquals(0, ui.colorIndex)
assertEquals(50f, ui.viewerPercent)
assertTrue(ui.showFab)
assertFalse(ui.canDelete)
assertTrue(ui.canEdit)
}
@Test
fun archivedAccountHidesTheFab() {
val ui = ready(detailState(AccountScenarios.loaded, AccountScenarios.gotland))
assertFalse(ui.showFab)
assertEquals(3, ui.colorIndex)
}
@Test
fun viewerPercentComesFromBasisPoints() {
assertEquals(60f, ready(detailState(AccountScenarios.loaded, AccountScenarios.huset)).viewerPercent)
}
@Test
fun emptyAccountMayBeDeletedOnlyOnline() {
assertTrue(ready(detailState(AccountScenarios.empty, AccountScenarios.sommarstugan.id)).canDelete)
val offline = AccountScenarios.empty.copy(online = false)
val ui = ready(detailState(offline, AccountScenarios.sommarstugan.id))
assertFalse(ui.canDelete)
assertFalse(ui.canEdit)
}
@Test
fun archiveNavigatesBackWithTheName() {
val ui = ready(detailState(AccountScenarios.loaded, AccountScenarios.vardag) { it.setMenuOpen(true); it.archive() })
assertEquals(AccountEvent.Archived("Vardag"), ui.event)
assertFalse(ui.menuOpen)
assertTrue(ui.detail.account.archived)
}
@Test
fun unarchiveStaysWithASnackbar() {
val ui = ready(detailState(AccountScenarios.loaded, AccountScenarios.gotland) { it.unarchive() })
assertEquals(AccountMessage.Restored("Gotland 2026"), ui.message)
assertFalse(ui.detail.account.archived)
assertNull(ui.event)
}
@Test
fun networkFailureOffersRetryAndChangesNothing() {
val ui = ready(detailState(AccountScenarios.loaded, AccountScenarios.vardag, failWith = NETWORK) { it.archive() })
assertEquals(AccountMessage.CouldNotReach(AccountAction.Archive), ui.message)
assertFalse(ui.detail.account.archived)
assertFalse(ui.busy)
val shown = ready(detailState(AccountScenarios.loaded, AccountScenarios.vardag, failWith = NETWORK) { it.archive(); it.messageShown() })
assertNull(shown.message)
}
@Test
fun offlineActionSaysNeedsConnection() {
val ui = ready(detailState(AccountScenarios.offline, AccountScenarios.vardag) { it.archive() })
assertEquals(AccountMessage.NeedsConnection, ui.message)
}
@Test
fun deleteAsksThenDeletes() {
val asked = ready(detailState(AccountScenarios.empty, AccountScenarios.sommarstugan.id) { it.askDelete() })
assertTrue(asked.deleteDialog)
val dismissed = ready(detailState(AccountScenarios.empty, AccountScenarios.sommarstugan.id) { it.askDelete(); it.dismissDelete() })
assertFalse(dismissed.deleteDialog)
val deleted = detailState(AccountScenarios.empty, AccountScenarios.sommarstugan.id) { it.askDelete(); it.confirmDelete() }
assertEquals(AccountDetailUi.Gone(AccountEvent.Deleted), deleted)
}
@Test
fun deleteOfANonEmptyAccountIsRefused() {
val ui = ready(detailState(AccountScenarios.loaded, AccountScenarios.vardag) { it.confirmDelete() })
assertEquals(AccountMessage.DeleteBlocked, ui.message)
}
@Test
fun otherErrorsKeepTheirCode() {
val ui = ready(detailState(AccountScenarios.loaded, AccountScenarios.vardag, failWith = "forbidden") { it.archive() })
assertEquals(AccountMessage.Failed("forbidden"), ui.message)
}
@Test
fun unknownAccountIsGone() {
assertEquals(AccountDetailUi.Gone(AccountEvent.Gone), detailState(AccountScenarios.loaded, AccountId("missing")))
}
}
class AccountEditorViewModelTest {
@Test
fun newAccountStartsAtFiftyFiftyAndNeedsAName() {
val ui = editorState(AccountScenarios.editor)
assertTrue(ui.loaded)
assertNull(ui.editing)
assertEquals(5_000, ui.viewerBp)
assertEquals(FakeScenarios.owner, ui.me)
assertFalse(ui.canSave)
assertTrue(editorState(AccountScenarios.editor) { it.onName("Sommarstugan") }.canSave)
assertFalse(editorState(AccountScenarios.editor) { it.onName(" ") }.canSave)
}
@Test
fun editStartsFromTheAccount() {
val ui = editorState(AccountScenarios.editor, id = AccountScenarios.huset)
assertEquals("Huset", ui.name)
assertEquals(6_000, ui.viewerBp)
assertFalse(ui.dirty)
}
@Test
fun percentsAcceptTwoDecimalsAndTheOtherFollows() {
assertEquals(6_275, editorState(AccountScenarios.editor) { it.onPercent(PercentField.Viewer, "62,75") }.viewerBp)
assertEquals(10_000 - 3_350, editorState(AccountScenarios.editor) { it.onPercent(PercentField.Partner, "33.5") }.viewerBp)
// A third decimal is not accepted: the text stays as it was.
val third = editorState(AccountScenarios.editor) { it.onPercent(PercentField.Viewer, "62,75"); it.onPercent(PercentField.Viewer, "62,755") }
assertEquals("62,75", third.typedText)
// Over 100 is not a valid ratio: Spara is off.
val over = editorState(AccountScenarios.editor) { it.onName("X"); it.onPercent(PercentField.Viewer, "101") }
assertNull(over.viewerBp)
assertFalse(over.canSave)
assertNull(editorState(AccountScenarios.editor) { it.onPercent(PercentField.Viewer, "") }.viewerBp)
}
@Test
fun parsePercent() {
assertEquals(6_000, parsePercentBp("60"))
assertEquals(6_050, parsePercentBp("60,5"))
assertEquals(5, parsePercentBp("0.05"))
assertEquals(10_000, parsePercentBp("100"))
assertNull(parsePercentBp("100,01"))
assertNull(parsePercentBp(","))
}
@Test
fun sliderAndPresetsSetWholePercents() {
val ui = editorState(AccountScenarios.editor) { it.onPercent(PercentField.Viewer, "12,5"); it.onViewerPercent(40) }
assertEquals(4_000, ui.viewerBp)
assertNull(ui.typed)
}
@Test
fun createSavesBasisPointsAndReportsTheNewId() {
val fakes = FakeRepositories(AccountScenarios.editor)
val vm = withMain { AccountEditorViewModel(args(null), fakes.accounts, fakes.session, fakes.connectivity) }
withMain { vm.onName("Sommarstugan"); vm.onPercent(PercentField.Viewer, "62,5"); vm.save() }
val saved = vm.ui.value.saved
val created = fakes.store.current.accounts.single { it.name == "Sommarstugan" }
assertEquals(created.id, saved)
assertEquals(mapOf(FakeScenarios.owner.id to 6_250, FakeScenarios.partner.id to 3_750), created.defaultShares)
}
@Test
fun nameTakenIsShownUnderTheName() {
val ui = editorState(AccountScenarios.editor) { it.onName(" vardag "); it.save() }
assertEquals(NameError.Taken("vardag"), ui.nameError)
assertFalse(ui.saving)
assertTrue(ui.canSave)
assertNull(editorState(AccountScenarios.editor) { it.onName("Vardag"); it.save(); it.onName("Vardag 2") }.nameError)
}
@Test
fun tooLongNameIsCaughtBeforeSaving() {
val ui = editorState(AccountScenarios.editor) { it.onName("a".repeat(MAX_NAME + 1)) }
assertEquals(NameError.TooLong, ui.nameError)
assertFalse(ui.canSave)
}
@Test
fun serverValidationMapsToTheField() {
assertEquals(NameError.Invalid, editorState(AccountScenarios.editor, failWith = "validation_failed", field = "name") { it.onName("X"); it.save() }.nameError)
assertTrue(editorState(AccountScenarios.editor, failWith = "validation_failed", field = "default_shares") { it.onName("X"); it.save() }.splitError)
assertEquals(EditorMessage.CouldNotReach, editorState(AccountScenarios.editor, failWith = NETWORK) { it.onName("X"); it.save() }.message)
assertEquals(EditorMessage.Failed("household_incomplete"), editorState(AccountScenarios.editor, failWith = "household_incomplete") { it.onName("X"); it.save() }.message)
}
@Test
fun offlineCannotSave() {
val ui = editorState(AccountScenarios.editorOffline) { it.onName("Sommarstugan") }
assertFalse(ui.online)
assertFalse(ui.enabled)
assertFalse(ui.canSave)
}
@Test
fun closingWithChangesAsksFirst() {
val fakes = FakeRepositories(AccountScenarios.editor)
val vm = withMain { AccountEditorViewModel(args(null), fakes.accounts, fakes.session, fakes.connectivity) }
assertTrue(withMain { vm.requestClose() })
withMain { vm.onName("Sommarstugan") }
assertFalse(withMain { vm.requestClose() })
assertTrue(vm.ui.value.discardDialog)
withMain { vm.keepEditing() }
assertFalse(vm.ui.value.discardDialog)
}
}
@@ -0,0 +1,92 @@
package se.hagstedt.kvitt.feature.account
import android.content.res.Configuration
import android.os.LocaleList
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.unit.Density
import com.github.takahirom.roborazzi.ExperimentalRoborazziApi
import com.github.takahirom.roborazzi.captureScreenRoboImage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
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 java.util.Locale
import kotlin.time.Instant
/**
* Full-screen goldens of this module's screens, as M6's component snapshots but for whole screens (dialogs and menus
* included): `src/test/screenshots/<state>_<sv|en>_<light|dark>.png`, plus `<state>_sv_light_200.png` at 200 % font
* scale for the long states. The screen is the Galaxy S26 (411 × 891 dp), at xhdpi to keep the goldens small. The
* clock is fixed at 17 October 2026 14:02 in Stockholm; the frame clock is advanced by hand so animations and
* snackbars render the same every run.
*/
object ScreenSnapshots {
const val QUALIFIERS = "w411dp-h891dp-xhdpi"
val clock = KvittClock.fixed(Instant.parse("2026-10-17T12:02:00Z"), TimeZone.of("Europe/Stockholm"))
private const val SETTLE_MILLIS = 1_000L
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 ""
}
@OptIn(ExperimentalRoborazziApi::class)
fun snap(compose: ComposeContentTestRule, name: String, largeFont: Boolean, 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.mainClock.autoAdvance = false
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, content = content)
}
}
variants.forEach { variant ->
current = variant
compose.mainClock.advanceTimeBy(SETTLE_MILLIS)
captureScreenRoboImage("src/test/screenshots/${name}_${variant.suffix}.png")
}
}
}
/** Runs [block] with `Dispatchers.Main` unconfined, so a ViewModel's state is ready when it returns. */
@OptIn(ExperimentalCoroutinesApi::class)
fun <T> withMain(block: () -> T): T {
Dispatchers.setMain(UnconfinedTestDispatcher())
try {
return block()
} finally {
Dispatchers.resetMain()
}
}
+16
View File
@@ -1,4 +1,20 @@
// :feature:home — Home with global balance, accounts, sync line and update banner (S-4, S-16, S-17; M7b). Convention: android/README.md.
// Dependencies: :core:model and :core:design; :core:fakes for tests and debug only (all three from kvitt.android.feature).
plugins {
id("kvitt.android.feature")
alias(libs.plugins.roborazzi)
}
roborazzi {
outputDir.set(file("src/test/screenshots"))
}
dependencies {
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)
debugImplementation(libs.androidx.compose.ui.test.manifest)
}
@@ -1,14 +0,0 @@
package se.hagstedt.kvitt.feature.home
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 HomeEntry(modifier: Modifier = Modifier) {
Box(modifier)
}
@@ -0,0 +1,291 @@
package se.hagstedt.kvitt.feature.home
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.semantics
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.delay
import se.hagstedt.kvitt.design.KvittTheme
import se.hagstedt.kvitt.design.component.AccountCard
import se.hagstedt.kvitt.design.component.AddExpenseFab
import se.hagstedt.kvitt.design.component.BalanceHero
import se.hagstedt.kvitt.design.component.CollapsibleRow
import se.hagstedt.kvitt.design.component.ConfirmDialog
import se.hagstedt.kvitt.design.component.EmptyState
import se.hagstedt.kvitt.design.component.HeroState
import se.hagstedt.kvitt.design.component.HeroWords
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.PersonAvatar
import se.hagstedt.kvitt.design.component.ProvideHousehold
import se.hagstedt.kvitt.design.component.SectionHeader
import se.hagstedt.kvitt.design.component.Skeleton
import se.hagstedt.kvitt.design.component.SyncStatusPill
import se.hagstedt.kvitt.design.component.UpdateBanner
import se.hagstedt.kvitt.design.component.kvittLanguage
import se.hagstedt.kvitt.design.format.DateFormatter
import se.hagstedt.kvitt.design.icon.KvittIcon
import se.hagstedt.kvitt.design.theme.KvittDimens
import se.hagstedt.kvitt.model.AccountId
import se.hagstedt.kvitt.model.AccountSummary
import se.hagstedt.kvitt.model.User
import se.hagstedt.kvitt.core.design.R as DesignR
/** Navigation and actions of Home; `:app` wires the navigation lambdas (D-017). */
data class HomeActions(
val onAddExpense: () -> Unit = {},
val onSettleUp: () -> Unit = {},
val onOpenAccount: (AccountId) -> Unit = {},
val onNewAccount: () -> Unit = {},
val onOpenSyncStatus: () -> Unit = {},
val onNavigate: (KvittDestination) -> Unit = {},
val onToggleArchived: () -> Unit = {},
val onUpdate: () -> Unit = {},
val onUpdateLater: () -> Unit = {},
val onUpdateRetry: () -> Unit = {},
val onInstallContinue: () -> Unit = {},
val onInstallDismiss: () -> Unit = {},
)
/**
* Home (S-4) with the update banner and the "Tillåt installation" dialog (S-17). [snackbarHostState] lets `:app` show
* the snackbar of the screen it returns from (S-7 saved, S-9 "Betalningen sparades", S-5 archived). While
* `HomeState.partner` is null `:app` shows S-3 (`:feature:onboarding`) in this destination instead (AC-5.1).
*/
@Composable
fun HomeScreen(
onAddExpense: () -> Unit,
onSettleUp: () -> Unit,
onOpenAccount: (AccountId) -> Unit,
onNewAccount: () -> Unit,
onOpenSyncStatus: () -> Unit,
onNavigate: (KvittDestination) -> Unit,
modifier: Modifier = Modifier,
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
viewModel: HomeViewModel = hiltViewModel(),
) {
val ui by viewModel.ui.collectAsStateWithLifecycle()
// The sync line's relative time is refreshed every minute (design-spec §3.6).
var minute by remember { mutableIntStateOf(0) }
LaunchedEffect(Unit) {
while (true) {
delay(MINUTE_MILLIS)
minute += 1
}
}
val actions = HomeActions(
onAddExpense = onAddExpense,
onSettleUp = onSettleUp,
onOpenAccount = onOpenAccount,
onNewAccount = onNewAccount,
onOpenSyncStatus = onOpenSyncStatus,
onNavigate = onNavigate,
onToggleArchived = viewModel::toggleArchived,
onUpdate = viewModel::onUpdate,
onUpdateLater = viewModel::onLater,
onUpdateRetry = viewModel::onRetry,
onInstallContinue = viewModel::onInstallDialogContinue,
onInstallDismiss = viewModel::onInstallDialogDismiss,
)
HomeContent(ui, actions, modifier, snackbarHostState, minute)
}
private const val MINUTE_MILLIS = 60_000L
/** The stateless Home, rendered by [HomeScreen], previews and screenshot tests. */
@Composable
fun HomeContent(
ui: HomeUi,
actions: HomeActions,
modifier: Modifier = Modifier,
snackbarHostState: SnackbarHostState? = null,
tick: Int = 0,
listState: LazyListState = rememberLazyListState(),
) {
val ready = ui as? HomeUi.Ready
val scrolled by remember { derivedStateOf { listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0 } }
val fabExpanded by remember { derivedStateOf { !listState.lastScrolledForward } }
KvittScaffold(
modifier = modifier,
topBar = { KvittTopAppBar(title = null, scrolled = scrolled) },
bottomBar = { KvittNavigationBar(KvittDestination.Home, actions.onNavigate) },
floatingActionButton = { if (ready?.showFab == true) AddExpenseFab(actions.onAddExpense, expanded = fabExpanded) },
snackbarHostState = snackbarHostState,
) { padding ->
val partner = ready?.home?.partner
if (ready != null && partner != null) {
ProvideHousehold(ready.home.me, partner) {
HomeList(ready, partner, actions, padding, tick, listState)
}
} else {
Box(Modifier.fillMaxSize().padding(padding))
}
if (ready?.installDialog == true) InstallDialog(actions)
}
}
@Composable
private fun HomeList(ui: HomeUi.Ready, partner: User, actions: HomeActions, padding: PaddingValues, tick: Int, listState: LazyListState) {
val spacing = KvittTheme.spacing
val words = HeroWords(
settleUp = stringResource(R.string.home_settle_up),
loadingTitle = stringResource(R.string.home_loading),
loadingBody = stringResource(R.string.home_loading_body),
)
val colorIndex = ui.colorIndex
LazyColumn(
Modifier.fillMaxSize().padding(padding),
state = listState,
contentPadding = PaddingValues(start = spacing.gutter, end = spacing.gutter, top = spacing.s1, bottom = spacing.fabClearance),
verticalArrangement = Arrangement.spacedBy(spacing.sectionGap),
) {
item(key = "update") { UpdateBannerItem(ui, actions) }
if (ui.loading) {
item(key = "hero") { BalanceHero(HeroState.Loading, ui.home.me, words, actions.onSettleUp) }
item(key = "pill") {
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
Skeleton(spacing.s1, KvittDimens.pillMinHeight, Modifier.fillMaxWidth(SKELETON_PILL_FRACTION), pill = true)
}
}
item(key = "header") { SectionHeader(stringResource(R.string.home_accounts)) }
items(SKELETON_CARDS) { Skeleton(spacing.s1, KvittDimens.accountCardMin, Modifier.fillMaxWidth()) }
return@LazyColumn
}
if (ui.empty) {
item(key = "welcome") { WelcomeCard(ui.home.me, partner) }
item(key = "empty") {
EmptyState(
title = stringResource(R.string.home_empty_title),
body = stringResource(R.string.home_empty_body),
illustration = ui.illustration,
action = stringResource(R.string.home_new_account),
actionIcon = KvittIcon.Add,
onAction = actions.onNewAccount,
)
}
return@LazyColumn
}
item(key = "hero") { BalanceHero(HeroState.of(ui.home.global), ui.home.me, words, actions.onSettleUp) }
item(key = "pill") {
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
key(tick) { SyncStatusPill(ui.home.sync, ui.online, actions.onOpenSyncStatus) }
}
}
item(key = "header") {
SectionHeader(
stringResource(R.string.home_accounts),
action = stringResource(R.string.home_new_account),
actionIcon = KvittIcon.Add,
actionEnabled = ui.online,
actionSupporting = if (ui.online) null else stringResource(DesignR.string.common_needs_connection),
onAction = actions.onNewAccount,
)
}
ui.home.active.forEach { summary ->
item(key = summary.account.id.value) { HomeAccountCard(summary, partner, colorIndex[summary.account.id.value] ?: 0, actions) }
}
if (ui.home.archived.isNotEmpty()) {
item(key = "archived") {
CollapsibleRow(
title = stringResource(R.string.home_archived_section, ui.home.archived.size),
expanded = ui.archivedExpanded,
expandedState = stringResource(R.string.home_archived_expanded),
collapsedState = stringResource(R.string.home_archived_collapsed),
onToggle = actions.onToggleArchived,
)
}
if (ui.archivedExpanded) {
ui.home.archived.forEach { summary ->
item(key = summary.account.id.value) { HomeAccountCard(summary, partner, colorIndex[summary.account.id.value] ?: 0, actions) }
}
}
}
}
}
private const val SKELETON_CARDS = 3
private const val SKELETON_PILL_FRACTION = 0.5f
@Composable
private fun UpdateBannerItem(ui: HomeUi.Ready, actions: HomeActions) {
UpdateBanner(ui.update, ui.online, actions.onUpdate, actions.onUpdateLater, actions.onUpdateRetry)
}
@Composable
private fun HomeAccountCard(summary: AccountSummary, partner: User, colorIndex: Int, actions: HomeActions) {
AccountCard(summary, partner, colorIndex, expensesLine(summary.expensesThisMonth), onClick = { actions.onOpenAccount(summary.account.id) })
}
/** "11 utgifter i oktober" / "Inga utgifter i oktober" for the current month (design-spec §3.4). */
@Composable
@ReadOnlyComposable
private fun expensesLine(count: Int): String {
val today = LocalKvittClock.current.today
val period = today.year.toString() + "-" + today.month.ordinal.plus(1).toString().padStart(2, '0')
// monthYear is "oktober 2026" / "October 2026"; running text needs the month alone.
val month = DateFormatter.monthYear(period, kvittLanguage()).substringBeforeLast(' ')
return if (count > 0) pluralStringResource(R.plurals.home_account_expenses_month, count, count, month) else stringResource(R.string.home_account_no_expenses_month, month)
}
/** S-4 empty: both avatars, "Du och Anna är med", "Hushållet är klart. Nu behövs ett konto." */
@Composable
private fun WelcomeCard(me: User, partner: User) {
KvittCard(Modifier.fillMaxWidth().semantics(mergeDescendants = true) {}) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.cardTileGap)) {
Box {
PersonAvatar(me, KvittDimens.accountTile)
PersonAvatar(partner, KvittDimens.accountTile, Modifier.padding(start = KvittDimens.accountTile - KvittTheme.spacing.s2))
}
Column(verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s1)) {
Text(stringResource(R.string.home_empty_welcome, partner.displayName), style = MaterialTheme.typography.titleMedium)
Text(stringResource(R.string.home_empty_subtitle), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
/** S-17: "Tillåt installation", shown once before the first install (AC-16.3). */
@Composable
private fun InstallDialog(actions: HomeActions) {
ConfirmDialog(
icon = KvittIcon.InstallMobile,
title = stringResource(DesignR.string.update_permission_title),
body = stringResource(DesignR.string.update_permission_body),
confirm = stringResource(DesignR.string.common_continue),
dismiss = stringResource(DesignR.string.update_later),
onConfirm = actions.onInstallContinue,
onDismiss = actions.onInstallDismiss,
tertiaryTile = true,
confirmIcon = KvittIcon.ArrowForward,
)
}
@@ -0,0 +1,129 @@
package se.hagstedt.kvitt.feature.home
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import se.hagstedt.kvitt.model.AccountSummary
import se.hagstedt.kvitt.model.Category
import se.hagstedt.kvitt.model.CategoryRepository
import se.hagstedt.kvitt.model.DefaultCategoryKey
import se.hagstedt.kvitt.model.ConnectivityRepository
import se.hagstedt.kvitt.model.HomeRepository
import se.hagstedt.kvitt.model.HomeState
import se.hagstedt.kvitt.model.UpdateRepository
import se.hagstedt.kvitt.model.UpdateState
import javax.inject.Inject
/** What Home (S-4, with the S-17 banner and dialog) shows. */
sealed interface HomeUi {
/** Before the first emission of the local copy. */
data object Starting : HomeUi
/**
* @property loading the first sync after pairing is still running (no successful sync yet): hero `Loading` and
* skeletons, no FAB.
* @property update the update banner's state, [UpdateState.None] while hidden ("Senare" until the next cold start).
* @property installDialog the "Tillåt installation" dialog is open (S-17).
* @property illustration the three tiles of the empty state (groceries, travel, restaurants; design-spec S-4).
*/
data class Ready(
val home: HomeState,
val online: Boolean,
val update: UpdateState,
val archivedExpanded: Boolean,
val installDialog: Boolean,
val loading: Boolean,
val illustration: List<Category> = emptyList(),
) : HomeUi {
/** No account at all: the empty state, no sync pill, no FAB (AC-8.6 needs an account). */
val empty: Boolean get() = !loading && home.active.isEmpty() && home.archived.isEmpty()
/** The FAB only while an active account exists (UX-6). */
val showFab: Boolean get() = !loading && home.active.isNotEmpty()
/** "Gör upp" and the hero amount only with a partner and a non-zero balance (AC-10.1). */
val canSettle: Boolean get() = (home.global?.netOre ?: 0L) != 0L
/**
* Colour slot per account: its position among all accounts by `created_at`, archived included (design-spec §2.4,
* AC-6.6).
*/
val colorIndex: Map<String, Int>
get() = (home.active + home.archived).map(AccountSummary::account)
.sortedWith(compareBy({ it.createdAt }, { it.id.value }))
.mapIndexed { index, account -> account.id.value to index }
.toMap()
}
}
/** Session-scoped choices on Home: the archived section, a hidden update banner, the install dialog. */
private data class HomeLocal(val archivedExpanded: Boolean = false, val updateHidden: Boolean = false, val installDialog: Boolean = false)
/** State holder of Home (S-4) and its update banner and dialog (S-17). */
@HiltViewModel
class HomeViewModel @Inject constructor(
home: HomeRepository,
connectivity: ConnectivityRepository,
private val updates: UpdateRepository,
categories: CategoryRepository,
) : ViewModel() {
private val local = MutableStateFlow(HomeLocal())
private val illustration = categories.all().map { all ->
EMPTY_ILLUSTRATION.mapNotNull { key -> all.firstOrNull { it.defaultKey == key } }
}
val ui: StateFlow<HomeUi> = combine(home.home(), connectivity.online, updates.state, local, illustration) { state, online, update, l, tiles ->
HomeUi.Ready(
home = state,
online = online,
update = if (l.updateHidden) UpdateState.None else update,
archivedExpanded = l.archivedExpanded,
installDialog = l.installDialog && update is UpdateState.NeedsInstallPermission,
loading = state.sync.lastSuccessAt == null,
illustration = tiles,
)
}.stateIn(viewModelScope, SharingStarted.Eagerly, HomeUi.Starting)
fun toggleArchived() = local.update { it.copy(archivedExpanded = !it.archivedExpanded) }
/** "Uppdatera": the first time the explanation dialog (AC-16.3), otherwise the download and install. */
fun onUpdate() {
val current = (ui.value as? HomeUi.Ready)?.update
if (current is UpdateState.NeedsInstallPermission) {
local.update { it.copy(installDialog = true) }
} else {
install()
}
}
/** "Försök igen" after a failed download (AC-16.4). */
fun onRetry() = install()
/** "Senare": hides the banner until the next cold start (design-spec S-17). */
fun onLater() = local.update { it.copy(updateHidden = true, installDialog = false) }
/** "Fortsätt" in the dialog: the next `install()` proceeds to the system prompt (AC-16.3). */
fun onInstallDialogContinue() {
local.update { it.copy(installDialog = false) }
install()
}
fun onInstallDialogDismiss() = local.update { it.copy(installDialog = false) }
private fun install() {
viewModelScope.launch { updates.install() }
}
private companion object {
val EMPTY_ILLUSTRATION = listOf(DefaultCategoryKey.GROCERIES, DefaultCategoryKey.TRAVEL, DefaultCategoryKey.RESTAURANTS)
}
}
@@ -0,0 +1,291 @@
package se.hagstedt.kvitt.feature.home
import android.content.ActivityNotFoundException
import android.content.Intent
import android.provider.Settings
import android.util.Log
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.lazy.LazyColumn
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
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 kotlinx.datetime.toLocalDateTime
import se.hagstedt.kvitt.design.KvittTheme
import se.hagstedt.kvitt.design.component.Banner
import se.hagstedt.kvitt.design.component.BannerKind
import se.hagstedt.kvitt.design.component.ButtonKind
import se.hagstedt.kvitt.design.component.ButtonSize
import se.hagstedt.kvitt.design.component.ExpenseRow
import se.hagstedt.kvitt.design.component.KvittButton
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.NeedsConnectionHint
import se.hagstedt.kvitt.design.component.ProvideHousehold
import se.hagstedt.kvitt.design.component.SectionHeader
import se.hagstedt.kvitt.design.component.SegmentedGroup
import se.hagstedt.kvitt.design.component.SettlementRow
import se.hagstedt.kvitt.design.component.TopBarNavigation
import se.hagstedt.kvitt.design.component.kvittLanguage
import se.hagstedt.kvitt.design.component.relativeTime
import se.hagstedt.kvitt.design.format.DateFormatter
import se.hagstedt.kvitt.design.icon.KvittIcon
import se.hagstedt.kvitt.design.theme.KvittDimens
import se.hagstedt.kvitt.model.EntityType
import se.hagstedt.kvitt.model.ExpenseId
import se.hagstedt.kvitt.model.LedgerRow
import se.hagstedt.kvitt.model.Rejection
import se.hagstedt.kvitt.model.SettlementId
import se.hagstedt.kvitt.model.SyncRejectionReason
import kotlin.time.Instant
import se.hagstedt.kvitt.core.design.R as DesignR
/** Actions of the sync status screen; `:app` wires the navigation (D-017). */
data class SyncStatusActions(
val onBack: () -> Unit = {},
val onOpenExpense: (ExpenseId) -> Unit = {},
val onOpenSettlement: (SettlementId) -> Unit = {},
val onSyncNow: () -> Unit = {},
val onDismiss: (String) -> Unit = {},
val onOpenDateSettings: () -> Unit = {},
)
/** Sync status (S-16): status, "Synka nu", pending changes and the changes that were not synced (AC-15.5). */
@Composable
fun SyncStatusScreen(
onBack: () -> Unit,
onOpenExpense: (ExpenseId) -> Unit,
onOpenSettlement: (SettlementId) -> Unit,
modifier: Modifier = Modifier,
viewModel: SyncStatusViewModel = hiltViewModel(),
) {
val ui by viewModel.ui.collectAsStateWithLifecycle()
val context = LocalContext.current
val actions = SyncStatusActions(
onBack = onBack,
onOpenExpense = onOpenExpense,
onOpenSettlement = onOpenSettlement,
onSyncNow = viewModel::syncNow,
onDismiss = viewModel::dismiss,
onOpenDateSettings = {
try {
context.startActivity(Intent(Settings.ACTION_DATE_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
} catch (e: ActivityNotFoundException) {
Log.w(LOG_TAG, "no date settings activity", e)
}
},
)
SyncStatusContent(ui, actions, modifier)
}
private const val LOG_TAG = "KvittSyncStatus"
/** The stateless sync status screen, rendered by [SyncStatusScreen], previews and screenshot tests. */
@Composable
fun SyncStatusContent(ui: SyncStatusUi, actions: SyncStatusActions, modifier: Modifier = Modifier) {
KvittScaffold(
modifier = modifier,
topBar = { KvittTopAppBar(stringResource(R.string.home_sync_status_title), navigation = TopBarNavigation.Back, onNavigation = actions.onBack) },
) { padding ->
val ready = ui as? SyncStatusUi.Ready
if (ready == null) {
Box(Modifier.fillMaxSize().padding(padding))
return@KvittScaffold
}
ProvideHousehold(ready.me, ready.partner) {
SyncStatusList(ready, actions, padding)
}
}
}
@Composable
private fun SyncStatusList(ui: SyncStatusUi.Ready, actions: SyncStatusActions, padding: PaddingValues) {
val spacing = KvittTheme.spacing
val summary = ui.status.summary
LazyColumn(
Modifier.fillMaxSize().padding(padding),
contentPadding = PaddingValues(start = spacing.gutter, end = spacing.gutter, top = spacing.s1, bottom = spacing.s8),
verticalArrangement = Arrangement.spacedBy(spacing.detailGap),
) {
if (summary.clockSkew) item(key = "clock") { ClockBanner(actions.onOpenDateSettings) }
item(key = "status") { StatusCard(ui) }
item(key = "sync-now") {
Column {
KvittButton(
stringResource(R.string.home_sync_status_sync_now),
actions.onSyncNow,
Modifier.fillMaxWidth(),
size = ButtonSize.Medium,
icon = KvittIcon.Sync,
enabled = ui.canSyncNow,
)
if (!ui.online) NeedsConnectionHint()
}
}
if (ui.status.pending.isNotEmpty()) {
item(key = "pending-title") { SectionHeader(stringResource(R.string.home_sync_status_pending_title, ui.status.pending.size)) }
item(key = "pending") { PendingGroup(ui.status.pending, actions) }
}
if (ui.status.rejections.isNotEmpty()) {
item(key = "rejected-title") { SectionHeader(stringResource(R.string.home_sync_status_rejected_title)) }
ui.status.rejections.forEach { rejection ->
item(key = rejection.mutationId) { RejectionCard(rejection, ui, actions) }
}
}
}
}
/** Error banner of §9.10: "Telefonens klocka går fel…" with "Datum och tid". */
@Composable
private fun ClockBanner(onOpenDateSettings: () -> Unit) {
Banner(
BannerKind.Error,
KvittIcon.Schedule,
stringResource(R.string.home_sync_status_clock_warning),
body = stringResource(R.string.home_sync_status_clock_body),
) { content ->
KvittButton(stringResource(R.string.home_sync_status_open_date_settings), onOpenDateSettings, kind = ButtonKind.Text, icon = KvittIcon.OpenInNew, contentColor = content)
}
}
/** The status card: Sunny tile, the state in words over "Senast lyckade synk 13:59"; one TalkBack node. */
@Composable
private fun StatusCard(ui: SyncStatusUi.Ready) {
val scheme = MaterialTheme.colorScheme
val colors = KvittTheme.colors
val summary = ui.status.summary
val lastSuccess = summary.lastSuccessAt
val problem = summary.clockSkew || ui.failed
val title = when {
ui.failed -> stringResource(DesignR.string.sync_failed_line)
summary.running || lastSuccess == null -> stringResource(DesignR.string.sync_syncing)
!ui.online -> stringResource(DesignR.string.common_offline)
ui.allSynced -> stringResource(R.string.home_sync_status_all_synced)
else -> stringResource(DesignR.string.sync_synced_relative, relativeTime(lastSuccess))
}
val subtitle = when {
summary.clockSkew -> stringResource(R.string.home_sync_status_last_attempt_clock)
lastSuccess != null -> stringResource(R.string.home_sync_status_last_success, lastSyncTime(lastSuccess))
else -> null
}
val spoken = listOfNotNull(title, subtitle).joinToString(stringResource(DesignR.string.common_separator_comma))
KvittCard(Modifier.fillMaxWidth().clearAndSetSemantics { contentDescription = spoken }, contentPadding = PaddingValues(KvittDimens.cardPaddingLarge)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(KvittDimens.dialogGap)) {
Box(
Modifier.size(KvittDimens.tileEditor).background(if (problem) scheme.surfaceContainerHighest else colors.owedContainer, KvittTheme.shapes.avatarLarge),
contentAlignment = Alignment.Center,
) {
KvittIconImage(
if (problem) KvittIcon.SyncProblem else KvittIcon.CloudDone,
null,
filled = true,
tint = if (problem) scheme.onSurfaceVariant else colors.owed,
)
}
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s1)) {
Text(title, style = MaterialTheme.typography.titleMedium)
if (subtitle != null) Text(subtitle, style = MaterialTheme.typography.bodySmall, color = scheme.onSurfaceVariant)
}
}
}
}
/** "13:59" today, otherwise "16 okt. 14:00" (§9.8). */
@Composable
@ReadOnlyComposable
private fun lastSyncTime(rfc3339: String): String {
val clock = LocalKvittClock.current
val instant = Instant.parse(rfc3339)
return if (instant.toLocalDateTime(clock.zone).date == clock.today) {
DateFormatter.time(instant, clock.zone)
} else {
DateFormatter.listDateTime(instant, clock.zone, clock.today, kvittLanguage())
}
}
/** Pending rows: the ledger rows of `:core:design` (D-019), each with its pending marker. */
@Composable
private fun PendingGroup(rows: List<LedgerRow>, actions: SyncStatusActions) {
val typeLabel = stringResource(DesignR.string.ledger_settlement_label)
SegmentedGroup(rows.size) { item ->
when (val row = rows[item.index]) {
is LedgerRow.ExpenseRow -> ExpenseRow(item, row, onClick = { actions.onOpenExpense(row.id) })
is LedgerRow.SettlementRow -> SettlementRow(item, row, typeLabel, onClick = { actions.onOpenSettlement(row.id) })
}
}
}
/** One change that was not synced (§14.4.6): what, why and when, "Öppna" and "Ta bort från listan". */
@Composable
private fun RejectionCard(rejection: Rejection, ui: SyncStatusUi.Ready, actions: SyncStatusActions) {
val scheme = MaterialTheme.colorScheme
val clock = LocalKvittClock.current
val partnerName = ui.partner?.displayName.orEmpty()
val settlement = rejection.entityType == EntityType.SETTLEMENT
val reason = when (rejection.reason) {
SyncRejectionReason.STALE -> stringResource(R.string.home_sync_reject_stale, partnerName)
SyncRejectionReason.DELETED -> stringResource(R.string.home_sync_reject_deleted)
SyncRejectionReason.VALIDATION_FAILED -> stringResource(R.string.home_sync_reject_validation)
SyncRejectionReason.REFERENCE_NOT_FOUND -> stringResource(R.string.home_sync_reject_reference)
SyncRejectionReason.CLOCK_SKEW -> stringResource(R.string.home_sync_reject_clock_skew)
}
val help = when {
rejection.reason != SyncRejectionReason.STALE -> null
settlement -> stringResource(R.string.home_sync_reject_stale_help_payment, partnerName)
else -> stringResource(R.string.home_sync_reject_stale_help, partnerName)
}
val time = DateFormatter.listDateTime(Instant.parse(rejection.rejectedAt), clock.zone, clock.today, kvittLanguage())
val reasonLine = reason + stringResource(DesignR.string.common_separator_dot) + time
val open = {
if (settlement) actions.onOpenSettlement(SettlementId(rejection.entityId)) else actions.onOpenExpense(ExpenseId(rejection.entityId))
}
Column(
Modifier
.fillMaxWidth()
.border(BorderStroke(KvittDimens.divider, scheme.outlineVariant), KvittTheme.shapes.accountCard)
.padding(KvittTheme.spacing.s4),
verticalArrangement = Arrangement.spacedBy(KvittDimens.emptyGap),
) {
Row(
Modifier.semantics(mergeDescendants = true) {},
horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3),
) {
Box(Modifier.size(KvittDimens.tileSmall).background(scheme.surfaceContainerHighest, KvittTheme.shapes.categoryTile), contentAlignment = Alignment.Center) {
KvittIconImage(if (settlement) KvittIcon.Handshake else KvittIcon.ReceiptLong, null, filled = true, size = KvittDimens.iconChip, tint = scheme.onSurfaceVariant)
}
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s1)) {
Text(rejection.summary, style = MaterialTheme.typography.titleMedium)
Text(reasonLine, style = MaterialTheme.typography.bodyMedium)
if (help != null) Text(help, style = MaterialTheme.typography.bodySmall, color = scheme.onSurfaceVariant)
}
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2, Alignment.End)) {
KvittButton(stringResource(R.string.home_sync_status_open), open, kind = ButtonKind.Text)
KvittButton(stringResource(R.string.home_sync_status_dismiss), { actions.onDismiss(rejection.mutationId) }, kind = ButtonKind.Tonal)
}
}
}
@@ -0,0 +1,57 @@
package se.hagstedt.kvitt.feature.home
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 kotlinx.coroutines.launch
import se.hagstedt.kvitt.model.ConnectivityRepository
import se.hagstedt.kvitt.model.SessionRepository
import se.hagstedt.kvitt.model.SessionState
import se.hagstedt.kvitt.model.SyncRepository
import se.hagstedt.kvitt.model.SyncStatusState
import se.hagstedt.kvitt.model.User
import javax.inject.Inject
/** What the sync status screen (S-16) shows. */
sealed interface SyncStatusUi {
data object Starting : SyncStatusUi
/**
* @property me the viewer and [partner] the other person: `stale` is explained with the partner's name (§14.4.6;
* `Rejection` carries no actor, see the M7b report).
* @property failed the last attempt failed: `SyncSummary` has no such field yet, so the ViewModel passes false.
*/
data class Ready(val status: SyncStatusState, val online: Boolean, val me: User, val partner: User?, val failed: Boolean = false) : SyncStatusUi {
/** Nothing pending, nothing refused, no clock problem: "Allt är synkat". */
val allSynced: Boolean
get() = status.pending.isEmpty() && status.rejections.isEmpty() && !status.summary.clockSkew && !status.summary.running && online && !failed
/** "Synka nu" is offered when online and no sync runs (§9.9 does not list it; it needs the server). */
val canSyncNow: Boolean get() = online && !status.summary.running
}
}
/** State holder of the sync status screen (S-16, AC-15.5). */
@HiltViewModel
class SyncStatusViewModel @Inject constructor(
private val sync: SyncRepository,
connectivity: ConnectivityRepository,
session: SessionRepository,
) : ViewModel() {
val ui: StateFlow<SyncStatusUi> = combine(sync.status(), connectivity.online, session.session) { status, online, s ->
if (s is SessionState.Paired) SyncStatusUi.Ready(status, online, s.me, s.partner) else SyncStatusUi.Starting
}.stateIn(viewModelScope, SharingStarted.Eagerly, SyncStatusUi.Starting)
fun syncNow() {
viewModelScope.launch { sync.syncNow() }
}
/** "Ta bort från listan": works offline (design-spec S-16). */
fun dismiss(mutationId: String) {
viewModelScope.launch { sync.dismiss(mutationId) }
}
}
@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Strings of :feature:home, from docs/ux/strings.csv (module=home). -->
<resources>
<!-- shown on Home before the system permission prompt, after the first full sync (AC-2.9, ia.md §3) -->
<string name="home_notification_rationale">Kvitt säger till när %1$s lägger till eller ändrar något.</string>
<string name="home_accounts">Konton</string>
<string name="home_new_account">Nytt konto</string>
<string name="home_archived_section">Arkiverade (%d)</string>
<string name="home_settle_up">Gör upp</string>
<!-- plural one; %2$s month name (lowercase in sv) -->
<plurals name="home_account_expenses_month">
<item quantity="one">%1$d utgift i %2$s</item>
<item quantity="other">%1$d utgifter i %2$s</item>
</plurals>
<string name="home_account_no_expenses_month">Inga utgifter i %1$s</string>
<!-- first sync only -->
<string name="home_loading">Hämtar era utgifter…</string>
<!-- first sync only -->
<string name="home_loading_body">Första synken hämtar allt som finns på servern. Det tar bara en stund.</string>
<!-- %1$s partner name; S-4 empty state card -->
<string name="home_empty_welcome">Du och %1$s är med</string>
<string name="home_empty_subtitle">Hushållet är klart. Nu behövs ett konto.</string>
<string name="home_empty_title">Skapa ert första konto</string>
<string name="home_empty_body">Ett konto samlar utgifter som ni delar på samma sätt, till exempel Vardag, Huset eller en resa. Ni kan ha hur många ni vill.</string>
<string name="home_sync_status_sync_now">Synka nu</string>
<string name="home_sync_status_title">Synkstatus</string>
<string name="home_sync_status_all_synced">Allt är synkat</string>
<!-- followed by the sync line -->
<string name="home_sync_status_all_synced_body">Alla ändringar finns på servern.</string>
<!-- %1$s time or list date and time -->
<string name="home_sync_status_last_success">Senast lyckade synk %1$s</string>
<string name="home_sync_status_pending_title">Väntande ändringar (%d)</string>
<!-- pending row: home_sync_status_row_pending(tag) -->
<string name="home_sync_status_tag_new">Ny</string>
<string name="home_sync_status_tag_changed">Ändrad</string>
<string name="home_sync_status_tag_deleted">Borttagen</string>
<!-- %1$s a home_sync_status_tag_* -->
<string name="home_sync_status_row_pending">%1$s · väntar på synk</string>
<string name="home_sync_status_rejected_title">Ändringar som inte synkades</string>
<string name="home_sync_status_dismiss">Ta bort från listan</string>
<!-- opens the rejected change read-only -->
<string name="home_sync_status_open">Öppna</string>
<!-- §14.4.6 -->
<string name="home_sync_reject_stale">Din ändring ersattes av en nyare ändring från %1$s</string>
<!-- sv genitive as onboarding_profile_joining -->
<string name="home_sync_reject_stale_help">%1$ss version gäller. Öppna utgiften om du vill göra din ändring igen.</string>
<!-- as home_sync_reject_stale_help -->
<string name="home_sync_reject_stale_help_payment">%1$ss version gäller. Öppna betalningen om du vill göra din ändring igen.</string>
<string name="home_sync_reject_deleted">Den här togs bort innan din ändring kom fram</string>
<string name="home_sync_reject_validation">Ändringen var inte giltig och sparades inte</string>
<string name="home_sync_reject_reference">Dess konto eller kategori finns inte längre</string>
<string name="home_sync_reject_clock_skew">Telefonens klocka går fel. Rätta den och synka igen</string>
<!-- §9.10 -->
<string name="home_sync_status_clock_warning">Telefonens klocka går fel. Rätta den och synka igen.</string>
<!-- §14.4.6 clock_skew -->
<string name="home_sync_status_clock_body">Klockan ligger mer än 10 minuter före servern. Dina ändringar sparas på telefonen tills dess.</string>
<!-- opens the system date settings -->
<string name="home_sync_status_open_date_settings">Datum och tid</string>
<string name="home_sync_status_last_attempt_clock">Senaste försöket nekades: klockan</string>
<!-- PROPOSED (not in strings.csv): TalkBack state of the "Archived (n)" row -->
<string name="home_archived_expanded">utfälld</string>
<!-- PROPOSED (not in strings.csv): TalkBack state of the "Archived (n)" row -->
<string name="home_archived_collapsed">hopfälld</string>
</resources>
@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Strings of :feature:home, from docs/ux/strings.csv (module=home). -->
<resources>
<!-- shown on Home before the system permission prompt, after the first full sync (AC-2.9, ia.md §3) -->
<string name="home_notification_rationale">Kvitt tells you when %1$s adds or changes something.</string>
<string name="home_accounts">Accounts</string>
<string name="home_new_account">New account</string>
<string name="home_archived_section">Archived (%d)</string>
<string name="home_settle_up">Settle up</string>
<!-- plural one; %2$s month name (lowercase in sv) -->
<plurals name="home_account_expenses_month">
<item quantity="one">%1$d expense in %2$s</item>
<item quantity="other">%1$d expenses in %2$s</item>
</plurals>
<string name="home_account_no_expenses_month">No expenses in %1$s</string>
<!-- first sync only -->
<string name="home_loading">Getting your expenses…</string>
<!-- first sync only -->
<string name="home_loading_body">The first sync fetches everything on the server. It only takes a moment.</string>
<!-- %1$s partner name; S-4 empty state card -->
<string name="home_empty_welcome">You and %1$s are in</string>
<string name="home_empty_subtitle">The household is ready. Now you need an account.</string>
<string name="home_empty_title">Create your first account</string>
<string name="home_empty_body">An account collects expenses that you share the same way, for example Everyday, The house or a trip. You can have as many as you like.</string>
<string name="home_sync_status_sync_now">Sync now</string>
<string name="home_sync_status_title">Sync status</string>
<string name="home_sync_status_all_synced">Everything is synced</string>
<!-- followed by the sync line -->
<string name="home_sync_status_all_synced_body">All changes are on the server.</string>
<!-- %1$s time or list date and time -->
<string name="home_sync_status_last_success">Last successful sync %1$s</string>
<string name="home_sync_status_pending_title">Pending changes (%d)</string>
<!-- pending row: home_sync_status_row_pending(tag) -->
<string name="home_sync_status_tag_new">New</string>
<string name="home_sync_status_tag_changed">Changed</string>
<string name="home_sync_status_tag_deleted">Deleted</string>
<!-- %1$s a home_sync_status_tag_* -->
<string name="home_sync_status_row_pending">%1$s · waiting to sync</string>
<string name="home_sync_status_rejected_title">Changes that were not synced</string>
<string name="home_sync_status_dismiss">Remove from list</string>
<!-- opens the rejected change read-only -->
<string name="home_sync_status_open">Open</string>
<!-- §14.4.6 -->
<string name="home_sync_reject_stale">Your change was replaced by a newer change from %1$s</string>
<!-- sv genitive as onboarding_profile_joining -->
<string name="home_sync_reject_stale_help">%1$s\'s version applies. Open the expense if you want to make your change again.</string>
<!-- as home_sync_reject_stale_help -->
<string name="home_sync_reject_stale_help_payment">%1$s\'s version applies. Open the payment if you want to make your change again.</string>
<string name="home_sync_reject_deleted">This was deleted before your change arrived</string>
<string name="home_sync_reject_validation">This change was not valid and was not saved</string>
<string name="home_sync_reject_reference">Its account or category no longer exists</string>
<string name="home_sync_reject_clock_skew">Your phone\'s clock is wrong. Fix it and sync again</string>
<!-- §9.10 -->
<string name="home_sync_status_clock_warning">Your phone\'s clock is wrong. Fix it and sync again.</string>
<!-- §14.4.6 clock_skew -->
<string name="home_sync_status_clock_body">The clock is more than 10 minutes ahead of the server. Your changes stay on the phone until it is fixed.</string>
<!-- opens the system date settings -->
<string name="home_sync_status_open_date_settings">Date &amp; time</string>
<string name="home_sync_status_last_attempt_clock">Last attempt refused: the clock</string>
<!-- PROPOSED (not in strings.csv): TalkBack state of the "Archived (n)" row -->
<string name="home_archived_expanded">expanded</string>
<!-- PROPOSED (not in strings.csv): TalkBack state of the "Archived (n)" row -->
<string name="home_archived_collapsed">collapsed</string>
</resources>
@@ -0,0 +1,100 @@
package se.hagstedt.kvitt.feature.home
import androidx.compose.runtime.Composable
import androidx.compose.ui.test.junit4.v2.createComposeRule
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.home.HomeScenarios
/** One state of a screen: its golden name, whether a 200 % capture is taken, and its content. */
class ScreenState(val name: String, val largeFont: Boolean = false, val content: @Composable () -> Unit)
/** Home with [scenario] after [act] on its ViewModel. */
fun homeState(scenario: FakeScenario, act: (HomeViewModel) -> Unit = {}): HomeUi = withMain {
val fakes = FakeRepositories(scenario)
val vm = HomeViewModel(fakes.home, fakes.connectivity, fakes.update, fakes.categories)
act(vm)
vm.ui.value
}
/** Sync status with [scenario]; [failed] stands in for the missing `SyncSummary` field (see the M7b report). */
fun syncState(scenario: FakeScenario, failed: Boolean = false): SyncStatusUi = withMain {
val fakes = FakeRepositories(scenario)
val ui = SyncStatusViewModel(fakes.sync, fakes.connectivity, fakes.session).ui.value
if (failed && ui is SyncStatusUi.Ready) ui.copy(failed = true) else ui
}
/** Every state of S-4, S-16 and S-17 (design-spec §4 and PLAN-M7b §5) × light/dark × sv/en. */
val homeStates: List<ScreenState> = listOf(
home("S-4-home", HomeScenarios.household),
home("S-4-home-owes-you", HomeScenarios.owesYou),
home("S-4-home-settled", HomeScenarios.settled) { it.toggleArchived() },
home("S-4-home-pending", HomeScenarios.pending),
home("S-4-home-rejected", HomeScenarios.rejected),
home("S-4-home-clock", HomeScenarios.clockSkew),
home("S-4-home-syncing", HomeScenarios.syncing),
home("S-4-home-empty", HomeScenarios.empty),
home("S-4-home-loading", HomeScenarios.loading),
home("S-4-home-offline", HomeScenarios.offline),
home("S-4-home-only-archived", HomeScenarios.onlyArchived),
home("S-4-home-long", HomeScenarios.long, largeFont = true),
home("S-17-update-banner", HomeScenarios.updateAvailable),
home("S-17-update-permission", HomeScenarios.updateNeedsPermission),
home("S-17-update", HomeScenarios.updateNeedsPermission) { it.onUpdate() },
home("S-17-update-progress", HomeScenarios.updateDownloading),
home("S-17-update-failed", HomeScenarios.updateFailed),
sync("S-16-sync", HomeScenarios.syncLoaded),
sync("S-16-sync-empty", HomeScenarios.household),
sync("S-16-sync-rejections", HomeScenarios.allRejections),
sync("S-16-sync-clock", HomeScenarios.clockSkew),
sync("S-16-sync-running", HomeScenarios.syncing),
sync("S-16-sync-error", HomeScenarios.pending, failed = true),
sync("S-16-sync-offline", HomeScenarios.syncOffline),
sync("S-16-sync-long", HomeScenarios.longSync, largeFont = true),
)
private fun home(name: String, scenario: FakeScenario, largeFont: Boolean = false, act: (HomeViewModel) -> Unit = {}): ScreenState {
val ui = homeState(scenario, act)
return ScreenState(name, largeFont) { HomeContent(ui, HomeActions()) }
}
private fun sync(name: String, scenario: FakeScenario, largeFont: Boolean = false, failed: Boolean = false): ScreenState {
val ui = syncState(scenario, failed)
return ScreenState(name, largeFont) { SyncStatusContent(ui, SyncStatusActions()) }
}
/**
* Goldens of every Home, sync status and update state. `check` verifies them; re-record an intended change with
* `tools/gradle-serial.sh :feature:home:recordRoborazziDebug` and open every changed PNG.
*/
@RunWith(ParameterizedRobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [36], qualifiers = ScreenSnapshots.QUALIFIERS)
class HomeScreenshotTest(private val state: String) {
@get:Rule
val compose = createComposeRule()
@Test
fun screen() {
val screen = homeStates.first { it.name == state }
ScreenSnapshots.snap(compose, screen.name, screen.largeFont, screen.content)
}
companion object {
@JvmStatic
@ParameterizedRobolectricTestRunner.Parameters(name = "{0}")
fun states(): List<Array<Any>> = listOf(
"S-4-home", "S-4-home-owes-you", "S-4-home-settled", "S-4-home-pending", "S-4-home-rejected", "S-4-home-clock",
"S-4-home-syncing", "S-4-home-empty", "S-4-home-loading", "S-4-home-offline", "S-4-home-only-archived", "S-4-home-long",
"S-17-update-banner", "S-17-update-permission", "S-17-update", "S-17-update-progress", "S-17-update-failed",
"S-16-sync", "S-16-sync-empty", "S-16-sync-rejections", "S-16-sync-clock", "S-16-sync-running", "S-16-sync-error",
"S-16-sync-offline", "S-16-sync-long",
).map { arrayOf<Any>(it) }
}
}
@@ -0,0 +1,165 @@
package se.hagstedt.kvitt.feature.home
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import se.hagstedt.kvitt.fakes.FakeRepositories
import se.hagstedt.kvitt.fakes.FakeScenario
import se.hagstedt.kvitt.fakes.home.HomeScenarios
import se.hagstedt.kvitt.model.DefaultCategoryKey
import se.hagstedt.kvitt.model.SyncRejectionReason
import se.hagstedt.kvitt.model.UpdateRepository
import se.hagstedt.kvitt.model.UpdateState
/** Records `install()` calls; the update state is set by the test (no mocking library, CLAUDE.md). */
private class RecordingUpdates(initial: UpdateState) : UpdateRepository {
val flow = MutableStateFlow(initial)
var installs = 0
override val state: Flow<UpdateState> = flow
override suspend fun check() = Unit
override suspend fun install() {
installs += 1
}
}
class HomeViewModelTest {
private fun ready(scenario: FakeScenario, act: (HomeViewModel) -> Unit = {}) = homeState(scenario, act) as HomeUi.Ready
private fun vm(scenario: FakeScenario, updates: UpdateRepository): HomeViewModel = withMain {
val fakes = FakeRepositories(scenario)
HomeViewModel(fakes.home, fakes.connectivity, updates, fakes.categories)
}
@Test
fun householdShowsBalanceAccountsAndFab() {
val ui = ready(HomeScenarios.household)
assertEquals(-325_000L, ui.home.global?.netOre)
assertEquals(listOf("Vardag", "Huset", "Japan 2027"), ui.home.active.map { it.account.name })
assertEquals(listOf("Gotland 2026"), ui.home.archived.map { it.account.name })
assertTrue(ui.showFab)
assertTrue(ui.canSettle)
assertFalse(ui.empty)
assertFalse(ui.loading)
assertFalse(ui.archivedExpanded)
}
@Test
fun colourSlotsFollowCreatedOrderWithArchivedIncluded() {
val ui = ready(HomeScenarios.household)
val index = ui.colorIndex
assertEquals(listOf(0, 1, 2, 3), listOf(HomeScenarios.vardag, HomeScenarios.huset, HomeScenarios.japan, HomeScenarios.gotland).map { index[it.id.value] })
}
@Test
fun settledHasNoSettleButton() {
val ui = ready(HomeScenarios.settled)
assertEquals(0L, ui.home.global?.netOre)
assertFalse(ui.canSettle)
}
@Test
fun archivedSectionToggles() {
assertTrue(ready(HomeScenarios.household) { it.toggleArchived() }.archivedExpanded)
assertFalse(ready(HomeScenarios.household) { it.toggleArchived(); it.toggleArchived() }.archivedExpanded)
}
@Test
fun emptyAndOnlyArchivedHideTheFab() {
val empty = ready(HomeScenarios.empty)
assertTrue(empty.empty)
assertFalse(empty.showFab)
assertEquals(listOf(DefaultCategoryKey.GROCERIES, DefaultCategoryKey.TRAVEL, DefaultCategoryKey.RESTAURANTS), empty.illustration.map { it.defaultKey })
val archivedOnly = ready(HomeScenarios.onlyArchived)
assertFalse(archivedOnly.empty)
assertFalse(archivedOnly.showFab)
}
@Test
fun firstSyncIsLoading() {
val ui = ready(HomeScenarios.loading)
assertTrue(ui.loading)
assertFalse(ui.empty)
assertFalse(ui.showFab)
}
@Test
fun offlineAndPendingAreReported() {
val ui = ready(HomeScenarios.offline)
assertFalse(ui.online)
assertEquals(2, ui.home.sync.pendingCount)
assertTrue(ui.update is UpdateState.Available)
}
@Test
fun laterHidesTheBanner() {
assertEquals(UpdateState.None, ready(HomeScenarios.updateAvailable) { it.onLater() }.update)
}
@Test
fun firstUpdateOpensTheDialogAndContinueInstalls() {
val updates = RecordingUpdates(UpdateState.NeedsInstallPermission(HomeScenarios.update))
val vm = vm(HomeScenarios.household, updates)
withMain { vm.onUpdate() }
assertTrue((vm.ui.value as HomeUi.Ready).installDialog)
assertEquals(0, updates.installs)
withMain { vm.onInstallDialogContinue() }
assertFalse((vm.ui.value as HomeUi.Ready).installDialog)
assertEquals(1, updates.installs)
}
@Test
fun updateAndRetryInstallDirectly() {
val updates = RecordingUpdates(UpdateState.Available(HomeScenarios.update))
val vm = vm(HomeScenarios.household, updates)
withMain { vm.onUpdate() }
assertFalse((vm.ui.value as HomeUi.Ready).installDialog)
withMain { vm.onRetry() }
assertEquals(2, updates.installs)
}
@Test
fun dialogDismissKeepsTheBanner() {
val ui = ready(HomeScenarios.updateNeedsPermission) { it.onUpdate(); it.onInstallDialogDismiss() }
assertFalse(ui.installDialog)
assertTrue(ui.update is UpdateState.NeedsInstallPermission)
}
}
class SyncStatusViewModelTest {
@Test
fun syncedHouseholdIsAllSynced() {
val ui = syncState(HomeScenarios.household) as SyncStatusUi.Ready
assertTrue(ui.allSynced)
assertTrue(ui.canSyncNow)
}
@Test
fun pendingAndRejectionsAreListed() {
val ui = syncState(HomeScenarios.allRejections) as SyncStatusUi.Ready
assertFalse(ui.allSynced)
assertEquals(2, ui.status.pending.size)
assertEquals(SyncRejectionReason.entries.toSet(), ui.status.rejections.map { it.reason }.toSet())
assertEquals("Anna", ui.partner?.displayName)
}
@Test
fun syncNowIsDisabledOfflineAndWhileRunning() {
assertFalse((syncState(HomeScenarios.syncOffline) as SyncStatusUi.Ready).canSyncNow)
assertFalse((syncState(HomeScenarios.syncing) as SyncStatusUi.Ready).canSyncNow)
}
@Test
fun syncNowClearsPendingAndDismissRemovesARejection() {
val fakes = FakeRepositories(HomeScenarios.syncLoaded)
val vm = withMain { SyncStatusViewModel(fakes.sync, fakes.connectivity, fakes.session) }
withMain { vm.syncNow() }
val synced = vm.ui.value as SyncStatusUi.Ready
assertTrue(synced.status.pending.isEmpty())
assertEquals(1, synced.status.rejections.size)
withMain { vm.dismiss(HomeScenarios.staleRejection.mutationId) }
assertTrue((vm.ui.value as SyncStatusUi.Ready).status.rejections.isEmpty())
}
}
@@ -0,0 +1,92 @@
package se.hagstedt.kvitt.feature.home
import android.content.res.Configuration
import android.os.LocaleList
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.unit.Density
import com.github.takahirom.roborazzi.ExperimentalRoborazziApi
import com.github.takahirom.roborazzi.captureScreenRoboImage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
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 java.util.Locale
import kotlin.time.Instant
/**
* Full-screen goldens of this module's screens, as M6's component snapshots but for whole screens (dialogs and menus
* included): `src/test/screenshots/<state>_<sv|en>_<light|dark>.png`, plus `<state>_sv_light_200.png` at 200 % font
* scale for the long states. The screen is the Galaxy S26 (411 × 891 dp), at xhdpi to keep the goldens small. The
* clock is fixed at 17 October 2026 14:02 in Stockholm; the frame clock is advanced by hand so animations and
* snackbars render the same every run.
*/
object ScreenSnapshots {
const val QUALIFIERS = "w411dp-h891dp-xhdpi"
val clock = KvittClock.fixed(Instant.parse("2026-10-17T12:02:00Z"), TimeZone.of("Europe/Stockholm"))
private const val SETTLE_MILLIS = 1_000L
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 ""
}
@OptIn(ExperimentalRoborazziApi::class)
fun snap(compose: ComposeContentTestRule, name: String, largeFont: Boolean, 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.mainClock.autoAdvance = false
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, content = content)
}
}
variants.forEach { variant ->
current = variant
compose.mainClock.advanceTimeBy(SETTLE_MILLIS)
captureScreenRoboImage("src/test/screenshots/${name}_${variant.suffix}.png")
}
}
}
/** Runs [block] with `Dispatchers.Main` unconfined, so a ViewModel's state is ready when it returns. */
@OptIn(ExperimentalCoroutinesApi::class)
fun <T> withMain(block: () -> T): T {
Dispatchers.setMain(UnconfinedTestDispatcher())
try {
return block()
} finally {
Dispatchers.resetMain()
}
}