From 0e1d99df93cbbec5d30fc3f22ec288ab4258b10a Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Sun, 18 Oct 2026 10:49:31 +0200 Subject: [PATCH] M7b: feature home and account (S-4, S-5, S-6, S-16, S-17) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit :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 Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B --- .../kvitt/fakes/account/AccountScenarios.kt | 40 +++ .../kvitt/fakes/home/HomeScenarios.kt | 198 +++++++++++ android/feature/account/build.gradle.kts | 16 + .../feature/account/AccountDetailScreen.kt | 289 +++++++++++++++++ .../feature/account/AccountDetailViewModel.kt | 179 ++++++++++ .../feature/account/AccountEditorScreen.kt | 307 ++++++++++++++++++ .../feature/account/AccountEditorViewModel.kt | 201 ++++++++++++ .../kvitt/feature/account/AccountEntry.kt | 14 - .../hagstedt/kvitt/feature/account/Errors.kt | 24 ++ .../src/main/res/values-sv/strings.xml | 34 ++ .../account/src/main/res/values/strings.xml | 34 ++ .../feature/account/AccountScreenshotTest.kt | 86 +++++ .../kvitt/feature/account/AccountStates.kt | 43 +++ .../feature/account/AccountViewModelTest.kt | 215 ++++++++++++ .../kvitt/feature/account/ScreenSnapshots.kt | 92 ++++++ android/feature/home/build.gradle.kts | 16 + .../hagstedt/kvitt/feature/home/HomeEntry.kt | 14 - .../hagstedt/kvitt/feature/home/HomeScreen.kt | 291 +++++++++++++++++ .../kvitt/feature/home/HomeViewModel.kt | 129 ++++++++ .../kvitt/feature/home/SyncStatusScreen.kt | 291 +++++++++++++++++ .../kvitt/feature/home/SyncStatusViewModel.kt | 57 ++++ .../home/src/main/res/values-sv/strings.xml | 64 ++++ .../home/src/main/res/values/strings.xml | 64 ++++ .../kvitt/feature/home/HomeScreenshotTest.kt | 100 ++++++ .../kvitt/feature/home/HomeViewModelTest.kt | 165 ++++++++++ .../kvitt/feature/home/ScreenSnapshots.kt | 92 ++++++ 26 files changed, 3027 insertions(+), 28 deletions(-) create mode 100644 android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/account/AccountScenarios.kt create mode 100644 android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/home/HomeScenarios.kt create mode 100644 android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountDetailScreen.kt create mode 100644 android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountDetailViewModel.kt create mode 100644 android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountEditorScreen.kt create mode 100644 android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountEditorViewModel.kt delete mode 100644 android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountEntry.kt create mode 100644 android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/Errors.kt create mode 100644 android/feature/account/src/main/res/values-sv/strings.xml create mode 100644 android/feature/account/src/main/res/values/strings.xml create mode 100644 android/feature/account/src/test/kotlin/se/hagstedt/kvitt/feature/account/AccountScreenshotTest.kt create mode 100644 android/feature/account/src/test/kotlin/se/hagstedt/kvitt/feature/account/AccountStates.kt create mode 100644 android/feature/account/src/test/kotlin/se/hagstedt/kvitt/feature/account/AccountViewModelTest.kt create mode 100644 android/feature/account/src/test/kotlin/se/hagstedt/kvitt/feature/account/ScreenSnapshots.kt delete mode 100644 android/feature/home/src/main/kotlin/se/hagstedt/kvitt/feature/home/HomeEntry.kt create mode 100644 android/feature/home/src/main/kotlin/se/hagstedt/kvitt/feature/home/HomeScreen.kt create mode 100644 android/feature/home/src/main/kotlin/se/hagstedt/kvitt/feature/home/HomeViewModel.kt create mode 100644 android/feature/home/src/main/kotlin/se/hagstedt/kvitt/feature/home/SyncStatusScreen.kt create mode 100644 android/feature/home/src/main/kotlin/se/hagstedt/kvitt/feature/home/SyncStatusViewModel.kt create mode 100644 android/feature/home/src/main/res/values-sv/strings.xml create mode 100644 android/feature/home/src/main/res/values/strings.xml create mode 100644 android/feature/home/src/test/kotlin/se/hagstedt/kvitt/feature/home/HomeScreenshotTest.kt create mode 100644 android/feature/home/src/test/kotlin/se/hagstedt/kvitt/feature/home/HomeViewModelTest.kt create mode 100644 android/feature/home/src/test/kotlin/se/hagstedt/kvitt/feature/home/ScreenSnapshots.kt diff --git a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/account/AccountScenarios.kt b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/account/AccountScenarios.kt new file mode 100644 index 0000000..2933f15 --- /dev/null +++ b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/account/AccountScenarios.kt @@ -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) +} diff --git a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/home/HomeScenarios.kt b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/home/HomeScenarios.kt new file mode 100644 index 0000000..5d31036 --- /dev/null +++ b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/home/HomeScenarios.kt @@ -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, 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) +} diff --git a/android/feature/account/build.gradle.kts b/android/feature/account/build.gradle.kts index 132870a..e65ae87 100644 --- a/android/feature/account/build.gradle.kts +++ b/android/feature/account/build.gradle.kts @@ -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) } diff --git a/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountDetailScreen.kt b/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountDetailScreen.kt new file mode 100644 index 0000000..549ea67 --- /dev/null +++ b/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountDetailScreen.kt @@ -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() + } +} diff --git a/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountDetailViewModel.kt b/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountDetailViewModel.kt new file mode 100644 index 0000000..0135f9a --- /dev/null +++ b/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountDetailViewModel.kt @@ -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(ARG_ACCOUNT_ID)) { "missing $ARG_ACCOUNT_ID" }) + private val local = MutableStateFlow(AccountLocal()) + + val ui: StateFlow = 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" diff --git a/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountEditorScreen.kt b/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountEditorScreen.kt new file mode 100644 index 0000000..280592e --- /dev/null +++ b/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountEditorScreen.kt @@ -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() + } +} diff --git a/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountEditorViewModel.kt b/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountEditorViewModel.kt new file mode 100644 index 0000000..a591360 --- /dev/null +++ b/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountEditorViewModel.kt @@ -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(AccountDetailViewModel.ARG_ACCOUNT_ID)?.let(::AccountId) + private val form = MutableStateFlow(AccountEditorUi(editing)) + + val ui: StateFlow = combine(form, connectivity.online) { f, online -> f.copy(online = online) } + .stateIn(viewModelScope, SharingStarted.Eagerly, AccountEditorUi(editing)) + + init { + viewModelScope.launch { + val paired = session.session.filterIsInstance().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 = 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 } +} diff --git a/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountEntry.kt b/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountEntry.kt deleted file mode 100644 index 48c4920..0000000 --- a/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountEntry.kt +++ /dev/null @@ -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) -} diff --git a/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/Errors.kt b/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/Errors.kt new file mode 100644 index 0000000..88bdc66 --- /dev/null +++ b/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/Errors.kt @@ -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 + }, +) diff --git a/android/feature/account/src/main/res/values-sv/strings.xml b/android/feature/account/src/main/res/values-sv/strings.xml new file mode 100644 index 0000000..e9a480c --- /dev/null +++ b/android/feature/account/src/main/res/values-sv/strings.xml @@ -0,0 +1,34 @@ + + + + + %1$s · %2$s + + Standarddelning: %1$s + Gör upp det här kontot + Ändra konto + Arkivera + Återställ från arkiv + Ta bort konto + + Kontot kan inte tas bort så länge det har utgifter, betalningar eller återkommande utgifter + Ta bort kontot %1$s? + Det går inte att ångra. + %1$s arkiverades + %1$s återställdes + Inga utgifter än + Lägg till den första utgiften i %1$s. + Nytt konto + Ändra konto + Standarddelning + + Av %1$s: %2$s + + Gäller nya utgifter i kontot. Varje utgift kan ändra sin egen delning. + + Konton sparas direkt på servern. Anslut telefonen till internet för att skapa kontot. + + Konton sparas direkt på servern. Anslut telefonen till internet för att spara ändringen. + + Det finns redan ett konto som heter %1$s + diff --git a/android/feature/account/src/main/res/values/strings.xml b/android/feature/account/src/main/res/values/strings.xml new file mode 100644 index 0000000..553d48e --- /dev/null +++ b/android/feature/account/src/main/res/values/strings.xml @@ -0,0 +1,34 @@ + + + + + %1$s · %2$s + + Default split: %1$s + Settle this account + Edit account + Archive + Restore from archive + Delete account + + The account cannot be deleted while it has expenses, payments or recurring expenses + Delete the account %1$s? + This cannot be undone. + %1$s was archived + %1$s was restored + No expenses yet + Add the first expense in %1$s. + New account + Edit account + Default split + + Of %1$s: %2$s + + Applies to new expenses in the account. Each expense can change its own split. + + Accounts are saved straight to the server. Connect the phone to the internet to create the account. + + Accounts are saved straight to the server. Connect the phone to the internet to save the change. + + There is already an account called %1$s + diff --git a/android/feature/account/src/test/kotlin/se/hagstedt/kvitt/feature/account/AccountScreenshotTest.kt b/android/feature/account/src/test/kotlin/se/hagstedt/kvitt/feature/account/AccountScreenshotTest.kt new file mode 100644 index 0000000..dd353f3 --- /dev/null +++ b/android/feature/account/src/test/kotlin/se/hagstedt/kvitt/feature/account/AccountScreenshotTest.kt @@ -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 = 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> = 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(it) } + } +} diff --git a/android/feature/account/src/test/kotlin/se/hagstedt/kvitt/feature/account/AccountStates.kt b/android/feature/account/src/test/kotlin/se/hagstedt/kvitt/feature/account/AccountStates.kt new file mode 100644 index 0000000..340439f --- /dev/null +++ b/android/feature/account/src/test/kotlin/se/hagstedt/kvitt/feature/account/AccountStates.kt @@ -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): OnlineResult = OnlineResult.Failed(code, field) + override suspend fun update(id: AccountId, name: String, defaultShares: Map): OnlineResult = OnlineResult.Failed(code, field) + override suspend fun setArchived(id: AccountId, archived: Boolean): OnlineResult = OnlineResult.Failed(code, field) + override suspend fun delete(id: AccountId): OnlineResult = 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 +} diff --git a/android/feature/account/src/test/kotlin/se/hagstedt/kvitt/feature/account/AccountViewModelTest.kt b/android/feature/account/src/test/kotlin/se/hagstedt/kvitt/feature/account/AccountViewModelTest.kt new file mode 100644 index 0000000..db67f6f --- /dev/null +++ b/android/feature/account/src/test/kotlin/se/hagstedt/kvitt/feature/account/AccountViewModelTest.kt @@ -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) + } +} diff --git a/android/feature/account/src/test/kotlin/se/hagstedt/kvitt/feature/account/ScreenSnapshots.kt b/android/feature/account/src/test/kotlin/se/hagstedt/kvitt/feature/account/ScreenSnapshots.kt new file mode 100644 index 0000000..3823556 --- /dev/null +++ b/android/feature/account/src/test/kotlin/se/hagstedt/kvitt/feature/account/ScreenSnapshots.kt @@ -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/__.png`, plus `_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 withMain(block: () -> T): T { + Dispatchers.setMain(UnconfinedTestDispatcher()) + try { + return block() + } finally { + Dispatchers.resetMain() + } +} diff --git a/android/feature/home/build.gradle.kts b/android/feature/home/build.gradle.kts index 0d464c1..3646d90 100644 --- a/android/feature/home/build.gradle.kts +++ b/android/feature/home/build.gradle.kts @@ -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) } diff --git a/android/feature/home/src/main/kotlin/se/hagstedt/kvitt/feature/home/HomeEntry.kt b/android/feature/home/src/main/kotlin/se/hagstedt/kvitt/feature/home/HomeEntry.kt deleted file mode 100644 index d698dbd..0000000 --- a/android/feature/home/src/main/kotlin/se/hagstedt/kvitt/feature/home/HomeEntry.kt +++ /dev/null @@ -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) -} diff --git a/android/feature/home/src/main/kotlin/se/hagstedt/kvitt/feature/home/HomeScreen.kt b/android/feature/home/src/main/kotlin/se/hagstedt/kvitt/feature/home/HomeScreen.kt new file mode 100644 index 0000000..132a141 --- /dev/null +++ b/android/feature/home/src/main/kotlin/se/hagstedt/kvitt/feature/home/HomeScreen.kt @@ -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, + ) +} diff --git a/android/feature/home/src/main/kotlin/se/hagstedt/kvitt/feature/home/HomeViewModel.kt b/android/feature/home/src/main/kotlin/se/hagstedt/kvitt/feature/home/HomeViewModel.kt new file mode 100644 index 0000000..1421c4f --- /dev/null +++ b/android/feature/home/src/main/kotlin/se/hagstedt/kvitt/feature/home/HomeViewModel.kt @@ -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 = 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 + 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 = 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) + } +} diff --git a/android/feature/home/src/main/kotlin/se/hagstedt/kvitt/feature/home/SyncStatusScreen.kt b/android/feature/home/src/main/kotlin/se/hagstedt/kvitt/feature/home/SyncStatusScreen.kt new file mode 100644 index 0000000..ef06308 --- /dev/null +++ b/android/feature/home/src/main/kotlin/se/hagstedt/kvitt/feature/home/SyncStatusScreen.kt @@ -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, 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) + } + } +} diff --git a/android/feature/home/src/main/kotlin/se/hagstedt/kvitt/feature/home/SyncStatusViewModel.kt b/android/feature/home/src/main/kotlin/se/hagstedt/kvitt/feature/home/SyncStatusViewModel.kt new file mode 100644 index 0000000..31189f4 --- /dev/null +++ b/android/feature/home/src/main/kotlin/se/hagstedt/kvitt/feature/home/SyncStatusViewModel.kt @@ -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 = 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) } + } +} diff --git a/android/feature/home/src/main/res/values-sv/strings.xml b/android/feature/home/src/main/res/values-sv/strings.xml new file mode 100644 index 0000000..a0b5586 --- /dev/null +++ b/android/feature/home/src/main/res/values-sv/strings.xml @@ -0,0 +1,64 @@ + + + + + Kvitt säger till när %1$s lägger till eller ändrar något. + Konton + Nytt konto + Arkiverade (%d) + Gör upp + + + %1$d utgift i %2$s + %1$d utgifter i %2$s + + Inga utgifter i %1$s + + Hämtar era utgifter… + + Första synken hämtar allt som finns på servern. Det tar bara en stund. + + Du och %1$s är med + Hushållet är klart. Nu behövs ett konto. + Skapa ert första konto + 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. + Synka nu + Synkstatus + Allt är synkat + + Alla ändringar finns på servern. + + Senast lyckade synk %1$s + Väntande ändringar (%d) + + Ny + Ändrad + Borttagen + + %1$s · väntar på synk + Ändringar som inte synkades + Ta bort från listan + + Öppna + + Din ändring ersattes av en nyare ändring från %1$s + + %1$ss version gäller. Öppna utgiften om du vill göra din ändring igen. + + %1$ss version gäller. Öppna betalningen om du vill göra din ändring igen. + Den här togs bort innan din ändring kom fram + Ändringen var inte giltig och sparades inte + Dess konto eller kategori finns inte längre + Telefonens klocka går fel. Rätta den och synka igen + + Telefonens klocka går fel. Rätta den och synka igen. + + Klockan ligger mer än 10 minuter före servern. Dina ändringar sparas på telefonen tills dess. + + Datum och tid + Senaste försöket nekades: klockan + + utfälld + + hopfälld + diff --git a/android/feature/home/src/main/res/values/strings.xml b/android/feature/home/src/main/res/values/strings.xml new file mode 100644 index 0000000..723374d --- /dev/null +++ b/android/feature/home/src/main/res/values/strings.xml @@ -0,0 +1,64 @@ + + + + + Kvitt tells you when %1$s adds or changes something. + Accounts + New account + Archived (%d) + Settle up + + + %1$d expense in %2$s + %1$d expenses in %2$s + + No expenses in %1$s + + Getting your expenses… + + The first sync fetches everything on the server. It only takes a moment. + + You and %1$s are in + The household is ready. Now you need an account. + Create your first account + 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. + Sync now + Sync status + Everything is synced + + All changes are on the server. + + Last successful sync %1$s + Pending changes (%d) + + New + Changed + Deleted + + %1$s · waiting to sync + Changes that were not synced + Remove from list + + Open + + Your change was replaced by a newer change from %1$s + + %1$s\'s version applies. Open the expense if you want to make your change again. + + %1$s\'s version applies. Open the payment if you want to make your change again. + This was deleted before your change arrived + This change was not valid and was not saved + Its account or category no longer exists + Your phone\'s clock is wrong. Fix it and sync again + + Your phone\'s clock is wrong. Fix it and sync again. + + The clock is more than 10 minutes ahead of the server. Your changes stay on the phone until it is fixed. + + Date & time + Last attempt refused: the clock + + expanded + + collapsed + diff --git a/android/feature/home/src/test/kotlin/se/hagstedt/kvitt/feature/home/HomeScreenshotTest.kt b/android/feature/home/src/test/kotlin/se/hagstedt/kvitt/feature/home/HomeScreenshotTest.kt new file mode 100644 index 0000000..b9b9bce --- /dev/null +++ b/android/feature/home/src/test/kotlin/se/hagstedt/kvitt/feature/home/HomeScreenshotTest.kt @@ -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 = 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> = 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(it) } + } +} diff --git a/android/feature/home/src/test/kotlin/se/hagstedt/kvitt/feature/home/HomeViewModelTest.kt b/android/feature/home/src/test/kotlin/se/hagstedt/kvitt/feature/home/HomeViewModelTest.kt new file mode 100644 index 0000000..3d70781 --- /dev/null +++ b/android/feature/home/src/test/kotlin/se/hagstedt/kvitt/feature/home/HomeViewModelTest.kt @@ -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 = 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()) + } +} diff --git a/android/feature/home/src/test/kotlin/se/hagstedt/kvitt/feature/home/ScreenSnapshots.kt b/android/feature/home/src/test/kotlin/se/hagstedt/kvitt/feature/home/ScreenSnapshots.kt new file mode 100644 index 0000000..318ae34 --- /dev/null +++ b/android/feature/home/src/test/kotlin/se/hagstedt/kvitt/feature/home/ScreenSnapshots.kt @@ -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/__.png`, plus `_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 withMain(block: () -> T): T { + Dispatchers.setMain(UnconfinedTestDispatcher()) + try { + return block() + } finally { + Dispatchers.resetMain() + } +}