M7b: feature home and account (S-4, S-5, S-6, S-16, S-17)
:feature:home: Home with balance hero, sync pill, accounts, archived section, empty and first-sync states, update banner and "Tillåt installation" dialog; sync status with pending rows, rejections of every §14.4.6 reason and the clock-skew banner. :feature:account: account detail with header, ledger by month, overflow menu, archive/unarchive/delete and their errors; account editor with name, percent fields (two decimals), slider, presets, preview, name_taken/validation errors and offline state. Fake scenarios in fakes/home and fakes/account; full-screen Roborazzi goldens for every state in sv/en × light/dark (200 % for long states); ViewModel unit tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
+40
@@ -0,0 +1,40 @@
|
|||||||
|
package se.hagstedt.kvitt.fakes.account
|
||||||
|
|
||||||
|
import se.hagstedt.kvitt.fakes.FakeScenario
|
||||||
|
import se.hagstedt.kvitt.fakes.FakeScenarios
|
||||||
|
import se.hagstedt.kvitt.fakes.home.HomeScenarios
|
||||||
|
import se.hagstedt.kvitt.model.Account
|
||||||
|
import se.hagstedt.kvitt.model.AccountId
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scenarios of the account screens (S-5, S-6; M7b) on the household of [HomeScenarios]. Open S-5 with the account ids
|
||||||
|
* named here; S-6 creates or edits against the same household.
|
||||||
|
*/
|
||||||
|
object AccountScenarios {
|
||||||
|
/** Vardag: "Anna är skyldig dig 1 184,50 kr", October and September, the SL-kort row and a payment pending. */
|
||||||
|
val loaded: FakeScenario = HomeScenarios.pending
|
||||||
|
val vardag: AccountId = HomeScenarios.vardag.id
|
||||||
|
|
||||||
|
/** Huset: "Du är skyldig Anna 3 084,50 kr" (negative balance). */
|
||||||
|
val huset: AccountId = HomeScenarios.huset.id
|
||||||
|
|
||||||
|
/** Gotland 2026: archived, settled up ("Kvitt"), no FAB. */
|
||||||
|
val gotland: AccountId = HomeScenarios.gotland.id
|
||||||
|
|
||||||
|
/** The new account "Sommarstugan" without expenses: settled, deletable (AC-6.5). */
|
||||||
|
val sommarstugan = Account(AccountId("01920000-0000-7000-8000-b00000000007"), "Sommarstugan", mapOf(FakeScenarios.owner.id to 6_000, FakeScenarios.partner.id to 4_000), "2026-10-17T11:00:00.000Z", archived = false)
|
||||||
|
val empty: FakeScenario = HomeScenarios.household.copy(accounts = HomeScenarios.household.accounts + sommarstugan)
|
||||||
|
|
||||||
|
/** [loaded] offline: every menu item needs a connection; rows, settle and the FAB still work. */
|
||||||
|
val offline: FakeScenario = HomeScenarios.pending.copy(online = false)
|
||||||
|
|
||||||
|
/** "Sommarstugan i Österåker 2027" with long descriptions, 1 234 567,89 kr and a 7-digit month total. */
|
||||||
|
val long: FakeScenario = HomeScenarios.long
|
||||||
|
val longAccount: AccountId = HomeScenarios.sommarstugan.id
|
||||||
|
|
||||||
|
/** S-6: the household without the account being created, online. */
|
||||||
|
val editor: FakeScenario = HomeScenarios.household
|
||||||
|
|
||||||
|
/** S-6 offline: "Kräver anslutning". */
|
||||||
|
val editorOffline: FakeScenario = HomeScenarios.household.copy(online = false)
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
package se.hagstedt.kvitt.fakes.home
|
||||||
|
|
||||||
|
import se.hagstedt.kvitt.fakes.FakeExpense
|
||||||
|
import se.hagstedt.kvitt.fakes.FakeScenario
|
||||||
|
import se.hagstedt.kvitt.fakes.FakeScenarios
|
||||||
|
import se.hagstedt.kvitt.fakes.FakeSettlement
|
||||||
|
import se.hagstedt.kvitt.model.Account
|
||||||
|
import se.hagstedt.kvitt.model.AccountId
|
||||||
|
import se.hagstedt.kvitt.model.CategoryId
|
||||||
|
import se.hagstedt.kvitt.model.DefaultCategoryKey
|
||||||
|
import se.hagstedt.kvitt.model.EntityType
|
||||||
|
import se.hagstedt.kvitt.model.ExpenseId
|
||||||
|
import se.hagstedt.kvitt.model.Rejection
|
||||||
|
import se.hagstedt.kvitt.model.SettlementId
|
||||||
|
import se.hagstedt.kvitt.model.Share
|
||||||
|
import se.hagstedt.kvitt.model.SplitMode
|
||||||
|
import se.hagstedt.kvitt.model.SyncRejectionReason
|
||||||
|
import se.hagstedt.kvitt.model.UpdateInfo
|
||||||
|
import se.hagstedt.kvitt.model.UpdateState
|
||||||
|
import se.hagstedt.kvitt.model.UserId
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The household of the design spec's frames (S-4, S-5, S-16, S-17; M7b): Jacob (the viewer) and Anna, the accounts
|
||||||
|
* Vardag, Huset, Japan 2027 and Gotland 2026 (archived). The phone's clock is 17 October 2026 14:02 in Stockholm, the
|
||||||
|
* last sync three minutes earlier. Balances come from the expenses through `:domain`: Vardag +1 184,50 kr, Huset
|
||||||
|
* −3 084,50 kr, Japan 2027 −1 350,00 kr, Gotland 2026 0, globally "Du är skyldig Anna 3 250,00 kr".
|
||||||
|
*/
|
||||||
|
object HomeScenarios {
|
||||||
|
private val jacob = FakeScenarios.owner.id
|
||||||
|
private val anna = FakeScenarios.partner.id
|
||||||
|
|
||||||
|
private fun id(prefix: String, n: Int) = "01920000-0000-7000-8000-$prefix" + n.toString(16).padStart(12 - prefix.length, '0')
|
||||||
|
|
||||||
|
fun category(key: DefaultCategoryKey): CategoryId = FakeScenarios.defaultCategories.first { it.defaultKey == key }.id
|
||||||
|
|
||||||
|
private val half = mapOf(jacob to 5_000, anna to 5_000)
|
||||||
|
val vardag = Account(AccountId(id("b", 1)), "Vardag", half, "2026-01-04T09:00:00.000Z", archived = false)
|
||||||
|
val huset = Account(AccountId(id("b", 2)), "Huset", mapOf(jacob to 6_000, anna to 4_000), "2026-01-05T09:00:00.000Z", archived = false)
|
||||||
|
val japan = Account(AccountId(id("b", 3)), "Japan 2027", half, "2026-03-01T09:00:00.000Z", archived = false)
|
||||||
|
val gotland = Account(AccountId(id("b", 4)), "Gotland 2026", half, "2026-05-20T09:00:00.000Z", archived = true)
|
||||||
|
|
||||||
|
/** An expense split in two exact halves (or on [fullOn] alone). */
|
||||||
|
fun expense(
|
||||||
|
n: Int,
|
||||||
|
account: Account,
|
||||||
|
key: DefaultCategoryKey,
|
||||||
|
amountOre: Long,
|
||||||
|
description: String,
|
||||||
|
date: String,
|
||||||
|
payer: UserId,
|
||||||
|
jacobShareOre: Long = amountOre / 2,
|
||||||
|
pending: Boolean = false,
|
||||||
|
) = FakeExpense(
|
||||||
|
id = ExpenseId(id("e", n)),
|
||||||
|
accountId = account.id,
|
||||||
|
categoryId = category(key),
|
||||||
|
amountOre = amountOre,
|
||||||
|
description = description,
|
||||||
|
notes = "",
|
||||||
|
date = date,
|
||||||
|
payer = payer,
|
||||||
|
mode = if (jacobShareOre * 2 == amountOre) SplitMode.DEFAULT else SplitMode.EXACT,
|
||||||
|
shares = listOf(Share(jacob, jacobShareOre, null), Share(anna, amountOre - jacobShareOre, null)),
|
||||||
|
createdAt = date + "T10:00:00.000Z",
|
||||||
|
pending = pending,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun settlement(n: Int, from: UserId, allocations: Map<AccountId, Long>, date: String, pending: Boolean = false) = FakeSettlement(
|
||||||
|
id = SettlementId(id("5", n)),
|
||||||
|
from = from,
|
||||||
|
to = if (from == jacob) anna else jacob,
|
||||||
|
amountOre = allocations.values.sum(),
|
||||||
|
date = date,
|
||||||
|
note = "",
|
||||||
|
allocations = allocations,
|
||||||
|
createdAt = date + "T11:00:00.000Z",
|
||||||
|
pending = pending,
|
||||||
|
)
|
||||||
|
|
||||||
|
val icaMaxi = expense(1, vardag, DefaultCategoryKey.GROCERIES, 124_760, "ICA Maxi Lindhagen", "2026-10-16", anna)
|
||||||
|
val systembolaget = expense(2, vardag, DefaultCategoryKey.ENTERTAINMENT, 41_200, "Systembolaget", "2026-10-15", jacob)
|
||||||
|
val slKort = expense(3, vardag, DefaultCategoryKey.TRANSPORT, 106_000, "SL-kort 30 dagar, Anna", "2026-10-14", jacob, jacobShareOre = 0)
|
||||||
|
val hemkop = expense(4, vardag, DefaultCategoryKey.GROCERIES, 38_440, "Hemköp Odenplan", "2026-10-10", jacob)
|
||||||
|
val willys = expense(5, vardag, DefaultCategoryKey.GROCERIES, 52_300, "Willys Hornstull", "2026-09-28", anna)
|
||||||
|
val bauhaus = expense(6, vardag, DefaultCategoryKey.HOME, 282_320, "Bauhaus Barkarby", "2026-09-20", jacob)
|
||||||
|
val rent = expense(7, huset, DefaultCategoryKey.HOUSING, 500_000, "Hyra oktober", "2026-10-01", anna, jacobShareOre = 300_000)
|
||||||
|
val power = expense(8, huset, DefaultCategoryKey.UTILITIES, 16_900, "Elräkning september", "2026-10-05", anna)
|
||||||
|
val flight = expense(9, japan, DefaultCategoryKey.TRAVEL, 270_000, "Flyg Stockholm–Tokyo", "2026-10-12", anna)
|
||||||
|
val ferry = expense(10, gotland, DefaultCategoryKey.TRAVEL, 98_000, "Färja Visby", "2026-06-14", jacob)
|
||||||
|
val vardagPayment = settlement(1, anna, mapOf(vardag.id to 80_000), "2026-10-06")
|
||||||
|
val gotlandPayment = settlement(2, anna, mapOf(gotland.id to 49_000), "2026-07-01")
|
||||||
|
|
||||||
|
/** Owner and partner, four accounts, synced three minutes ago, nothing pending; "Du är skyldig Anna 3 250,00 kr". */
|
||||||
|
val household: FakeScenario = FakeScenarios.empty.copy(
|
||||||
|
accounts = listOf(vardag, huset, japan, gotland),
|
||||||
|
expenses = listOf(icaMaxi, systembolaget, slKort, hemkop, willys, bauhaus, rent, power, flight, ferry),
|
||||||
|
settlements = listOf(vardagPayment, gotlandPayment),
|
||||||
|
lastSuccessAt = "2026-10-17T11:59:00.000Z",
|
||||||
|
today = "2026-10-17",
|
||||||
|
now = "2026-10-17T12:02:00.000Z",
|
||||||
|
defaultAccountId = vardag.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** [household] with two changes waiting to sync: the SL-kort expense and the Vardag payment. */
|
||||||
|
val pending: FakeScenario = household.copy(
|
||||||
|
expenses = household.expenses.map { if (it.id == slKort.id) it.copy(pending = true) else it },
|
||||||
|
settlements = household.settlements.map { if (it.id == vardagPayment.id) it.copy(pending = true) else it },
|
||||||
|
)
|
||||||
|
|
||||||
|
/** [household] plus a hotel Jacob paid in Japan 2027: "Anna är skyldig dig 750,00 kr". */
|
||||||
|
val owesYou: FakeScenario = household.copy(
|
||||||
|
expenses = household.expenses + expense(11, japan, DefaultCategoryKey.TRAVEL, 800_000, "Hotell Kyoto", "2026-10-13", jacob),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** [household] settled up today in every account: "Ni är kvitt". */
|
||||||
|
val settled: FakeScenario = household.copy(
|
||||||
|
settlements = household.settlements +
|
||||||
|
settlement(3, anna, mapOf(vardag.id to 118_450), "2026-10-17") +
|
||||||
|
settlement(4, jacob, mapOf(huset.id to 308_450, japan.id to 135_000), "2026-10-17"),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** A `stale` rejection of Anna's newer Babette expense (S-16 loaded). */
|
||||||
|
val staleRejection = Rejection("m-1", EntityType.EXPENSE, id("e", 12), SyncRejectionReason.STALE, "Babette", "2026-10-17T06:31:00.000Z")
|
||||||
|
|
||||||
|
/** S-16 loaded: two changes waiting and one `stale` rejection. */
|
||||||
|
val syncLoaded: FakeScenario = pending.copy(rejections = listOf(staleRejection))
|
||||||
|
|
||||||
|
/** [household] with one change that was not synced. */
|
||||||
|
val rejected: FakeScenario = household.copy(rejections = listOf(staleRejection))
|
||||||
|
|
||||||
|
/** [pending] with the phone's clock more than ten minutes ahead; the last successful sync two hours ago. */
|
||||||
|
val clockSkew: FakeScenario = pending.copy(clockSkew = true, lastSuccessAt = "2026-10-17T10:02:00.000Z")
|
||||||
|
|
||||||
|
/** [pending] while a sync runs. */
|
||||||
|
val syncing: FakeScenario = pending.copy(syncRunning = true)
|
||||||
|
|
||||||
|
val update = UpdateInfo(versionCode = 2, versionName = "1.2", sizeBytes = 8_400_000)
|
||||||
|
|
||||||
|
/** [household] with version 1.2 available. */
|
||||||
|
val updateAvailable: FakeScenario = household.copy(update = UpdateState.Available(update))
|
||||||
|
|
||||||
|
/** [household] before the first install: "Uppdatera" opens the "Tillåt installation" dialog (S-17). */
|
||||||
|
val updateNeedsPermission: FakeScenario = household.copy(update = UpdateState.NeedsInstallPermission(update))
|
||||||
|
|
||||||
|
/** [household] while version 1.2 downloads, at 64 %. */
|
||||||
|
val updateDownloading: FakeScenario = household.copy(update = UpdateState.Downloading(update, 0.64f))
|
||||||
|
|
||||||
|
/** [household] after a failed download. */
|
||||||
|
val updateFailed: FakeScenario = household.copy(update = UpdateState.Failed(update))
|
||||||
|
|
||||||
|
/** Offline with two changes waiting and version 1.2 available (S-4-home-offline). */
|
||||||
|
val offline: FakeScenario = pending.copy(online = false, update = UpdateState.Available(update))
|
||||||
|
|
||||||
|
/** The first sync after pairing: nothing synced yet, the sync running. */
|
||||||
|
val loading: FakeScenario = FakeScenarios.empty.copy(lastSuccessAt = null, syncRunning = true, today = "2026-10-17", now = "2026-10-17T12:02:00.000Z")
|
||||||
|
|
||||||
|
/** Partner joined, no accounts yet (S-4-home-empty). */
|
||||||
|
val empty: FakeScenario = FakeScenarios.empty.copy(lastSuccessAt = "2026-10-17T11:59:00.000Z", today = "2026-10-17", now = "2026-10-17T12:02:00.000Z")
|
||||||
|
|
||||||
|
/** Only archived accounts: no FAB (UX-6). */
|
||||||
|
val onlyArchived: FakeScenario = household.copy(
|
||||||
|
accounts = listOf(gotland),
|
||||||
|
expenses = listOf(ferry),
|
||||||
|
settlements = listOf(gotlandPayment),
|
||||||
|
)
|
||||||
|
|
||||||
|
val sommarstugan = Account(AccountId(id("b", 5)), "Sommarstugan i Österåker 2027", mapOf(jacob to 6_000, anna to 4_000), "2026-09-01T09:00:00.000Z", archived = false)
|
||||||
|
val bills = Account(AccountId(id("b", 6)), "Gemensamma räkningar och försäkringar", half, "2026-09-02T09:00:00.000Z", archived = false)
|
||||||
|
|
||||||
|
/** Long Swedish account names and 7-digit amounts (S-4 long, S-5 long). */
|
||||||
|
val long: FakeScenario = household.copy(
|
||||||
|
accounts = household.accounts + sommarstugan + bills,
|
||||||
|
expenses = household.expenses +
|
||||||
|
expense(20, sommarstugan, DefaultCategoryKey.HOUSING, 246_913_578, "Handpenning för sommarstugan vid Åkersberga och lagfart", "2026-10-11", anna, jacobShareOre = 123_456_789) +
|
||||||
|
expense(21, sommarstugan, DefaultCategoryKey.HOME, 1_456_000, "Snickeriarbete på verandan och nytt staket runt tomten", "2026-10-09", jacob) +
|
||||||
|
expense(22, bills, DefaultCategoryKey.INSURANCE, 4_312_350, "Hemförsäkring, bilförsäkring och olycksfallsförsäkring helår", "2026-10-02", jacob),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** [pending] with one rejection of every §14.4.6 reason. */
|
||||||
|
val allRejections: FakeScenario = pending.copy(
|
||||||
|
rejections = listOf(
|
||||||
|
staleRejection,
|
||||||
|
Rejection("m-2", EntityType.EXPENSE, id("e", 13), SyncRejectionReason.DELETED, "Pressbyrån Odenplan", "2026-10-17T07:10:00.000Z"),
|
||||||
|
Rejection("m-3", EntityType.SETTLEMENT, id("5", 9), SyncRejectionReason.VALIDATION_FAILED, "Betalning till Anna", "2026-10-17T07:42:00.000Z"),
|
||||||
|
Rejection("m-4", EntityType.EXPENSE, id("e", 14), SyncRejectionReason.REFERENCE_NOT_FOUND, "Glass i Visby hamn", "2026-10-16T18:05:00.000Z"),
|
||||||
|
Rejection("m-5", EntityType.EXPENSE, id("e", 15), SyncRejectionReason.CLOCK_SKEW, "Biobiljetter Rigoletto", "2026-10-16T17:20:00.000Z"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** S-16 long: long descriptions and 7-digit amounts waiting, and a long rejection summary. */
|
||||||
|
val longSync: FakeScenario = long.copy(
|
||||||
|
expenses = long.expenses.map { if (it.accountId == sommarstugan.id || it.accountId == bills.id) it.copy(pending = true) else it },
|
||||||
|
rejections = listOf(staleRejection.copy(summary = "Middag på Restaurang Frantzén med Annas föräldrar och syskon")),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** [pending] offline: "Synka nu" needs a connection. */
|
||||||
|
val syncOffline: FakeScenario = pending.copy(online = false)
|
||||||
|
}
|
||||||
@@ -1,4 +1,20 @@
|
|||||||
// :feature:account — Account detail and account editor (S-5, S-6; M7b). Convention: android/README.md.
|
// :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 {
|
plugins {
|
||||||
id("kvitt.android.feature")
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
+289
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
+179
@@ -0,0 +1,179 @@
|
|||||||
|
package se.hagstedt.kvitt.feature.account
|
||||||
|
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import se.hagstedt.kvitt.model.AccountDetailState
|
||||||
|
import se.hagstedt.kvitt.model.AccountId
|
||||||
|
import se.hagstedt.kvitt.model.AccountRepository
|
||||||
|
import se.hagstedt.kvitt.model.ConnectivityRepository
|
||||||
|
import se.hagstedt.kvitt.model.OnlineResult
|
||||||
|
import se.hagstedt.kvitt.model.User
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/** The online actions of S-5 (AC-6.4, AC-6.5). */
|
||||||
|
enum class AccountAction { Archive, Unarchive, Delete }
|
||||||
|
|
||||||
|
/** A snackbar of S-5. */
|
||||||
|
sealed interface AccountMessage {
|
||||||
|
/** "Kunde inte nå servern" with "Försök igen" (§9.9). */
|
||||||
|
data class CouldNotReach(val retry: AccountAction) : AccountMessage
|
||||||
|
|
||||||
|
/** "Vardag återställdes". */
|
||||||
|
data class Restored(val name: String) : AccountMessage
|
||||||
|
|
||||||
|
/** `account_not_empty` (AC-6.5). */
|
||||||
|
data object DeleteBlocked : AccountMessage
|
||||||
|
|
||||||
|
/** The action was tried while offline (§9.9). */
|
||||||
|
data object NeedsConnection : AccountMessage
|
||||||
|
|
||||||
|
/** Any other error code of §14.3.2. */
|
||||||
|
data class Failed(val code: String) : AccountMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A one-off navigation of S-5, handled by the screen and then cleared. */
|
||||||
|
sealed interface AccountEvent {
|
||||||
|
/** Archived: back to Home with "Vardag arkiverades" (design-spec S-5). */
|
||||||
|
data class Archived(val name: String) : AccountEvent
|
||||||
|
|
||||||
|
/** Deleted: back to Home. */
|
||||||
|
data object Deleted : AccountEvent
|
||||||
|
|
||||||
|
/** The account no longer exists (deleted elsewhere). */
|
||||||
|
data object Gone : AccountEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the account screen (S-5) shows. */
|
||||||
|
sealed interface AccountDetailUi {
|
||||||
|
data object Starting : AccountDetailUi
|
||||||
|
|
||||||
|
data class Ready(
|
||||||
|
val detail: AccountDetailState,
|
||||||
|
val online: Boolean,
|
||||||
|
val colorIndex: Int,
|
||||||
|
val menuOpen: Boolean = false,
|
||||||
|
val deleteDialog: Boolean = false,
|
||||||
|
val busy: Boolean = false,
|
||||||
|
val message: AccountMessage? = null,
|
||||||
|
val event: AccountEvent? = null,
|
||||||
|
) : AccountDetailUi {
|
||||||
|
val partner: User get() = detail.balance.partner
|
||||||
|
val me: User get() = detail.users.firstOrNull { it.id != partner.id } ?: partner
|
||||||
|
|
||||||
|
/** The viewer's default share in percent, for the split bar. */
|
||||||
|
val viewerPercent: Float get() = (detail.account.defaultShares[me.id] ?: HALF_BP) / BP_PER_PERCENT
|
||||||
|
|
||||||
|
/** Archive, unarchive, edit and delete need the server (AC-6.7, §9.9). */
|
||||||
|
val canEdit: Boolean get() = online && !busy
|
||||||
|
val canDelete: Boolean get() = canEdit && detail.deletable
|
||||||
|
|
||||||
|
/** Archived accounts hide the FAB (AC-6.4, UX-6). */
|
||||||
|
val showFab: Boolean get() = !detail.account.archived
|
||||||
|
}
|
||||||
|
|
||||||
|
data class Gone(val event: AccountEvent?) : AccountDetailUi
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class AccountLocal(
|
||||||
|
val menuOpen: Boolean = false,
|
||||||
|
val deleteDialog: Boolean = false,
|
||||||
|
val busy: Boolean = false,
|
||||||
|
val message: AccountMessage? = null,
|
||||||
|
val event: AccountEvent? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal const val HALF_BP = 5_000
|
||||||
|
internal const val BP_PER_PERCENT = 100f
|
||||||
|
|
||||||
|
/** State holder of the account screen (S-5). The account id comes from the navigation argument [ARG_ACCOUNT_ID]. */
|
||||||
|
@HiltViewModel
|
||||||
|
class AccountDetailViewModel @Inject constructor(
|
||||||
|
savedState: SavedStateHandle,
|
||||||
|
private val accounts: AccountRepository,
|
||||||
|
connectivity: ConnectivityRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
val id = AccountId(checkNotNull(savedState.get<String>(ARG_ACCOUNT_ID)) { "missing $ARG_ACCOUNT_ID" })
|
||||||
|
private val local = MutableStateFlow(AccountLocal())
|
||||||
|
|
||||||
|
val ui: StateFlow<AccountDetailUi> = combine(accounts.detail(id), accounts.all(), connectivity.online, local) { detail, all, online, l ->
|
||||||
|
if (detail == null) {
|
||||||
|
AccountDetailUi.Gone(l.event ?: AccountEvent.Gone)
|
||||||
|
} else {
|
||||||
|
AccountDetailUi.Ready(
|
||||||
|
detail = detail,
|
||||||
|
online = online,
|
||||||
|
colorIndex = all.indexOfFirst { it.id == id }.coerceAtLeast(0),
|
||||||
|
menuOpen = l.menuOpen,
|
||||||
|
deleteDialog = l.deleteDialog,
|
||||||
|
busy = l.busy,
|
||||||
|
message = l.message,
|
||||||
|
event = l.event,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.stateIn(viewModelScope, SharingStarted.Eagerly, AccountDetailUi.Starting)
|
||||||
|
|
||||||
|
fun setMenuOpen(open: Boolean) = local.update { it.copy(menuOpen = open) }
|
||||||
|
|
||||||
|
fun archive() = run(AccountAction.Archive)
|
||||||
|
|
||||||
|
fun unarchive() = run(AccountAction.Unarchive)
|
||||||
|
|
||||||
|
/** "Ta bort konto": asks first (design-spec S-5). */
|
||||||
|
fun askDelete() = local.update { it.copy(menuOpen = false, deleteDialog = true) }
|
||||||
|
|
||||||
|
fun dismissDelete() = local.update { it.copy(deleteDialog = false) }
|
||||||
|
|
||||||
|
fun confirmDelete() = run(AccountAction.Delete)
|
||||||
|
|
||||||
|
fun retry(action: AccountAction) = run(action)
|
||||||
|
|
||||||
|
fun messageShown() = local.update { it.copy(message = null) }
|
||||||
|
|
||||||
|
fun eventHandled() = local.update { it.copy(event = null) }
|
||||||
|
|
||||||
|
private fun run(action: AccountAction) {
|
||||||
|
val name = (ui.value as? AccountDetailUi.Ready)?.detail?.account?.name.orEmpty()
|
||||||
|
local.update { it.copy(menuOpen = false, deleteDialog = false, busy = true, message = null) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
val result = when (action) {
|
||||||
|
AccountAction.Archive -> accounts.setArchived(id, true)
|
||||||
|
AccountAction.Unarchive -> accounts.setArchived(id, false)
|
||||||
|
AccountAction.Delete -> accounts.delete(id)
|
||||||
|
}
|
||||||
|
local.update { l ->
|
||||||
|
when (result) {
|
||||||
|
is OnlineResult.Ok -> when (action) {
|
||||||
|
AccountAction.Archive -> l.copy(busy = false, event = AccountEvent.Archived(name))
|
||||||
|
AccountAction.Unarchive -> l.copy(busy = false, message = AccountMessage.Restored(name))
|
||||||
|
AccountAction.Delete -> l.copy(busy = false, event = AccountEvent.Deleted)
|
||||||
|
}
|
||||||
|
OnlineResult.Offline -> l.copy(busy = false, message = AccountMessage.NeedsConnection)
|
||||||
|
is OnlineResult.Failed -> l.copy(busy = false, message = failureMessage(result.code, action))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** The navigation argument holding the account id (`:app` names its route argument so). */
|
||||||
|
const val ARG_ACCOUNT_ID = "accountId"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun failureMessage(code: String, action: AccountAction): AccountMessage = when (code) {
|
||||||
|
NETWORK -> AccountMessage.CouldNotReach(action)
|
||||||
|
"account_not_empty" -> AccountMessage.DeleteBlocked
|
||||||
|
else -> AccountMessage.Failed(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `OnlineResult.Failed` code of a network failure (design-spec §1, §9.9). */
|
||||||
|
internal const val NETWORK = "network"
|
||||||
+307
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
+201
@@ -0,0 +1,201 @@
|
|||||||
|
package se.hagstedt.kvitt.feature.account
|
||||||
|
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.filterIsInstance
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import se.hagstedt.kvitt.model.AccountId
|
||||||
|
import se.hagstedt.kvitt.model.AccountRepository
|
||||||
|
import se.hagstedt.kvitt.model.ConnectivityRepository
|
||||||
|
import se.hagstedt.kvitt.model.OnlineResult
|
||||||
|
import se.hagstedt.kvitt.model.SessionRepository
|
||||||
|
import se.hagstedt.kvitt.model.SessionState
|
||||||
|
import se.hagstedt.kvitt.model.User
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/** Which percent field the user typed in last (the other one follows as 100 minus it, AC-6.1). */
|
||||||
|
enum class PercentField { Viewer, Partner }
|
||||||
|
|
||||||
|
/** Why the name field is in error (S-6). */
|
||||||
|
sealed interface NameError {
|
||||||
|
data object Required : NameError
|
||||||
|
|
||||||
|
/** More than [MAX_NAME] code points (§9.6). */
|
||||||
|
data object TooLong : NameError
|
||||||
|
|
||||||
|
/** `name_taken` (AC-6.2) for [name]. */
|
||||||
|
data class Taken(val name: String) : NameError
|
||||||
|
|
||||||
|
/** `validation_failed` on the name from the server. */
|
||||||
|
data object Invalid : NameError
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A snackbar of S-6. */
|
||||||
|
sealed interface EditorMessage {
|
||||||
|
data object CouldNotReach : EditorMessage
|
||||||
|
data object NeedsConnection : EditorMessage
|
||||||
|
data class Failed(val code: String) : EditorMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the account editor (S-6) shows.
|
||||||
|
*
|
||||||
|
* @property viewerBp the viewer's default share in bp, null while the typed percent is not a valid 0–100 with at most
|
||||||
|
* two decimals; the partner's is always `10 000 − viewerBp` (AC-6.1).
|
||||||
|
* @property typed the field typed in and its raw [typedText]; the other field shows the formatted complement.
|
||||||
|
* @property saved the account id after a successful save (new: the new account), consumed by the screen.
|
||||||
|
*/
|
||||||
|
data class AccountEditorUi(
|
||||||
|
val editing: AccountId?,
|
||||||
|
val me: User? = null,
|
||||||
|
val partner: User? = null,
|
||||||
|
val loaded: Boolean = false,
|
||||||
|
val name: String = "",
|
||||||
|
val viewerBp: Int? = HALF_BP,
|
||||||
|
val typed: PercentField? = null,
|
||||||
|
val typedText: String = "",
|
||||||
|
val online: Boolean = true,
|
||||||
|
val saving: Boolean = false,
|
||||||
|
val serverNameError: NameError? = null,
|
||||||
|
val splitError: Boolean = false,
|
||||||
|
val message: EditorMessage? = null,
|
||||||
|
val discardDialog: Boolean = false,
|
||||||
|
val saved: AccountId? = null,
|
||||||
|
val initialName: String = "",
|
||||||
|
val initialBp: Int = HALF_BP,
|
||||||
|
) {
|
||||||
|
/** The name error shown: a live length check, else the server's answer to the last save. */
|
||||||
|
val nameError: NameError? get() = if (name.trim().codePointCount() > MAX_NAME) NameError.TooLong else serverNameError
|
||||||
|
|
||||||
|
/** "Spara": a non-blank name within the limit, percentages that sum to 100, online, not already saving (S-6). */
|
||||||
|
val canSave: Boolean get() = loaded && online && !saving && name.isNotBlank() && viewerBp != null && nameError != NameError.TooLong
|
||||||
|
|
||||||
|
/** Field, card and "Spara" are disabled offline (AC-6.7). */
|
||||||
|
val enabled: Boolean get() = online && !saving
|
||||||
|
|
||||||
|
val dirty: Boolean get() = name != initialName || viewerBp != initialBp
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.codePointCount(): Int = codePointCount(0, length)
|
||||||
|
|
||||||
|
/** §9.6: account names are 1–60 code points. */
|
||||||
|
internal const val MAX_NAME = 60
|
||||||
|
|
||||||
|
/** State holder of the account editor (S-6). [AccountDetailViewModel.ARG_ACCOUNT_ID] absent: a new account. */
|
||||||
|
@HiltViewModel
|
||||||
|
class AccountEditorViewModel @Inject constructor(
|
||||||
|
savedState: SavedStateHandle,
|
||||||
|
private val accounts: AccountRepository,
|
||||||
|
session: SessionRepository,
|
||||||
|
connectivity: ConnectivityRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
private val editing: AccountId? = savedState.get<String>(AccountDetailViewModel.ARG_ACCOUNT_ID)?.let(::AccountId)
|
||||||
|
private val form = MutableStateFlow(AccountEditorUi(editing))
|
||||||
|
|
||||||
|
val ui: StateFlow<AccountEditorUi> = combine(form, connectivity.online) { f, online -> f.copy(online = online) }
|
||||||
|
.stateIn(viewModelScope, SharingStarted.Eagerly, AccountEditorUi(editing))
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val paired = session.session.filterIsInstance<SessionState.Paired>().first()
|
||||||
|
val account = editing?.let { id -> accounts.all().first().firstOrNull { it.id == id } }
|
||||||
|
val name = account?.name.orEmpty()
|
||||||
|
val bp = account?.defaultShares?.get(paired.me.id) ?: HALF_BP
|
||||||
|
form.update {
|
||||||
|
it.copy(me = paired.me, partner = paired.partner, loaded = true, name = name, viewerBp = bp, initialName = name, initialBp = bp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onName(name: String) = form.update { it.copy(name = name, serverNameError = null) }
|
||||||
|
|
||||||
|
/** A typed percent in [field]: digits with `,` or `.` and at most two decimals (AC-6.1). */
|
||||||
|
fun onPercent(field: PercentField, text: String) {
|
||||||
|
if (!PERCENT_INPUT.matches(text)) return
|
||||||
|
val bp = parsePercentBp(text)
|
||||||
|
form.update {
|
||||||
|
it.copy(
|
||||||
|
typed = field,
|
||||||
|
typedText = text,
|
||||||
|
viewerBp = bp?.let { v -> if (field == PercentField.Viewer) v else BP_TOTAL - v },
|
||||||
|
splitError = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The slider and the presets set whole percents for the viewer. */
|
||||||
|
fun onViewerPercent(percent: Int) = form.update {
|
||||||
|
it.copy(viewerBp = percent.coerceIn(0, 100) * BP_PER_PERCENT.toInt(), typed = null, typedText = "", splitError = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun save() {
|
||||||
|
val f = form.value
|
||||||
|
val me = f.me ?: return
|
||||||
|
val partner = f.partner ?: return
|
||||||
|
val bp = f.viewerBp ?: return
|
||||||
|
if (!ui.value.canSave) return
|
||||||
|
form.update { it.copy(saving = true, message = null, serverNameError = null, splitError = false) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
val shares = mapOf(me.id to bp, partner.id to BP_TOTAL - bp)
|
||||||
|
val result: OnlineResult<AccountId> = if (editing == null) {
|
||||||
|
accounts.create(f.name, shares)
|
||||||
|
} else {
|
||||||
|
when (val r = accounts.update(editing, f.name, shares)) {
|
||||||
|
is OnlineResult.Ok -> OnlineResult.Ok(editing)
|
||||||
|
OnlineResult.Offline -> OnlineResult.Offline
|
||||||
|
is OnlineResult.Failed -> r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
form.update { s ->
|
||||||
|
when (result) {
|
||||||
|
is OnlineResult.Ok -> s.copy(saving = false, saved = result.value)
|
||||||
|
OnlineResult.Offline -> s.copy(saving = false, message = EditorMessage.NeedsConnection)
|
||||||
|
is OnlineResult.Failed -> s.failed(result.code, result.field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Close: with changes the discard dialog first (design-spec S-6); returns true when the screen may close now. */
|
||||||
|
fun requestClose(): Boolean {
|
||||||
|
if (!form.value.dirty) return true
|
||||||
|
form.update { it.copy(discardDialog = true) }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun keepEditing() = form.update { it.copy(discardDialog = false) }
|
||||||
|
|
||||||
|
fun messageShown() = form.update { it.copy(message = null) }
|
||||||
|
|
||||||
|
fun savedHandled() = form.update { it.copy(saved = null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun AccountEditorUi.failed(code: String, field: String?): AccountEditorUi = when {
|
||||||
|
code == "name_taken" -> copy(saving = false, serverNameError = NameError.Taken(name.trim()))
|
||||||
|
code == "validation_failed" && field == "name" -> copy(saving = false, serverNameError = NameError.Invalid)
|
||||||
|
code == "validation_failed" && field == "default_shares" -> copy(saving = false, splitError = true)
|
||||||
|
code == NETWORK -> copy(saving = false, message = EditorMessage.CouldNotReach)
|
||||||
|
else -> copy(saving = false, message = EditorMessage.Failed(code))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Digits with an optional `,` or `.` and at most two decimals, up to "100". */
|
||||||
|
private val PERCENT_INPUT = Regex("""^\d{0,3}([.,]\d{0,2})?$""")
|
||||||
|
|
||||||
|
/** "60,5" -> 6050; null when empty or outside 0–100 (AC-6.1: percent with up to two decimals, stored as bp). */
|
||||||
|
internal fun parsePercentBp(text: String): Int? {
|
||||||
|
val normalized = text.replace(',', '.')
|
||||||
|
if (normalized.isEmpty() || normalized == ".") return null
|
||||||
|
val whole = normalized.substringBefore('.').ifEmpty { "0" }.toIntOrNull() ?: return null
|
||||||
|
val decimals = normalized.substringAfter('.', "").padEnd(2, '0').toIntOrNull() ?: return null
|
||||||
|
val bp = whole * 100 + decimals
|
||||||
|
return bp.takeIf { it in 0..BP_TOTAL }
|
||||||
|
}
|
||||||
-14
@@ -1,14 +0,0 @@
|
|||||||
package se.hagstedt.kvitt.feature.account
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Module shell from M2b: the feature's entry point. The owning milestone replaces it with one composable per screen
|
|
||||||
* that takes its Hilt ViewModel (`hiltViewModel()`) by default and navigation as plain lambdas (android/README.md).
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun AccountEntry(modifier: Modifier = Modifier) {
|
|
||||||
Box(modifier)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package se.hagstedt.kvitt.feature.account
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.ReadOnlyComposable
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import se.hagstedt.kvitt.core.design.R as DesignR
|
||||||
|
|
||||||
|
/** The snackbar text of an `OnlineResult.Failed` code without a field at fault (§14.3.2, strings.csv `error_*`). */
|
||||||
|
@Composable
|
||||||
|
@ReadOnlyComposable
|
||||||
|
internal fun errorText(code: String): String = stringResource(
|
||||||
|
when (code) {
|
||||||
|
NETWORK -> DesignR.string.common_could_not_reach_server
|
||||||
|
"household_incomplete" -> DesignR.string.error_household_incomplete
|
||||||
|
"not_found" -> DesignR.string.error_not_found
|
||||||
|
"forbidden" -> DesignR.string.error_forbidden
|
||||||
|
"rate_limited" -> DesignR.string.error_rate_limited
|
||||||
|
"protocol_unsupported" -> DesignR.string.error_app_too_old
|
||||||
|
"internal", "unavailable", "not_implemented" -> DesignR.string.error_server
|
||||||
|
"validation_failed" -> DesignR.string.error_validation
|
||||||
|
"account_not_empty" -> R.string.account_delete_blocked
|
||||||
|
else -> DesignR.string.error_generic_invalid
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Strings of :feature:account, from docs/ux/strings.csv (module=account). -->
|
||||||
|
<resources>
|
||||||
|
<!-- "50 / 50 · Jacob och Anna"; %1$s ratio, %2$s names with common_and -->
|
||||||
|
<string name="account_default_split">%1$s · %2$s</string>
|
||||||
|
<!-- TalkBack; %1$s editor_ratio_spoken -->
|
||||||
|
<string name="account_default_split_spoken">Standarddelning: %1$s</string>
|
||||||
|
<string name="account_settle_this">Gör upp det här kontot</string>
|
||||||
|
<string name="account_edit">Ändra konto</string>
|
||||||
|
<string name="account_archive">Arkivera</string>
|
||||||
|
<string name="account_unarchive">Återställ från arkiv</string>
|
||||||
|
<string name="account_delete">Ta bort konto</string>
|
||||||
|
<!-- AC-6.5; also the error account_not_empty -->
|
||||||
|
<string name="account_delete_blocked">Kontot kan inte tas bort så länge det har utgifter, betalningar eller återkommande utgifter</string>
|
||||||
|
<string name="account_delete_confirm_title">Ta bort kontot %1$s?</string>
|
||||||
|
<string name="account_delete_confirm_body">Det går inte att ångra.</string>
|
||||||
|
<string name="account_archived_snackbar">%1$s arkiverades</string>
|
||||||
|
<string name="account_unarchived_snackbar">%1$s återställdes</string>
|
||||||
|
<string name="account_empty_title">Inga utgifter än</string>
|
||||||
|
<string name="account_empty_body">Lägg till den första utgiften i %1$s.</string>
|
||||||
|
<string name="account_editor_new">Nytt konto</string>
|
||||||
|
<string name="account_editor_edit">Ändra konto</string>
|
||||||
|
<string name="account_editor_default_split">Standarddelning</string>
|
||||||
|
<!-- "Av 100,00 kr: Jacob 60,00 kr, Anna 40,00 kr" -->
|
||||||
|
<string name="account_editor_preview">Av %1$s: %2$s</string>
|
||||||
|
<!-- AC-6.3; S-6 under the split control (new and edit) -->
|
||||||
|
<string name="account_editor_split_hint">Gäller nya utgifter i kontot. Varje utgift kan ändra sin egen delning.</string>
|
||||||
|
<!-- §9.9; under the title common_needs_connection; new account -->
|
||||||
|
<string name="account_editor_offline_body">Konton sparas direkt på servern. Anslut telefonen till internet för att skapa kontot.</string>
|
||||||
|
<!-- §9.9; edit mode -->
|
||||||
|
<string name="account_editor_offline_body_edit">Konton sparas direkt på servern. Anslut telefonen till internet för att spara ändringen.</string>
|
||||||
|
<!-- name_taken -->
|
||||||
|
<string name="account_editor_name_taken">Det finns redan ett konto som heter %1$s</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Strings of :feature:account, from docs/ux/strings.csv (module=account). -->
|
||||||
|
<resources>
|
||||||
|
<!-- "50 / 50 · Jacob och Anna"; %1$s ratio, %2$s names with common_and -->
|
||||||
|
<string name="account_default_split">%1$s · %2$s</string>
|
||||||
|
<!-- TalkBack; %1$s editor_ratio_spoken -->
|
||||||
|
<string name="account_default_split_spoken">Default split: %1$s</string>
|
||||||
|
<string name="account_settle_this">Settle this account</string>
|
||||||
|
<string name="account_edit">Edit account</string>
|
||||||
|
<string name="account_archive">Archive</string>
|
||||||
|
<string name="account_unarchive">Restore from archive</string>
|
||||||
|
<string name="account_delete">Delete account</string>
|
||||||
|
<!-- AC-6.5; also the error account_not_empty -->
|
||||||
|
<string name="account_delete_blocked">The account cannot be deleted while it has expenses, payments or recurring expenses</string>
|
||||||
|
<string name="account_delete_confirm_title">Delete the account %1$s?</string>
|
||||||
|
<string name="account_delete_confirm_body">This cannot be undone.</string>
|
||||||
|
<string name="account_archived_snackbar">%1$s was archived</string>
|
||||||
|
<string name="account_unarchived_snackbar">%1$s was restored</string>
|
||||||
|
<string name="account_empty_title">No expenses yet</string>
|
||||||
|
<string name="account_empty_body">Add the first expense in %1$s.</string>
|
||||||
|
<string name="account_editor_new">New account</string>
|
||||||
|
<string name="account_editor_edit">Edit account</string>
|
||||||
|
<string name="account_editor_default_split">Default split</string>
|
||||||
|
<!-- "Av 100,00 kr: Jacob 60,00 kr, Anna 40,00 kr" -->
|
||||||
|
<string name="account_editor_preview">Of %1$s: %2$s</string>
|
||||||
|
<!-- AC-6.3; S-6 under the split control (new and edit) -->
|
||||||
|
<string name="account_editor_split_hint">Applies to new expenses in the account. Each expense can change its own split.</string>
|
||||||
|
<!-- §9.9; under the title common_needs_connection; new account -->
|
||||||
|
<string name="account_editor_offline_body">Accounts are saved straight to the server. Connect the phone to the internet to create the account.</string>
|
||||||
|
<!-- §9.9; edit mode -->
|
||||||
|
<string name="account_editor_offline_body_edit">Accounts are saved straight to the server. Connect the phone to the internet to save the change.</string>
|
||||||
|
<!-- name_taken -->
|
||||||
|
<string name="account_editor_name_taken">There is already an account called %1$s</string>
|
||||||
|
</resources>
|
||||||
+86
@@ -0,0 +1,86 @@
|
|||||||
|
package se.hagstedt.kvitt.feature.account
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.ParameterizedRobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
import org.robolectric.annotation.GraphicsMode
|
||||||
|
import se.hagstedt.kvitt.fakes.FakeScenario
|
||||||
|
import se.hagstedt.kvitt.fakes.account.AccountScenarios
|
||||||
|
import se.hagstedt.kvitt.model.AccountId
|
||||||
|
|
||||||
|
/** One state of a screen: its golden name, whether a 200 % capture is taken, and its content. */
|
||||||
|
class ScreenState(val name: String, val largeFont: Boolean = false, val content: @Composable () -> Unit)
|
||||||
|
|
||||||
|
private const val LONG_NAME = "Gemensamma utgifter för sommarstugan i Österåker och båten i Vaxholm"
|
||||||
|
|
||||||
|
/** Every state of S-5 and S-6 (design-spec §4 and PLAN-M7b §5) × light/dark × sv/en. */
|
||||||
|
val accountStates: List<ScreenState> = listOf(
|
||||||
|
detail("S-5-account", AccountScenarios.loaded, AccountScenarios.vardag),
|
||||||
|
detail("S-5-account-negative", AccountScenarios.loaded, AccountScenarios.huset),
|
||||||
|
detail("S-5-account-archived", AccountScenarios.loaded, AccountScenarios.gotland),
|
||||||
|
detail("S-5-account-menu", AccountScenarios.loaded, AccountScenarios.vardag) { it.setMenuOpen(true) },
|
||||||
|
detail("S-5-account-menu-deletable", AccountScenarios.empty, AccountScenarios.sommarstugan.id) { it.setMenuOpen(true) },
|
||||||
|
detail("S-5-account-delete-confirm", AccountScenarios.empty, AccountScenarios.sommarstugan.id) { it.askDelete() },
|
||||||
|
detail("S-5-account-empty", AccountScenarios.empty, AccountScenarios.sommarstugan.id),
|
||||||
|
detail("S-5-account-offline", AccountScenarios.offline, AccountScenarios.vardag) { it.setMenuOpen(true) },
|
||||||
|
detail("S-5-account-error", AccountScenarios.loaded, AccountScenarios.vardag, failWith = NETWORK) { it.archive() },
|
||||||
|
detail("S-5-account-long", AccountScenarios.long, AccountScenarios.longAccount, largeFont = true),
|
||||||
|
editor("S-6-account-editor-create", AccountScenarios.editor),
|
||||||
|
editor("S-6-account-editor", AccountScenarios.editor, largeFont = true) { it.onName("Sommarstugan"); it.onViewerPercent(60) },
|
||||||
|
editor("S-6-account-editor-edit", AccountScenarios.editor, id = AccountScenarios.huset),
|
||||||
|
editor("S-6-account-editor-decimals", AccountScenarios.editor) { it.onName("Sommarstugan"); it.onPercent(PercentField.Viewer, "62,75") },
|
||||||
|
editor("S-6-account-editor-error", AccountScenarios.editor) { it.onName("Vardag"); it.save() },
|
||||||
|
editor("S-6-account-editor-validation", AccountScenarios.editor) { it.onName(LONG_NAME) },
|
||||||
|
editor("S-6-account-editor-network", AccountScenarios.editor, failWith = NETWORK) { it.onName("Sommarstugan"); it.save() },
|
||||||
|
editor("S-6-account-editor-offline", AccountScenarios.editorOffline) { it.onName("Sommarstugan") },
|
||||||
|
saving(),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun saving(): ScreenState {
|
||||||
|
val ui = editorState(AccountScenarios.editor) { it.onName("Sommarstugan"); it.onViewerPercent(60) }.copy(saving = true)
|
||||||
|
return ScreenState("S-6-account-editor-saving") { AccountEditorContent(ui, AccountEditorActions()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun detail(name: String, scenario: FakeScenario, id: AccountId, failWith: String? = null, largeFont: Boolean = false, act: (AccountDetailViewModel) -> Unit = {}): ScreenState {
|
||||||
|
val ui = detailState(scenario, id, failWith, act)
|
||||||
|
return ScreenState(name, largeFont) { AccountDetailContent(ui, AccountDetailActions()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun editor(name: String, scenario: FakeScenario, id: AccountId? = null, failWith: String? = null, largeFont: Boolean = false, act: (AccountEditorViewModel) -> Unit = {}): ScreenState {
|
||||||
|
val ui = editorState(scenario, id, failWith, act = act)
|
||||||
|
return ScreenState(name, largeFont) { AccountEditorContent(ui, AccountEditorActions()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Goldens of every account detail and account editor state. `check` verifies them; re-record an intended change with
|
||||||
|
* `tools/gradle-serial.sh :feature:account:recordRoborazziDebug` and open every changed PNG.
|
||||||
|
*/
|
||||||
|
@RunWith(ParameterizedRobolectricTestRunner::class)
|
||||||
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
|
@Config(sdk = [36], qualifiers = ScreenSnapshots.QUALIFIERS)
|
||||||
|
class AccountScreenshotTest(private val state: String) {
|
||||||
|
@get:Rule
|
||||||
|
val compose = createComposeRule()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun screen() {
|
||||||
|
val screen = accountStates.first { it.name == state }
|
||||||
|
ScreenSnapshots.snap(compose, screen.name, screen.largeFont, screen.content)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@JvmStatic
|
||||||
|
@ParameterizedRobolectricTestRunner.Parameters(name = "{0}")
|
||||||
|
fun states(): List<Array<Any>> = listOf(
|
||||||
|
"S-5-account", "S-5-account-negative", "S-5-account-archived", "S-5-account-menu", "S-5-account-menu-deletable",
|
||||||
|
"S-5-account-delete-confirm", "S-5-account-empty", "S-5-account-offline", "S-5-account-error", "S-5-account-long",
|
||||||
|
"S-6-account-editor-create", "S-6-account-editor", "S-6-account-editor-edit", "S-6-account-editor-decimals",
|
||||||
|
"S-6-account-editor-error", "S-6-account-editor-validation", "S-6-account-editor-network", "S-6-account-editor-offline",
|
||||||
|
"S-6-account-editor-saving",
|
||||||
|
).map { arrayOf<Any>(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
package se.hagstedt.kvitt.feature.account
|
||||||
|
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
|
import se.hagstedt.kvitt.fakes.FakeRepositories
|
||||||
|
import se.hagstedt.kvitt.fakes.FakeScenario
|
||||||
|
import se.hagstedt.kvitt.model.AccountId
|
||||||
|
import se.hagstedt.kvitt.model.AccountRepository
|
||||||
|
import se.hagstedt.kvitt.model.OnlineResult
|
||||||
|
import se.hagstedt.kvitt.model.UserId
|
||||||
|
|
||||||
|
/** The fake accounts with every online call failing with [code] (a REST error, §9.9); reads are the fakes'. */
|
||||||
|
class FailingAccounts(private val delegate: AccountRepository, private val code: String, private val field: String? = null) : AccountRepository by delegate {
|
||||||
|
override suspend fun create(name: String, defaultShares: Map<UserId, Int>): OnlineResult<AccountId> = OnlineResult.Failed(code, field)
|
||||||
|
override suspend fun update(id: AccountId, name: String, defaultShares: Map<UserId, Int>): OnlineResult<Unit> = OnlineResult.Failed(code, field)
|
||||||
|
override suspend fun setArchived(id: AccountId, archived: Boolean): OnlineResult<Unit> = OnlineResult.Failed(code, field)
|
||||||
|
override suspend fun delete(id: AccountId): OnlineResult<Unit> = OnlineResult.Failed(code, field)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun args(id: AccountId?): SavedStateHandle = SavedStateHandle(if (id == null) emptyMap() else mapOf(AccountDetailViewModel.ARG_ACCOUNT_ID to id.value))
|
||||||
|
|
||||||
|
/** S-5 for [id] in [scenario] after [act]; [failWith] makes the online calls fail with that code. */
|
||||||
|
fun detailState(scenario: FakeScenario, id: AccountId, failWith: String? = null, act: (AccountDetailViewModel) -> Unit = {}): AccountDetailUi = withMain {
|
||||||
|
val fakes = FakeRepositories(scenario)
|
||||||
|
val accounts = if (failWith == null) fakes.accounts else FailingAccounts(fakes.accounts, failWith)
|
||||||
|
val vm = AccountDetailViewModel(args(id), accounts, fakes.connectivity)
|
||||||
|
act(vm)
|
||||||
|
vm.ui.value
|
||||||
|
}
|
||||||
|
|
||||||
|
/** S-6 in [scenario], editing [id] or new, after [act]. */
|
||||||
|
fun editorState(
|
||||||
|
scenario: FakeScenario,
|
||||||
|
id: AccountId? = null,
|
||||||
|
failWith: String? = null,
|
||||||
|
field: String? = null,
|
||||||
|
act: (AccountEditorViewModel) -> Unit = {},
|
||||||
|
): AccountEditorUi = withMain {
|
||||||
|
val fakes = FakeRepositories(scenario)
|
||||||
|
val accounts = if (failWith == null) fakes.accounts else FailingAccounts(fakes.accounts, failWith, field)
|
||||||
|
val vm = AccountEditorViewModel(args(id), accounts, fakes.session, fakes.connectivity)
|
||||||
|
act(vm)
|
||||||
|
vm.ui.value
|
||||||
|
}
|
||||||
+215
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+92
@@ -0,0 +1,92 @@
|
|||||||
|
package se.hagstedt.kvitt.feature.account
|
||||||
|
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import android.os.LocaleList
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalResources
|
||||||
|
import androidx.compose.ui.test.junit4.ComposeContentTestRule
|
||||||
|
import androidx.compose.ui.unit.Density
|
||||||
|
import com.github.takahirom.roborazzi.ExperimentalRoborazziApi
|
||||||
|
import com.github.takahirom.roborazzi.captureScreenRoboImage
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.resetMain
|
||||||
|
import kotlinx.coroutines.test.setMain
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import se.hagstedt.kvitt.design.KvittTheme
|
||||||
|
import se.hagstedt.kvitt.design.component.KvittClock
|
||||||
|
import se.hagstedt.kvitt.design.component.LocalKvittClock
|
||||||
|
import java.util.Locale
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-screen goldens of this module's screens, as M6's component snapshots but for whole screens (dialogs and menus
|
||||||
|
* included): `src/test/screenshots/<state>_<sv|en>_<light|dark>.png`, plus `<state>_sv_light_200.png` at 200 % font
|
||||||
|
* scale for the long states. The screen is the Galaxy S26 (411 × 891 dp), at xhdpi to keep the goldens small. The
|
||||||
|
* clock is fixed at 17 October 2026 14:02 in Stockholm; the frame clock is advanced by hand so animations and
|
||||||
|
* snackbars render the same every run.
|
||||||
|
*/
|
||||||
|
object ScreenSnapshots {
|
||||||
|
const val QUALIFIERS = "w411dp-h891dp-xhdpi"
|
||||||
|
val clock = KvittClock.fixed(Instant.parse("2026-10-17T12:02:00Z"), TimeZone.of("Europe/Stockholm"))
|
||||||
|
private const val SETTLE_MILLIS = 1_000L
|
||||||
|
|
||||||
|
private data class Variant(val language: String, val dark: Boolean, val fontScale: Float) {
|
||||||
|
val suffix: String get() = "${language}_${if (dark) "dark" else "light"}" + if (fontScale > 1f) "_200" else ""
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalRoborazziApi::class)
|
||||||
|
fun snap(compose: ComposeContentTestRule, name: String, largeFont: Boolean, content: @Composable () -> Unit) {
|
||||||
|
val variants = listOf("sv", "en").flatMap { l -> listOf(false, true).map { Variant(l, it, 1f) } } +
|
||||||
|
if (largeFont) listOf(Variant("sv", dark = false, fontScale = 2f)) else emptyList()
|
||||||
|
var current by mutableStateOf(variants.first())
|
||||||
|
compose.mainClock.autoAdvance = false
|
||||||
|
compose.setContent {
|
||||||
|
val variant = current
|
||||||
|
val base = LocalContext.current
|
||||||
|
val baseConfiguration = LocalConfiguration.current
|
||||||
|
val context = remember(variant) {
|
||||||
|
val config = Configuration(baseConfiguration).apply {
|
||||||
|
setLocales(LocaleList(Locale.forLanguageTag(if (variant.language == "sv") "sv-SE" else "en-GB")))
|
||||||
|
fontScale = variant.fontScale
|
||||||
|
uiMode = (uiMode and Configuration.UI_MODE_NIGHT_MASK.inv()) or if (variant.dark) Configuration.UI_MODE_NIGHT_YES else Configuration.UI_MODE_NIGHT_NO
|
||||||
|
}
|
||||||
|
base.createConfigurationContext(config)
|
||||||
|
}
|
||||||
|
CompositionLocalProvider(
|
||||||
|
LocalContext provides context,
|
||||||
|
LocalConfiguration provides context.resources.configuration,
|
||||||
|
LocalResources provides context.resources,
|
||||||
|
LocalDensity provides Density(context),
|
||||||
|
LocalKvittClock provides clock,
|
||||||
|
) {
|
||||||
|
KvittTheme(darkTheme = variant.dark, content = content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
variants.forEach { variant ->
|
||||||
|
current = variant
|
||||||
|
compose.mainClock.advanceTimeBy(SETTLE_MILLIS)
|
||||||
|
captureScreenRoboImage("src/test/screenshots/${name}_${variant.suffix}.png")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs [block] with `Dispatchers.Main` unconfined, so a ViewModel's state is ready when it returns. */
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
fun <T> withMain(block: () -> T): T {
|
||||||
|
Dispatchers.setMain(UnconfinedTestDispatcher())
|
||||||
|
try {
|
||||||
|
return block()
|
||||||
|
} finally {
|
||||||
|
Dispatchers.resetMain()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
// :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 {
|
plugins {
|
||||||
id("kvitt.android.feature")
|
id("kvitt.android.feature")
|
||||||
|
alias(libs.plugins.roborazzi)
|
||||||
|
}
|
||||||
|
|
||||||
|
roborazzi {
|
||||||
|
outputDir.set(file("src/test/screenshots"))
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
testImplementation(libs.kotlinx.coroutines.test)
|
||||||
|
testImplementation(libs.androidx.compose.ui.test.junit4)
|
||||||
|
testImplementation(libs.robolectric)
|
||||||
|
testImplementation(libs.roborazzi)
|
||||||
|
testImplementation(libs.roborazzi.compose)
|
||||||
|
testImplementation(libs.roborazzi.junit.rule)
|
||||||
|
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
package se.hagstedt.kvitt.feature.home
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Module shell from M2b: the feature's entry point. The owning milestone replaces it with one composable per screen
|
|
||||||
* that takes its Hilt ViewModel (`hiltViewModel()`) by default and navigation as plain lambdas (android/README.md).
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun HomeEntry(modifier: Modifier = Modifier) {
|
|
||||||
Box(modifier)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
package se.hagstedt.kvitt.feature.home
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.ReadOnlyComposable
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.key
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import se.hagstedt.kvitt.design.KvittTheme
|
||||||
|
import se.hagstedt.kvitt.design.component.AccountCard
|
||||||
|
import se.hagstedt.kvitt.design.component.AddExpenseFab
|
||||||
|
import se.hagstedt.kvitt.design.component.BalanceHero
|
||||||
|
import se.hagstedt.kvitt.design.component.CollapsibleRow
|
||||||
|
import se.hagstedt.kvitt.design.component.ConfirmDialog
|
||||||
|
import se.hagstedt.kvitt.design.component.EmptyState
|
||||||
|
import se.hagstedt.kvitt.design.component.HeroState
|
||||||
|
import se.hagstedt.kvitt.design.component.HeroWords
|
||||||
|
import se.hagstedt.kvitt.design.component.KvittCard
|
||||||
|
import se.hagstedt.kvitt.design.component.KvittDestination
|
||||||
|
import se.hagstedt.kvitt.design.component.KvittNavigationBar
|
||||||
|
import se.hagstedt.kvitt.design.component.KvittScaffold
|
||||||
|
import se.hagstedt.kvitt.design.component.KvittTopAppBar
|
||||||
|
import se.hagstedt.kvitt.design.component.LocalKvittClock
|
||||||
|
import se.hagstedt.kvitt.design.component.PersonAvatar
|
||||||
|
import se.hagstedt.kvitt.design.component.ProvideHousehold
|
||||||
|
import se.hagstedt.kvitt.design.component.SectionHeader
|
||||||
|
import se.hagstedt.kvitt.design.component.Skeleton
|
||||||
|
import se.hagstedt.kvitt.design.component.SyncStatusPill
|
||||||
|
import se.hagstedt.kvitt.design.component.UpdateBanner
|
||||||
|
import se.hagstedt.kvitt.design.component.kvittLanguage
|
||||||
|
import se.hagstedt.kvitt.design.format.DateFormatter
|
||||||
|
import se.hagstedt.kvitt.design.icon.KvittIcon
|
||||||
|
import se.hagstedt.kvitt.design.theme.KvittDimens
|
||||||
|
import se.hagstedt.kvitt.model.AccountId
|
||||||
|
import se.hagstedt.kvitt.model.AccountSummary
|
||||||
|
import se.hagstedt.kvitt.model.User
|
||||||
|
import se.hagstedt.kvitt.core.design.R as DesignR
|
||||||
|
|
||||||
|
/** Navigation and actions of Home; `:app` wires the navigation lambdas (D-017). */
|
||||||
|
data class HomeActions(
|
||||||
|
val onAddExpense: () -> Unit = {},
|
||||||
|
val onSettleUp: () -> Unit = {},
|
||||||
|
val onOpenAccount: (AccountId) -> Unit = {},
|
||||||
|
val onNewAccount: () -> Unit = {},
|
||||||
|
val onOpenSyncStatus: () -> Unit = {},
|
||||||
|
val onNavigate: (KvittDestination) -> Unit = {},
|
||||||
|
val onToggleArchived: () -> Unit = {},
|
||||||
|
val onUpdate: () -> Unit = {},
|
||||||
|
val onUpdateLater: () -> Unit = {},
|
||||||
|
val onUpdateRetry: () -> Unit = {},
|
||||||
|
val onInstallContinue: () -> Unit = {},
|
||||||
|
val onInstallDismiss: () -> Unit = {},
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Home (S-4) with the update banner and the "Tillåt installation" dialog (S-17). [snackbarHostState] lets `:app` show
|
||||||
|
* the snackbar of the screen it returns from (S-7 saved, S-9 "Betalningen sparades", S-5 archived). While
|
||||||
|
* `HomeState.partner` is null `:app` shows S-3 (`:feature:onboarding`) in this destination instead (AC-5.1).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun HomeScreen(
|
||||||
|
onAddExpense: () -> Unit,
|
||||||
|
onSettleUp: () -> Unit,
|
||||||
|
onOpenAccount: (AccountId) -> Unit,
|
||||||
|
onNewAccount: () -> Unit,
|
||||||
|
onOpenSyncStatus: () -> Unit,
|
||||||
|
onNavigate: (KvittDestination) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
|
||||||
|
viewModel: HomeViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val ui by viewModel.ui.collectAsStateWithLifecycle()
|
||||||
|
// The sync line's relative time is refreshed every minute (design-spec §3.6).
|
||||||
|
var minute by remember { mutableIntStateOf(0) }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
while (true) {
|
||||||
|
delay(MINUTE_MILLIS)
|
||||||
|
minute += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val actions = HomeActions(
|
||||||
|
onAddExpense = onAddExpense,
|
||||||
|
onSettleUp = onSettleUp,
|
||||||
|
onOpenAccount = onOpenAccount,
|
||||||
|
onNewAccount = onNewAccount,
|
||||||
|
onOpenSyncStatus = onOpenSyncStatus,
|
||||||
|
onNavigate = onNavigate,
|
||||||
|
onToggleArchived = viewModel::toggleArchived,
|
||||||
|
onUpdate = viewModel::onUpdate,
|
||||||
|
onUpdateLater = viewModel::onLater,
|
||||||
|
onUpdateRetry = viewModel::onRetry,
|
||||||
|
onInstallContinue = viewModel::onInstallDialogContinue,
|
||||||
|
onInstallDismiss = viewModel::onInstallDialogDismiss,
|
||||||
|
)
|
||||||
|
HomeContent(ui, actions, modifier, snackbarHostState, minute)
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val MINUTE_MILLIS = 60_000L
|
||||||
|
|
||||||
|
/** The stateless Home, rendered by [HomeScreen], previews and screenshot tests. */
|
||||||
|
@Composable
|
||||||
|
fun HomeContent(
|
||||||
|
ui: HomeUi,
|
||||||
|
actions: HomeActions,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
snackbarHostState: SnackbarHostState? = null,
|
||||||
|
tick: Int = 0,
|
||||||
|
listState: LazyListState = rememberLazyListState(),
|
||||||
|
) {
|
||||||
|
val ready = ui as? HomeUi.Ready
|
||||||
|
val scrolled by remember { derivedStateOf { listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0 } }
|
||||||
|
val fabExpanded by remember { derivedStateOf { !listState.lastScrolledForward } }
|
||||||
|
KvittScaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
topBar = { KvittTopAppBar(title = null, scrolled = scrolled) },
|
||||||
|
bottomBar = { KvittNavigationBar(KvittDestination.Home, actions.onNavigate) },
|
||||||
|
floatingActionButton = { if (ready?.showFab == true) AddExpenseFab(actions.onAddExpense, expanded = fabExpanded) },
|
||||||
|
snackbarHostState = snackbarHostState,
|
||||||
|
) { padding ->
|
||||||
|
val partner = ready?.home?.partner
|
||||||
|
if (ready != null && partner != null) {
|
||||||
|
ProvideHousehold(ready.home.me, partner) {
|
||||||
|
HomeList(ready, partner, actions, padding, tick, listState)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Box(Modifier.fillMaxSize().padding(padding))
|
||||||
|
}
|
||||||
|
if (ready?.installDialog == true) InstallDialog(actions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun HomeList(ui: HomeUi.Ready, partner: User, actions: HomeActions, padding: PaddingValues, tick: Int, listState: LazyListState) {
|
||||||
|
val spacing = KvittTheme.spacing
|
||||||
|
val words = HeroWords(
|
||||||
|
settleUp = stringResource(R.string.home_settle_up),
|
||||||
|
loadingTitle = stringResource(R.string.home_loading),
|
||||||
|
loadingBody = stringResource(R.string.home_loading_body),
|
||||||
|
)
|
||||||
|
val colorIndex = ui.colorIndex
|
||||||
|
LazyColumn(
|
||||||
|
Modifier.fillMaxSize().padding(padding),
|
||||||
|
state = listState,
|
||||||
|
contentPadding = PaddingValues(start = spacing.gutter, end = spacing.gutter, top = spacing.s1, bottom = spacing.fabClearance),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(spacing.sectionGap),
|
||||||
|
) {
|
||||||
|
item(key = "update") { UpdateBannerItem(ui, actions) }
|
||||||
|
if (ui.loading) {
|
||||||
|
item(key = "hero") { BalanceHero(HeroState.Loading, ui.home.me, words, actions.onSettleUp) }
|
||||||
|
item(key = "pill") {
|
||||||
|
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||||
|
Skeleton(spacing.s1, KvittDimens.pillMinHeight, Modifier.fillMaxWidth(SKELETON_PILL_FRACTION), pill = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item(key = "header") { SectionHeader(stringResource(R.string.home_accounts)) }
|
||||||
|
items(SKELETON_CARDS) { Skeleton(spacing.s1, KvittDimens.accountCardMin, Modifier.fillMaxWidth()) }
|
||||||
|
return@LazyColumn
|
||||||
|
}
|
||||||
|
if (ui.empty) {
|
||||||
|
item(key = "welcome") { WelcomeCard(ui.home.me, partner) }
|
||||||
|
item(key = "empty") {
|
||||||
|
EmptyState(
|
||||||
|
title = stringResource(R.string.home_empty_title),
|
||||||
|
body = stringResource(R.string.home_empty_body),
|
||||||
|
illustration = ui.illustration,
|
||||||
|
action = stringResource(R.string.home_new_account),
|
||||||
|
actionIcon = KvittIcon.Add,
|
||||||
|
onAction = actions.onNewAccount,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return@LazyColumn
|
||||||
|
}
|
||||||
|
item(key = "hero") { BalanceHero(HeroState.of(ui.home.global), ui.home.me, words, actions.onSettleUp) }
|
||||||
|
item(key = "pill") {
|
||||||
|
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||||
|
key(tick) { SyncStatusPill(ui.home.sync, ui.online, actions.onOpenSyncStatus) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item(key = "header") {
|
||||||
|
SectionHeader(
|
||||||
|
stringResource(R.string.home_accounts),
|
||||||
|
action = stringResource(R.string.home_new_account),
|
||||||
|
actionIcon = KvittIcon.Add,
|
||||||
|
actionEnabled = ui.online,
|
||||||
|
actionSupporting = if (ui.online) null else stringResource(DesignR.string.common_needs_connection),
|
||||||
|
onAction = actions.onNewAccount,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ui.home.active.forEach { summary ->
|
||||||
|
item(key = summary.account.id.value) { HomeAccountCard(summary, partner, colorIndex[summary.account.id.value] ?: 0, actions) }
|
||||||
|
}
|
||||||
|
if (ui.home.archived.isNotEmpty()) {
|
||||||
|
item(key = "archived") {
|
||||||
|
CollapsibleRow(
|
||||||
|
title = stringResource(R.string.home_archived_section, ui.home.archived.size),
|
||||||
|
expanded = ui.archivedExpanded,
|
||||||
|
expandedState = stringResource(R.string.home_archived_expanded),
|
||||||
|
collapsedState = stringResource(R.string.home_archived_collapsed),
|
||||||
|
onToggle = actions.onToggleArchived,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (ui.archivedExpanded) {
|
||||||
|
ui.home.archived.forEach { summary ->
|
||||||
|
item(key = summary.account.id.value) { HomeAccountCard(summary, partner, colorIndex[summary.account.id.value] ?: 0, actions) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val SKELETON_CARDS = 3
|
||||||
|
private const val SKELETON_PILL_FRACTION = 0.5f
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun UpdateBannerItem(ui: HomeUi.Ready, actions: HomeActions) {
|
||||||
|
UpdateBanner(ui.update, ui.online, actions.onUpdate, actions.onUpdateLater, actions.onUpdateRetry)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun HomeAccountCard(summary: AccountSummary, partner: User, colorIndex: Int, actions: HomeActions) {
|
||||||
|
AccountCard(summary, partner, colorIndex, expensesLine(summary.expensesThisMonth), onClick = { actions.onOpenAccount(summary.account.id) })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "11 utgifter i oktober" / "Inga utgifter i oktober" for the current month (design-spec §3.4). */
|
||||||
|
@Composable
|
||||||
|
@ReadOnlyComposable
|
||||||
|
private fun expensesLine(count: Int): String {
|
||||||
|
val today = LocalKvittClock.current.today
|
||||||
|
val period = today.year.toString() + "-" + today.month.ordinal.plus(1).toString().padStart(2, '0')
|
||||||
|
// monthYear is "oktober 2026" / "October 2026"; running text needs the month alone.
|
||||||
|
val month = DateFormatter.monthYear(period, kvittLanguage()).substringBeforeLast(' ')
|
||||||
|
return if (count > 0) pluralStringResource(R.plurals.home_account_expenses_month, count, count, month) else stringResource(R.string.home_account_no_expenses_month, month)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** S-4 empty: both avatars, "Du och Anna är med", "Hushållet är klart. Nu behövs ett konto." */
|
||||||
|
@Composable
|
||||||
|
private fun WelcomeCard(me: User, partner: User) {
|
||||||
|
KvittCard(Modifier.fillMaxWidth().semantics(mergeDescendants = true) {}) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.cardTileGap)) {
|
||||||
|
Box {
|
||||||
|
PersonAvatar(me, KvittDimens.accountTile)
|
||||||
|
PersonAvatar(partner, KvittDimens.accountTile, Modifier.padding(start = KvittDimens.accountTile - KvittTheme.spacing.s2))
|
||||||
|
}
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s1)) {
|
||||||
|
Text(stringResource(R.string.home_empty_welcome, partner.displayName), style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(stringResource(R.string.home_empty_subtitle), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** S-17: "Tillåt installation", shown once before the first install (AC-16.3). */
|
||||||
|
@Composable
|
||||||
|
private fun InstallDialog(actions: HomeActions) {
|
||||||
|
ConfirmDialog(
|
||||||
|
icon = KvittIcon.InstallMobile,
|
||||||
|
title = stringResource(DesignR.string.update_permission_title),
|
||||||
|
body = stringResource(DesignR.string.update_permission_body),
|
||||||
|
confirm = stringResource(DesignR.string.common_continue),
|
||||||
|
dismiss = stringResource(DesignR.string.update_later),
|
||||||
|
onConfirm = actions.onInstallContinue,
|
||||||
|
onDismiss = actions.onInstallDismiss,
|
||||||
|
tertiaryTile = true,
|
||||||
|
confirmIcon = KvittIcon.ArrowForward,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package se.hagstedt.kvitt.feature.home
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import se.hagstedt.kvitt.model.AccountSummary
|
||||||
|
import se.hagstedt.kvitt.model.Category
|
||||||
|
import se.hagstedt.kvitt.model.CategoryRepository
|
||||||
|
import se.hagstedt.kvitt.model.DefaultCategoryKey
|
||||||
|
import se.hagstedt.kvitt.model.ConnectivityRepository
|
||||||
|
import se.hagstedt.kvitt.model.HomeRepository
|
||||||
|
import se.hagstedt.kvitt.model.HomeState
|
||||||
|
import se.hagstedt.kvitt.model.UpdateRepository
|
||||||
|
import se.hagstedt.kvitt.model.UpdateState
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/** What Home (S-4, with the S-17 banner and dialog) shows. */
|
||||||
|
sealed interface HomeUi {
|
||||||
|
/** Before the first emission of the local copy. */
|
||||||
|
data object Starting : HomeUi
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property loading the first sync after pairing is still running (no successful sync yet): hero `Loading` and
|
||||||
|
* skeletons, no FAB.
|
||||||
|
* @property update the update banner's state, [UpdateState.None] while hidden ("Senare" until the next cold start).
|
||||||
|
* @property installDialog the "Tillåt installation" dialog is open (S-17).
|
||||||
|
* @property illustration the three tiles of the empty state (groceries, travel, restaurants; design-spec S-4).
|
||||||
|
*/
|
||||||
|
data class Ready(
|
||||||
|
val home: HomeState,
|
||||||
|
val online: Boolean,
|
||||||
|
val update: UpdateState,
|
||||||
|
val archivedExpanded: Boolean,
|
||||||
|
val installDialog: Boolean,
|
||||||
|
val loading: Boolean,
|
||||||
|
val illustration: List<Category> = emptyList(),
|
||||||
|
) : HomeUi {
|
||||||
|
/** No account at all: the empty state, no sync pill, no FAB (AC-8.6 needs an account). */
|
||||||
|
val empty: Boolean get() = !loading && home.active.isEmpty() && home.archived.isEmpty()
|
||||||
|
|
||||||
|
/** The FAB only while an active account exists (UX-6). */
|
||||||
|
val showFab: Boolean get() = !loading && home.active.isNotEmpty()
|
||||||
|
|
||||||
|
/** "Gör upp" and the hero amount only with a partner and a non-zero balance (AC-10.1). */
|
||||||
|
val canSettle: Boolean get() = (home.global?.netOre ?: 0L) != 0L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Colour slot per account: its position among all accounts by `created_at`, archived included (design-spec §2.4,
|
||||||
|
* AC-6.6).
|
||||||
|
*/
|
||||||
|
val colorIndex: Map<String, Int>
|
||||||
|
get() = (home.active + home.archived).map(AccountSummary::account)
|
||||||
|
.sortedWith(compareBy({ it.createdAt }, { it.id.value }))
|
||||||
|
.mapIndexed { index, account -> account.id.value to index }
|
||||||
|
.toMap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Session-scoped choices on Home: the archived section, a hidden update banner, the install dialog. */
|
||||||
|
private data class HomeLocal(val archivedExpanded: Boolean = false, val updateHidden: Boolean = false, val installDialog: Boolean = false)
|
||||||
|
|
||||||
|
/** State holder of Home (S-4) and its update banner and dialog (S-17). */
|
||||||
|
@HiltViewModel
|
||||||
|
class HomeViewModel @Inject constructor(
|
||||||
|
home: HomeRepository,
|
||||||
|
connectivity: ConnectivityRepository,
|
||||||
|
private val updates: UpdateRepository,
|
||||||
|
categories: CategoryRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
private val local = MutableStateFlow(HomeLocal())
|
||||||
|
|
||||||
|
private val illustration = categories.all().map { all ->
|
||||||
|
EMPTY_ILLUSTRATION.mapNotNull { key -> all.firstOrNull { it.defaultKey == key } }
|
||||||
|
}
|
||||||
|
|
||||||
|
val ui: StateFlow<HomeUi> = combine(home.home(), connectivity.online, updates.state, local, illustration) { state, online, update, l, tiles ->
|
||||||
|
HomeUi.Ready(
|
||||||
|
home = state,
|
||||||
|
online = online,
|
||||||
|
update = if (l.updateHidden) UpdateState.None else update,
|
||||||
|
archivedExpanded = l.archivedExpanded,
|
||||||
|
installDialog = l.installDialog && update is UpdateState.NeedsInstallPermission,
|
||||||
|
loading = state.sync.lastSuccessAt == null,
|
||||||
|
illustration = tiles,
|
||||||
|
)
|
||||||
|
}.stateIn(viewModelScope, SharingStarted.Eagerly, HomeUi.Starting)
|
||||||
|
|
||||||
|
fun toggleArchived() = local.update { it.copy(archivedExpanded = !it.archivedExpanded) }
|
||||||
|
|
||||||
|
/** "Uppdatera": the first time the explanation dialog (AC-16.3), otherwise the download and install. */
|
||||||
|
fun onUpdate() {
|
||||||
|
val current = (ui.value as? HomeUi.Ready)?.update
|
||||||
|
if (current is UpdateState.NeedsInstallPermission) {
|
||||||
|
local.update { it.copy(installDialog = true) }
|
||||||
|
} else {
|
||||||
|
install()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Försök igen" after a failed download (AC-16.4). */
|
||||||
|
fun onRetry() = install()
|
||||||
|
|
||||||
|
/** "Senare": hides the banner until the next cold start (design-spec S-17). */
|
||||||
|
fun onLater() = local.update { it.copy(updateHidden = true, installDialog = false) }
|
||||||
|
|
||||||
|
/** "Fortsätt" in the dialog: the next `install()` proceeds to the system prompt (AC-16.3). */
|
||||||
|
fun onInstallDialogContinue() {
|
||||||
|
local.update { it.copy(installDialog = false) }
|
||||||
|
install()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onInstallDialogDismiss() = local.update { it.copy(installDialog = false) }
|
||||||
|
|
||||||
|
private fun install() {
|
||||||
|
viewModelScope.launch { updates.install() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
val EMPTY_ILLUSTRATION = listOf(DefaultCategoryKey.GROCERIES, DefaultCategoryKey.TRAVEL, DefaultCategoryKey.RESTAURANTS)
|
||||||
|
}
|
||||||
|
}
|
||||||
+291
@@ -0,0 +1,291 @@
|
|||||||
|
package se.hagstedt.kvitt.feature.home
|
||||||
|
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.Intent
|
||||||
|
import android.provider.Settings
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.ReadOnlyComposable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import se.hagstedt.kvitt.design.KvittTheme
|
||||||
|
import se.hagstedt.kvitt.design.component.Banner
|
||||||
|
import se.hagstedt.kvitt.design.component.BannerKind
|
||||||
|
import se.hagstedt.kvitt.design.component.ButtonKind
|
||||||
|
import se.hagstedt.kvitt.design.component.ButtonSize
|
||||||
|
import se.hagstedt.kvitt.design.component.ExpenseRow
|
||||||
|
import se.hagstedt.kvitt.design.component.KvittButton
|
||||||
|
import se.hagstedt.kvitt.design.component.KvittCard
|
||||||
|
import se.hagstedt.kvitt.design.component.KvittIconImage
|
||||||
|
import se.hagstedt.kvitt.design.component.KvittScaffold
|
||||||
|
import se.hagstedt.kvitt.design.component.KvittTopAppBar
|
||||||
|
import se.hagstedt.kvitt.design.component.LocalKvittClock
|
||||||
|
import se.hagstedt.kvitt.design.component.NeedsConnectionHint
|
||||||
|
import se.hagstedt.kvitt.design.component.ProvideHousehold
|
||||||
|
import se.hagstedt.kvitt.design.component.SectionHeader
|
||||||
|
import se.hagstedt.kvitt.design.component.SegmentedGroup
|
||||||
|
import se.hagstedt.kvitt.design.component.SettlementRow
|
||||||
|
import se.hagstedt.kvitt.design.component.TopBarNavigation
|
||||||
|
import se.hagstedt.kvitt.design.component.kvittLanguage
|
||||||
|
import se.hagstedt.kvitt.design.component.relativeTime
|
||||||
|
import se.hagstedt.kvitt.design.format.DateFormatter
|
||||||
|
import se.hagstedt.kvitt.design.icon.KvittIcon
|
||||||
|
import se.hagstedt.kvitt.design.theme.KvittDimens
|
||||||
|
import se.hagstedt.kvitt.model.EntityType
|
||||||
|
import se.hagstedt.kvitt.model.ExpenseId
|
||||||
|
import se.hagstedt.kvitt.model.LedgerRow
|
||||||
|
import se.hagstedt.kvitt.model.Rejection
|
||||||
|
import se.hagstedt.kvitt.model.SettlementId
|
||||||
|
import se.hagstedt.kvitt.model.SyncRejectionReason
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import se.hagstedt.kvitt.core.design.R as DesignR
|
||||||
|
|
||||||
|
/** Actions of the sync status screen; `:app` wires the navigation (D-017). */
|
||||||
|
data class SyncStatusActions(
|
||||||
|
val onBack: () -> Unit = {},
|
||||||
|
val onOpenExpense: (ExpenseId) -> Unit = {},
|
||||||
|
val onOpenSettlement: (SettlementId) -> Unit = {},
|
||||||
|
val onSyncNow: () -> Unit = {},
|
||||||
|
val onDismiss: (String) -> Unit = {},
|
||||||
|
val onOpenDateSettings: () -> Unit = {},
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Sync status (S-16): status, "Synka nu", pending changes and the changes that were not synced (AC-15.5). */
|
||||||
|
@Composable
|
||||||
|
fun SyncStatusScreen(
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onOpenExpense: (ExpenseId) -> Unit,
|
||||||
|
onOpenSettlement: (SettlementId) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: SyncStatusViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val ui by viewModel.ui.collectAsStateWithLifecycle()
|
||||||
|
val context = LocalContext.current
|
||||||
|
val actions = SyncStatusActions(
|
||||||
|
onBack = onBack,
|
||||||
|
onOpenExpense = onOpenExpense,
|
||||||
|
onOpenSettlement = onOpenSettlement,
|
||||||
|
onSyncNow = viewModel::syncNow,
|
||||||
|
onDismiss = viewModel::dismiss,
|
||||||
|
onOpenDateSettings = {
|
||||||
|
try {
|
||||||
|
context.startActivity(Intent(Settings.ACTION_DATE_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||||
|
} catch (e: ActivityNotFoundException) {
|
||||||
|
Log.w(LOG_TAG, "no date settings activity", e)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
SyncStatusContent(ui, actions, modifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val LOG_TAG = "KvittSyncStatus"
|
||||||
|
|
||||||
|
/** The stateless sync status screen, rendered by [SyncStatusScreen], previews and screenshot tests. */
|
||||||
|
@Composable
|
||||||
|
fun SyncStatusContent(ui: SyncStatusUi, actions: SyncStatusActions, modifier: Modifier = Modifier) {
|
||||||
|
KvittScaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
topBar = { KvittTopAppBar(stringResource(R.string.home_sync_status_title), navigation = TopBarNavigation.Back, onNavigation = actions.onBack) },
|
||||||
|
) { padding ->
|
||||||
|
val ready = ui as? SyncStatusUi.Ready
|
||||||
|
if (ready == null) {
|
||||||
|
Box(Modifier.fillMaxSize().padding(padding))
|
||||||
|
return@KvittScaffold
|
||||||
|
}
|
||||||
|
ProvideHousehold(ready.me, ready.partner) {
|
||||||
|
SyncStatusList(ready, actions, padding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SyncStatusList(ui: SyncStatusUi.Ready, actions: SyncStatusActions, padding: PaddingValues) {
|
||||||
|
val spacing = KvittTheme.spacing
|
||||||
|
val summary = ui.status.summary
|
||||||
|
LazyColumn(
|
||||||
|
Modifier.fillMaxSize().padding(padding),
|
||||||
|
contentPadding = PaddingValues(start = spacing.gutter, end = spacing.gutter, top = spacing.s1, bottom = spacing.s8),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(spacing.detailGap),
|
||||||
|
) {
|
||||||
|
if (summary.clockSkew) item(key = "clock") { ClockBanner(actions.onOpenDateSettings) }
|
||||||
|
item(key = "status") { StatusCard(ui) }
|
||||||
|
item(key = "sync-now") {
|
||||||
|
Column {
|
||||||
|
KvittButton(
|
||||||
|
stringResource(R.string.home_sync_status_sync_now),
|
||||||
|
actions.onSyncNow,
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
size = ButtonSize.Medium,
|
||||||
|
icon = KvittIcon.Sync,
|
||||||
|
enabled = ui.canSyncNow,
|
||||||
|
)
|
||||||
|
if (!ui.online) NeedsConnectionHint()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ui.status.pending.isNotEmpty()) {
|
||||||
|
item(key = "pending-title") { SectionHeader(stringResource(R.string.home_sync_status_pending_title, ui.status.pending.size)) }
|
||||||
|
item(key = "pending") { PendingGroup(ui.status.pending, actions) }
|
||||||
|
}
|
||||||
|
if (ui.status.rejections.isNotEmpty()) {
|
||||||
|
item(key = "rejected-title") { SectionHeader(stringResource(R.string.home_sync_status_rejected_title)) }
|
||||||
|
ui.status.rejections.forEach { rejection ->
|
||||||
|
item(key = rejection.mutationId) { RejectionCard(rejection, ui, actions) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Error banner of §9.10: "Telefonens klocka går fel…" with "Datum och tid". */
|
||||||
|
@Composable
|
||||||
|
private fun ClockBanner(onOpenDateSettings: () -> Unit) {
|
||||||
|
Banner(
|
||||||
|
BannerKind.Error,
|
||||||
|
KvittIcon.Schedule,
|
||||||
|
stringResource(R.string.home_sync_status_clock_warning),
|
||||||
|
body = stringResource(R.string.home_sync_status_clock_body),
|
||||||
|
) { content ->
|
||||||
|
KvittButton(stringResource(R.string.home_sync_status_open_date_settings), onOpenDateSettings, kind = ButtonKind.Text, icon = KvittIcon.OpenInNew, contentColor = content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The status card: Sunny tile, the state in words over "Senast lyckade synk 13:59"; one TalkBack node. */
|
||||||
|
@Composable
|
||||||
|
private fun StatusCard(ui: SyncStatusUi.Ready) {
|
||||||
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
val colors = KvittTheme.colors
|
||||||
|
val summary = ui.status.summary
|
||||||
|
val lastSuccess = summary.lastSuccessAt
|
||||||
|
val problem = summary.clockSkew || ui.failed
|
||||||
|
val title = when {
|
||||||
|
ui.failed -> stringResource(DesignR.string.sync_failed_line)
|
||||||
|
summary.running || lastSuccess == null -> stringResource(DesignR.string.sync_syncing)
|
||||||
|
!ui.online -> stringResource(DesignR.string.common_offline)
|
||||||
|
ui.allSynced -> stringResource(R.string.home_sync_status_all_synced)
|
||||||
|
else -> stringResource(DesignR.string.sync_synced_relative, relativeTime(lastSuccess))
|
||||||
|
}
|
||||||
|
val subtitle = when {
|
||||||
|
summary.clockSkew -> stringResource(R.string.home_sync_status_last_attempt_clock)
|
||||||
|
lastSuccess != null -> stringResource(R.string.home_sync_status_last_success, lastSyncTime(lastSuccess))
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
val spoken = listOfNotNull(title, subtitle).joinToString(stringResource(DesignR.string.common_separator_comma))
|
||||||
|
KvittCard(Modifier.fillMaxWidth().clearAndSetSemantics { contentDescription = spoken }, contentPadding = PaddingValues(KvittDimens.cardPaddingLarge)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(KvittDimens.dialogGap)) {
|
||||||
|
Box(
|
||||||
|
Modifier.size(KvittDimens.tileEditor).background(if (problem) scheme.surfaceContainerHighest else colors.owedContainer, KvittTheme.shapes.avatarLarge),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
KvittIconImage(
|
||||||
|
if (problem) KvittIcon.SyncProblem else KvittIcon.CloudDone,
|
||||||
|
null,
|
||||||
|
filled = true,
|
||||||
|
tint = if (problem) scheme.onSurfaceVariant else colors.owed,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s1)) {
|
||||||
|
Text(title, style = MaterialTheme.typography.titleMedium)
|
||||||
|
if (subtitle != null) Text(subtitle, style = MaterialTheme.typography.bodySmall, color = scheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "13:59" today, otherwise "16 okt. 14:00" (§9.8). */
|
||||||
|
@Composable
|
||||||
|
@ReadOnlyComposable
|
||||||
|
private fun lastSyncTime(rfc3339: String): String {
|
||||||
|
val clock = LocalKvittClock.current
|
||||||
|
val instant = Instant.parse(rfc3339)
|
||||||
|
return if (instant.toLocalDateTime(clock.zone).date == clock.today) {
|
||||||
|
DateFormatter.time(instant, clock.zone)
|
||||||
|
} else {
|
||||||
|
DateFormatter.listDateTime(instant, clock.zone, clock.today, kvittLanguage())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pending rows: the ledger rows of `:core:design` (D-019), each with its pending marker. */
|
||||||
|
@Composable
|
||||||
|
private fun PendingGroup(rows: List<LedgerRow>, actions: SyncStatusActions) {
|
||||||
|
val typeLabel = stringResource(DesignR.string.ledger_settlement_label)
|
||||||
|
SegmentedGroup(rows.size) { item ->
|
||||||
|
when (val row = rows[item.index]) {
|
||||||
|
is LedgerRow.ExpenseRow -> ExpenseRow(item, row, onClick = { actions.onOpenExpense(row.id) })
|
||||||
|
is LedgerRow.SettlementRow -> SettlementRow(item, row, typeLabel, onClick = { actions.onOpenSettlement(row.id) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One change that was not synced (§14.4.6): what, why and when, "Öppna" and "Ta bort från listan". */
|
||||||
|
@Composable
|
||||||
|
private fun RejectionCard(rejection: Rejection, ui: SyncStatusUi.Ready, actions: SyncStatusActions) {
|
||||||
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
val clock = LocalKvittClock.current
|
||||||
|
val partnerName = ui.partner?.displayName.orEmpty()
|
||||||
|
val settlement = rejection.entityType == EntityType.SETTLEMENT
|
||||||
|
val reason = when (rejection.reason) {
|
||||||
|
SyncRejectionReason.STALE -> stringResource(R.string.home_sync_reject_stale, partnerName)
|
||||||
|
SyncRejectionReason.DELETED -> stringResource(R.string.home_sync_reject_deleted)
|
||||||
|
SyncRejectionReason.VALIDATION_FAILED -> stringResource(R.string.home_sync_reject_validation)
|
||||||
|
SyncRejectionReason.REFERENCE_NOT_FOUND -> stringResource(R.string.home_sync_reject_reference)
|
||||||
|
SyncRejectionReason.CLOCK_SKEW -> stringResource(R.string.home_sync_reject_clock_skew)
|
||||||
|
}
|
||||||
|
val help = when {
|
||||||
|
rejection.reason != SyncRejectionReason.STALE -> null
|
||||||
|
settlement -> stringResource(R.string.home_sync_reject_stale_help_payment, partnerName)
|
||||||
|
else -> stringResource(R.string.home_sync_reject_stale_help, partnerName)
|
||||||
|
}
|
||||||
|
val time = DateFormatter.listDateTime(Instant.parse(rejection.rejectedAt), clock.zone, clock.today, kvittLanguage())
|
||||||
|
val reasonLine = reason + stringResource(DesignR.string.common_separator_dot) + time
|
||||||
|
val open = {
|
||||||
|
if (settlement) actions.onOpenSettlement(SettlementId(rejection.entityId)) else actions.onOpenExpense(ExpenseId(rejection.entityId))
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.border(BorderStroke(KvittDimens.divider, scheme.outlineVariant), KvittTheme.shapes.accountCard)
|
||||||
|
.padding(KvittTheme.spacing.s4),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(KvittDimens.emptyGap),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier.semantics(mergeDescendants = true) {},
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3),
|
||||||
|
) {
|
||||||
|
Box(Modifier.size(KvittDimens.tileSmall).background(scheme.surfaceContainerHighest, KvittTheme.shapes.categoryTile), contentAlignment = Alignment.Center) {
|
||||||
|
KvittIconImage(if (settlement) KvittIcon.Handshake else KvittIcon.ReceiptLong, null, filled = true, size = KvittDimens.iconChip, tint = scheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s1)) {
|
||||||
|
Text(rejection.summary, style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(reasonLine, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
if (help != null) Text(help, style = MaterialTheme.typography.bodySmall, color = scheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2, Alignment.End)) {
|
||||||
|
KvittButton(stringResource(R.string.home_sync_status_open), open, kind = ButtonKind.Text)
|
||||||
|
KvittButton(stringResource(R.string.home_sync_status_dismiss), { actions.onDismiss(rejection.mutationId) }, kind = ButtonKind.Tonal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
package se.hagstedt.kvitt.feature.home
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import se.hagstedt.kvitt.model.ConnectivityRepository
|
||||||
|
import se.hagstedt.kvitt.model.SessionRepository
|
||||||
|
import se.hagstedt.kvitt.model.SessionState
|
||||||
|
import se.hagstedt.kvitt.model.SyncRepository
|
||||||
|
import se.hagstedt.kvitt.model.SyncStatusState
|
||||||
|
import se.hagstedt.kvitt.model.User
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/** What the sync status screen (S-16) shows. */
|
||||||
|
sealed interface SyncStatusUi {
|
||||||
|
data object Starting : SyncStatusUi
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property me the viewer and [partner] the other person: `stale` is explained with the partner's name (§14.4.6;
|
||||||
|
* `Rejection` carries no actor, see the M7b report).
|
||||||
|
* @property failed the last attempt failed: `SyncSummary` has no such field yet, so the ViewModel passes false.
|
||||||
|
*/
|
||||||
|
data class Ready(val status: SyncStatusState, val online: Boolean, val me: User, val partner: User?, val failed: Boolean = false) : SyncStatusUi {
|
||||||
|
/** Nothing pending, nothing refused, no clock problem: "Allt är synkat". */
|
||||||
|
val allSynced: Boolean
|
||||||
|
get() = status.pending.isEmpty() && status.rejections.isEmpty() && !status.summary.clockSkew && !status.summary.running && online && !failed
|
||||||
|
|
||||||
|
/** "Synka nu" is offered when online and no sync runs (§9.9 does not list it; it needs the server). */
|
||||||
|
val canSyncNow: Boolean get() = online && !status.summary.running
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** State holder of the sync status screen (S-16, AC-15.5). */
|
||||||
|
@HiltViewModel
|
||||||
|
class SyncStatusViewModel @Inject constructor(
|
||||||
|
private val sync: SyncRepository,
|
||||||
|
connectivity: ConnectivityRepository,
|
||||||
|
session: SessionRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
val ui: StateFlow<SyncStatusUi> = combine(sync.status(), connectivity.online, session.session) { status, online, s ->
|
||||||
|
if (s is SessionState.Paired) SyncStatusUi.Ready(status, online, s.me, s.partner) else SyncStatusUi.Starting
|
||||||
|
}.stateIn(viewModelScope, SharingStarted.Eagerly, SyncStatusUi.Starting)
|
||||||
|
|
||||||
|
fun syncNow() {
|
||||||
|
viewModelScope.launch { sync.syncNow() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Ta bort från listan": works offline (design-spec S-16). */
|
||||||
|
fun dismiss(mutationId: String) {
|
||||||
|
viewModelScope.launch { sync.dismiss(mutationId) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Strings of :feature:home, from docs/ux/strings.csv (module=home). -->
|
||||||
|
<resources>
|
||||||
|
<!-- shown on Home before the system permission prompt, after the first full sync (AC-2.9, ia.md §3) -->
|
||||||
|
<string name="home_notification_rationale">Kvitt säger till när %1$s lägger till eller ändrar något.</string>
|
||||||
|
<string name="home_accounts">Konton</string>
|
||||||
|
<string name="home_new_account">Nytt konto</string>
|
||||||
|
<string name="home_archived_section">Arkiverade (%d)</string>
|
||||||
|
<string name="home_settle_up">Gör upp</string>
|
||||||
|
<!-- plural one; %2$s month name (lowercase in sv) -->
|
||||||
|
<plurals name="home_account_expenses_month">
|
||||||
|
<item quantity="one">%1$d utgift i %2$s</item>
|
||||||
|
<item quantity="other">%1$d utgifter i %2$s</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="home_account_no_expenses_month">Inga utgifter i %1$s</string>
|
||||||
|
<!-- first sync only -->
|
||||||
|
<string name="home_loading">Hämtar era utgifter…</string>
|
||||||
|
<!-- first sync only -->
|
||||||
|
<string name="home_loading_body">Första synken hämtar allt som finns på servern. Det tar bara en stund.</string>
|
||||||
|
<!-- %1$s partner name; S-4 empty state card -->
|
||||||
|
<string name="home_empty_welcome">Du och %1$s är med</string>
|
||||||
|
<string name="home_empty_subtitle">Hushållet är klart. Nu behövs ett konto.</string>
|
||||||
|
<string name="home_empty_title">Skapa ert första konto</string>
|
||||||
|
<string name="home_empty_body">Ett konto samlar utgifter som ni delar på samma sätt, till exempel Vardag, Huset eller en resa. Ni kan ha hur många ni vill.</string>
|
||||||
|
<string name="home_sync_status_sync_now">Synka nu</string>
|
||||||
|
<string name="home_sync_status_title">Synkstatus</string>
|
||||||
|
<string name="home_sync_status_all_synced">Allt är synkat</string>
|
||||||
|
<!-- followed by the sync line -->
|
||||||
|
<string name="home_sync_status_all_synced_body">Alla ändringar finns på servern.</string>
|
||||||
|
<!-- %1$s time or list date and time -->
|
||||||
|
<string name="home_sync_status_last_success">Senast lyckade synk %1$s</string>
|
||||||
|
<string name="home_sync_status_pending_title">Väntande ändringar (%d)</string>
|
||||||
|
<!-- pending row: home_sync_status_row_pending(tag) -->
|
||||||
|
<string name="home_sync_status_tag_new">Ny</string>
|
||||||
|
<string name="home_sync_status_tag_changed">Ändrad</string>
|
||||||
|
<string name="home_sync_status_tag_deleted">Borttagen</string>
|
||||||
|
<!-- %1$s a home_sync_status_tag_* -->
|
||||||
|
<string name="home_sync_status_row_pending">%1$s · väntar på synk</string>
|
||||||
|
<string name="home_sync_status_rejected_title">Ändringar som inte synkades</string>
|
||||||
|
<string name="home_sync_status_dismiss">Ta bort från listan</string>
|
||||||
|
<!-- opens the rejected change read-only -->
|
||||||
|
<string name="home_sync_status_open">Öppna</string>
|
||||||
|
<!-- §14.4.6 -->
|
||||||
|
<string name="home_sync_reject_stale">Din ändring ersattes av en nyare ändring från %1$s</string>
|
||||||
|
<!-- sv genitive as onboarding_profile_joining -->
|
||||||
|
<string name="home_sync_reject_stale_help">%1$ss version gäller. Öppna utgiften om du vill göra din ändring igen.</string>
|
||||||
|
<!-- as home_sync_reject_stale_help -->
|
||||||
|
<string name="home_sync_reject_stale_help_payment">%1$ss version gäller. Öppna betalningen om du vill göra din ändring igen.</string>
|
||||||
|
<string name="home_sync_reject_deleted">Den här togs bort innan din ändring kom fram</string>
|
||||||
|
<string name="home_sync_reject_validation">Ändringen var inte giltig och sparades inte</string>
|
||||||
|
<string name="home_sync_reject_reference">Dess konto eller kategori finns inte längre</string>
|
||||||
|
<string name="home_sync_reject_clock_skew">Telefonens klocka går fel. Rätta den och synka igen</string>
|
||||||
|
<!-- §9.10 -->
|
||||||
|
<string name="home_sync_status_clock_warning">Telefonens klocka går fel. Rätta den och synka igen.</string>
|
||||||
|
<!-- §14.4.6 clock_skew -->
|
||||||
|
<string name="home_sync_status_clock_body">Klockan ligger mer än 10 minuter före servern. Dina ändringar sparas på telefonen tills dess.</string>
|
||||||
|
<!-- opens the system date settings -->
|
||||||
|
<string name="home_sync_status_open_date_settings">Datum och tid</string>
|
||||||
|
<string name="home_sync_status_last_attempt_clock">Senaste försöket nekades: klockan</string>
|
||||||
|
<!-- PROPOSED (not in strings.csv): TalkBack state of the "Archived (n)" row -->
|
||||||
|
<string name="home_archived_expanded">utfälld</string>
|
||||||
|
<!-- PROPOSED (not in strings.csv): TalkBack state of the "Archived (n)" row -->
|
||||||
|
<string name="home_archived_collapsed">hopfälld</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Strings of :feature:home, from docs/ux/strings.csv (module=home). -->
|
||||||
|
<resources>
|
||||||
|
<!-- shown on Home before the system permission prompt, after the first full sync (AC-2.9, ia.md §3) -->
|
||||||
|
<string name="home_notification_rationale">Kvitt tells you when %1$s adds or changes something.</string>
|
||||||
|
<string name="home_accounts">Accounts</string>
|
||||||
|
<string name="home_new_account">New account</string>
|
||||||
|
<string name="home_archived_section">Archived (%d)</string>
|
||||||
|
<string name="home_settle_up">Settle up</string>
|
||||||
|
<!-- plural one; %2$s month name (lowercase in sv) -->
|
||||||
|
<plurals name="home_account_expenses_month">
|
||||||
|
<item quantity="one">%1$d expense in %2$s</item>
|
||||||
|
<item quantity="other">%1$d expenses in %2$s</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="home_account_no_expenses_month">No expenses in %1$s</string>
|
||||||
|
<!-- first sync only -->
|
||||||
|
<string name="home_loading">Getting your expenses…</string>
|
||||||
|
<!-- first sync only -->
|
||||||
|
<string name="home_loading_body">The first sync fetches everything on the server. It only takes a moment.</string>
|
||||||
|
<!-- %1$s partner name; S-4 empty state card -->
|
||||||
|
<string name="home_empty_welcome">You and %1$s are in</string>
|
||||||
|
<string name="home_empty_subtitle">The household is ready. Now you need an account.</string>
|
||||||
|
<string name="home_empty_title">Create your first account</string>
|
||||||
|
<string name="home_empty_body">An account collects expenses that you share the same way, for example Everyday, The house or a trip. You can have as many as you like.</string>
|
||||||
|
<string name="home_sync_status_sync_now">Sync now</string>
|
||||||
|
<string name="home_sync_status_title">Sync status</string>
|
||||||
|
<string name="home_sync_status_all_synced">Everything is synced</string>
|
||||||
|
<!-- followed by the sync line -->
|
||||||
|
<string name="home_sync_status_all_synced_body">All changes are on the server.</string>
|
||||||
|
<!-- %1$s time or list date and time -->
|
||||||
|
<string name="home_sync_status_last_success">Last successful sync %1$s</string>
|
||||||
|
<string name="home_sync_status_pending_title">Pending changes (%d)</string>
|
||||||
|
<!-- pending row: home_sync_status_row_pending(tag) -->
|
||||||
|
<string name="home_sync_status_tag_new">New</string>
|
||||||
|
<string name="home_sync_status_tag_changed">Changed</string>
|
||||||
|
<string name="home_sync_status_tag_deleted">Deleted</string>
|
||||||
|
<!-- %1$s a home_sync_status_tag_* -->
|
||||||
|
<string name="home_sync_status_row_pending">%1$s · waiting to sync</string>
|
||||||
|
<string name="home_sync_status_rejected_title">Changes that were not synced</string>
|
||||||
|
<string name="home_sync_status_dismiss">Remove from list</string>
|
||||||
|
<!-- opens the rejected change read-only -->
|
||||||
|
<string name="home_sync_status_open">Open</string>
|
||||||
|
<!-- §14.4.6 -->
|
||||||
|
<string name="home_sync_reject_stale">Your change was replaced by a newer change from %1$s</string>
|
||||||
|
<!-- sv genitive as onboarding_profile_joining -->
|
||||||
|
<string name="home_sync_reject_stale_help">%1$s\'s version applies. Open the expense if you want to make your change again.</string>
|
||||||
|
<!-- as home_sync_reject_stale_help -->
|
||||||
|
<string name="home_sync_reject_stale_help_payment">%1$s\'s version applies. Open the payment if you want to make your change again.</string>
|
||||||
|
<string name="home_sync_reject_deleted">This was deleted before your change arrived</string>
|
||||||
|
<string name="home_sync_reject_validation">This change was not valid and was not saved</string>
|
||||||
|
<string name="home_sync_reject_reference">Its account or category no longer exists</string>
|
||||||
|
<string name="home_sync_reject_clock_skew">Your phone\'s clock is wrong. Fix it and sync again</string>
|
||||||
|
<!-- §9.10 -->
|
||||||
|
<string name="home_sync_status_clock_warning">Your phone\'s clock is wrong. Fix it and sync again.</string>
|
||||||
|
<!-- §14.4.6 clock_skew -->
|
||||||
|
<string name="home_sync_status_clock_body">The clock is more than 10 minutes ahead of the server. Your changes stay on the phone until it is fixed.</string>
|
||||||
|
<!-- opens the system date settings -->
|
||||||
|
<string name="home_sync_status_open_date_settings">Date & time</string>
|
||||||
|
<string name="home_sync_status_last_attempt_clock">Last attempt refused: the clock</string>
|
||||||
|
<!-- PROPOSED (not in strings.csv): TalkBack state of the "Archived (n)" row -->
|
||||||
|
<string name="home_archived_expanded">expanded</string>
|
||||||
|
<!-- PROPOSED (not in strings.csv): TalkBack state of the "Archived (n)" row -->
|
||||||
|
<string name="home_archived_collapsed">collapsed</string>
|
||||||
|
</resources>
|
||||||
+100
@@ -0,0 +1,100 @@
|
|||||||
|
package se.hagstedt.kvitt.feature.home
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.ParameterizedRobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
import org.robolectric.annotation.GraphicsMode
|
||||||
|
import se.hagstedt.kvitt.fakes.FakeRepositories
|
||||||
|
import se.hagstedt.kvitt.fakes.FakeScenario
|
||||||
|
import se.hagstedt.kvitt.fakes.home.HomeScenarios
|
||||||
|
|
||||||
|
/** One state of a screen: its golden name, whether a 200 % capture is taken, and its content. */
|
||||||
|
class ScreenState(val name: String, val largeFont: Boolean = false, val content: @Composable () -> Unit)
|
||||||
|
|
||||||
|
/** Home with [scenario] after [act] on its ViewModel. */
|
||||||
|
fun homeState(scenario: FakeScenario, act: (HomeViewModel) -> Unit = {}): HomeUi = withMain {
|
||||||
|
val fakes = FakeRepositories(scenario)
|
||||||
|
val vm = HomeViewModel(fakes.home, fakes.connectivity, fakes.update, fakes.categories)
|
||||||
|
act(vm)
|
||||||
|
vm.ui.value
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sync status with [scenario]; [failed] stands in for the missing `SyncSummary` field (see the M7b report). */
|
||||||
|
fun syncState(scenario: FakeScenario, failed: Boolean = false): SyncStatusUi = withMain {
|
||||||
|
val fakes = FakeRepositories(scenario)
|
||||||
|
val ui = SyncStatusViewModel(fakes.sync, fakes.connectivity, fakes.session).ui.value
|
||||||
|
if (failed && ui is SyncStatusUi.Ready) ui.copy(failed = true) else ui
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every state of S-4, S-16 and S-17 (design-spec §4 and PLAN-M7b §5) × light/dark × sv/en. */
|
||||||
|
val homeStates: List<ScreenState> = listOf(
|
||||||
|
home("S-4-home", HomeScenarios.household),
|
||||||
|
home("S-4-home-owes-you", HomeScenarios.owesYou),
|
||||||
|
home("S-4-home-settled", HomeScenarios.settled) { it.toggleArchived() },
|
||||||
|
home("S-4-home-pending", HomeScenarios.pending),
|
||||||
|
home("S-4-home-rejected", HomeScenarios.rejected),
|
||||||
|
home("S-4-home-clock", HomeScenarios.clockSkew),
|
||||||
|
home("S-4-home-syncing", HomeScenarios.syncing),
|
||||||
|
home("S-4-home-empty", HomeScenarios.empty),
|
||||||
|
home("S-4-home-loading", HomeScenarios.loading),
|
||||||
|
home("S-4-home-offline", HomeScenarios.offline),
|
||||||
|
home("S-4-home-only-archived", HomeScenarios.onlyArchived),
|
||||||
|
home("S-4-home-long", HomeScenarios.long, largeFont = true),
|
||||||
|
home("S-17-update-banner", HomeScenarios.updateAvailable),
|
||||||
|
home("S-17-update-permission", HomeScenarios.updateNeedsPermission),
|
||||||
|
home("S-17-update", HomeScenarios.updateNeedsPermission) { it.onUpdate() },
|
||||||
|
home("S-17-update-progress", HomeScenarios.updateDownloading),
|
||||||
|
home("S-17-update-failed", HomeScenarios.updateFailed),
|
||||||
|
sync("S-16-sync", HomeScenarios.syncLoaded),
|
||||||
|
sync("S-16-sync-empty", HomeScenarios.household),
|
||||||
|
sync("S-16-sync-rejections", HomeScenarios.allRejections),
|
||||||
|
sync("S-16-sync-clock", HomeScenarios.clockSkew),
|
||||||
|
sync("S-16-sync-running", HomeScenarios.syncing),
|
||||||
|
sync("S-16-sync-error", HomeScenarios.pending, failed = true),
|
||||||
|
sync("S-16-sync-offline", HomeScenarios.syncOffline),
|
||||||
|
sync("S-16-sync-long", HomeScenarios.longSync, largeFont = true),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun home(name: String, scenario: FakeScenario, largeFont: Boolean = false, act: (HomeViewModel) -> Unit = {}): ScreenState {
|
||||||
|
val ui = homeState(scenario, act)
|
||||||
|
return ScreenState(name, largeFont) { HomeContent(ui, HomeActions()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sync(name: String, scenario: FakeScenario, largeFont: Boolean = false, failed: Boolean = false): ScreenState {
|
||||||
|
val ui = syncState(scenario, failed)
|
||||||
|
return ScreenState(name, largeFont) { SyncStatusContent(ui, SyncStatusActions()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Goldens of every Home, sync status and update state. `check` verifies them; re-record an intended change with
|
||||||
|
* `tools/gradle-serial.sh :feature:home:recordRoborazziDebug` and open every changed PNG.
|
||||||
|
*/
|
||||||
|
@RunWith(ParameterizedRobolectricTestRunner::class)
|
||||||
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
|
@Config(sdk = [36], qualifiers = ScreenSnapshots.QUALIFIERS)
|
||||||
|
class HomeScreenshotTest(private val state: String) {
|
||||||
|
@get:Rule
|
||||||
|
val compose = createComposeRule()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun screen() {
|
||||||
|
val screen = homeStates.first { it.name == state }
|
||||||
|
ScreenSnapshots.snap(compose, screen.name, screen.largeFont, screen.content)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@JvmStatic
|
||||||
|
@ParameterizedRobolectricTestRunner.Parameters(name = "{0}")
|
||||||
|
fun states(): List<Array<Any>> = listOf(
|
||||||
|
"S-4-home", "S-4-home-owes-you", "S-4-home-settled", "S-4-home-pending", "S-4-home-rejected", "S-4-home-clock",
|
||||||
|
"S-4-home-syncing", "S-4-home-empty", "S-4-home-loading", "S-4-home-offline", "S-4-home-only-archived", "S-4-home-long",
|
||||||
|
"S-17-update-banner", "S-17-update-permission", "S-17-update", "S-17-update-progress", "S-17-update-failed",
|
||||||
|
"S-16-sync", "S-16-sync-empty", "S-16-sync-rejections", "S-16-sync-clock", "S-16-sync-running", "S-16-sync-error",
|
||||||
|
"S-16-sync-offline", "S-16-sync-long",
|
||||||
|
).map { arrayOf<Any>(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
+165
@@ -0,0 +1,165 @@
|
|||||||
|
package se.hagstedt.kvitt.feature.home
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import se.hagstedt.kvitt.fakes.FakeRepositories
|
||||||
|
import se.hagstedt.kvitt.fakes.FakeScenario
|
||||||
|
import se.hagstedt.kvitt.fakes.home.HomeScenarios
|
||||||
|
import se.hagstedt.kvitt.model.DefaultCategoryKey
|
||||||
|
import se.hagstedt.kvitt.model.SyncRejectionReason
|
||||||
|
import se.hagstedt.kvitt.model.UpdateRepository
|
||||||
|
import se.hagstedt.kvitt.model.UpdateState
|
||||||
|
|
||||||
|
/** Records `install()` calls; the update state is set by the test (no mocking library, CLAUDE.md). */
|
||||||
|
private class RecordingUpdates(initial: UpdateState) : UpdateRepository {
|
||||||
|
val flow = MutableStateFlow(initial)
|
||||||
|
var installs = 0
|
||||||
|
override val state: Flow<UpdateState> = flow
|
||||||
|
override suspend fun check() = Unit
|
||||||
|
override suspend fun install() {
|
||||||
|
installs += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class HomeViewModelTest {
|
||||||
|
private fun ready(scenario: FakeScenario, act: (HomeViewModel) -> Unit = {}) = homeState(scenario, act) as HomeUi.Ready
|
||||||
|
|
||||||
|
private fun vm(scenario: FakeScenario, updates: UpdateRepository): HomeViewModel = withMain {
|
||||||
|
val fakes = FakeRepositories(scenario)
|
||||||
|
HomeViewModel(fakes.home, fakes.connectivity, updates, fakes.categories)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun householdShowsBalanceAccountsAndFab() {
|
||||||
|
val ui = ready(HomeScenarios.household)
|
||||||
|
assertEquals(-325_000L, ui.home.global?.netOre)
|
||||||
|
assertEquals(listOf("Vardag", "Huset", "Japan 2027"), ui.home.active.map { it.account.name })
|
||||||
|
assertEquals(listOf("Gotland 2026"), ui.home.archived.map { it.account.name })
|
||||||
|
assertTrue(ui.showFab)
|
||||||
|
assertTrue(ui.canSettle)
|
||||||
|
assertFalse(ui.empty)
|
||||||
|
assertFalse(ui.loading)
|
||||||
|
assertFalse(ui.archivedExpanded)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun colourSlotsFollowCreatedOrderWithArchivedIncluded() {
|
||||||
|
val ui = ready(HomeScenarios.household)
|
||||||
|
val index = ui.colorIndex
|
||||||
|
assertEquals(listOf(0, 1, 2, 3), listOf(HomeScenarios.vardag, HomeScenarios.huset, HomeScenarios.japan, HomeScenarios.gotland).map { index[it.id.value] })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun settledHasNoSettleButton() {
|
||||||
|
val ui = ready(HomeScenarios.settled)
|
||||||
|
assertEquals(0L, ui.home.global?.netOre)
|
||||||
|
assertFalse(ui.canSettle)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun archivedSectionToggles() {
|
||||||
|
assertTrue(ready(HomeScenarios.household) { it.toggleArchived() }.archivedExpanded)
|
||||||
|
assertFalse(ready(HomeScenarios.household) { it.toggleArchived(); it.toggleArchived() }.archivedExpanded)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun emptyAndOnlyArchivedHideTheFab() {
|
||||||
|
val empty = ready(HomeScenarios.empty)
|
||||||
|
assertTrue(empty.empty)
|
||||||
|
assertFalse(empty.showFab)
|
||||||
|
assertEquals(listOf(DefaultCategoryKey.GROCERIES, DefaultCategoryKey.TRAVEL, DefaultCategoryKey.RESTAURANTS), empty.illustration.map { it.defaultKey })
|
||||||
|
val archivedOnly = ready(HomeScenarios.onlyArchived)
|
||||||
|
assertFalse(archivedOnly.empty)
|
||||||
|
assertFalse(archivedOnly.showFab)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun firstSyncIsLoading() {
|
||||||
|
val ui = ready(HomeScenarios.loading)
|
||||||
|
assertTrue(ui.loading)
|
||||||
|
assertFalse(ui.empty)
|
||||||
|
assertFalse(ui.showFab)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun offlineAndPendingAreReported() {
|
||||||
|
val ui = ready(HomeScenarios.offline)
|
||||||
|
assertFalse(ui.online)
|
||||||
|
assertEquals(2, ui.home.sync.pendingCount)
|
||||||
|
assertTrue(ui.update is UpdateState.Available)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun laterHidesTheBanner() {
|
||||||
|
assertEquals(UpdateState.None, ready(HomeScenarios.updateAvailable) { it.onLater() }.update)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun firstUpdateOpensTheDialogAndContinueInstalls() {
|
||||||
|
val updates = RecordingUpdates(UpdateState.NeedsInstallPermission(HomeScenarios.update))
|
||||||
|
val vm = vm(HomeScenarios.household, updates)
|
||||||
|
withMain { vm.onUpdate() }
|
||||||
|
assertTrue((vm.ui.value as HomeUi.Ready).installDialog)
|
||||||
|
assertEquals(0, updates.installs)
|
||||||
|
withMain { vm.onInstallDialogContinue() }
|
||||||
|
assertFalse((vm.ui.value as HomeUi.Ready).installDialog)
|
||||||
|
assertEquals(1, updates.installs)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun updateAndRetryInstallDirectly() {
|
||||||
|
val updates = RecordingUpdates(UpdateState.Available(HomeScenarios.update))
|
||||||
|
val vm = vm(HomeScenarios.household, updates)
|
||||||
|
withMain { vm.onUpdate() }
|
||||||
|
assertFalse((vm.ui.value as HomeUi.Ready).installDialog)
|
||||||
|
withMain { vm.onRetry() }
|
||||||
|
assertEquals(2, updates.installs)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun dialogDismissKeepsTheBanner() {
|
||||||
|
val ui = ready(HomeScenarios.updateNeedsPermission) { it.onUpdate(); it.onInstallDialogDismiss() }
|
||||||
|
assertFalse(ui.installDialog)
|
||||||
|
assertTrue(ui.update is UpdateState.NeedsInstallPermission)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SyncStatusViewModelTest {
|
||||||
|
@Test
|
||||||
|
fun syncedHouseholdIsAllSynced() {
|
||||||
|
val ui = syncState(HomeScenarios.household) as SyncStatusUi.Ready
|
||||||
|
assertTrue(ui.allSynced)
|
||||||
|
assertTrue(ui.canSyncNow)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun pendingAndRejectionsAreListed() {
|
||||||
|
val ui = syncState(HomeScenarios.allRejections) as SyncStatusUi.Ready
|
||||||
|
assertFalse(ui.allSynced)
|
||||||
|
assertEquals(2, ui.status.pending.size)
|
||||||
|
assertEquals(SyncRejectionReason.entries.toSet(), ui.status.rejections.map { it.reason }.toSet())
|
||||||
|
assertEquals("Anna", ui.partner?.displayName)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun syncNowIsDisabledOfflineAndWhileRunning() {
|
||||||
|
assertFalse((syncState(HomeScenarios.syncOffline) as SyncStatusUi.Ready).canSyncNow)
|
||||||
|
assertFalse((syncState(HomeScenarios.syncing) as SyncStatusUi.Ready).canSyncNow)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun syncNowClearsPendingAndDismissRemovesARejection() {
|
||||||
|
val fakes = FakeRepositories(HomeScenarios.syncLoaded)
|
||||||
|
val vm = withMain { SyncStatusViewModel(fakes.sync, fakes.connectivity, fakes.session) }
|
||||||
|
withMain { vm.syncNow() }
|
||||||
|
val synced = vm.ui.value as SyncStatusUi.Ready
|
||||||
|
assertTrue(synced.status.pending.isEmpty())
|
||||||
|
assertEquals(1, synced.status.rejections.size)
|
||||||
|
withMain { vm.dismiss(HomeScenarios.staleRejection.mutationId) }
|
||||||
|
assertTrue((vm.ui.value as SyncStatusUi.Ready).status.rejections.isEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
+92
@@ -0,0 +1,92 @@
|
|||||||
|
package se.hagstedt.kvitt.feature.home
|
||||||
|
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import android.os.LocaleList
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalResources
|
||||||
|
import androidx.compose.ui.test.junit4.ComposeContentTestRule
|
||||||
|
import androidx.compose.ui.unit.Density
|
||||||
|
import com.github.takahirom.roborazzi.ExperimentalRoborazziApi
|
||||||
|
import com.github.takahirom.roborazzi.captureScreenRoboImage
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.resetMain
|
||||||
|
import kotlinx.coroutines.test.setMain
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import se.hagstedt.kvitt.design.KvittTheme
|
||||||
|
import se.hagstedt.kvitt.design.component.KvittClock
|
||||||
|
import se.hagstedt.kvitt.design.component.LocalKvittClock
|
||||||
|
import java.util.Locale
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-screen goldens of this module's screens, as M6's component snapshots but for whole screens (dialogs and menus
|
||||||
|
* included): `src/test/screenshots/<state>_<sv|en>_<light|dark>.png`, plus `<state>_sv_light_200.png` at 200 % font
|
||||||
|
* scale for the long states. The screen is the Galaxy S26 (411 × 891 dp), at xhdpi to keep the goldens small. The
|
||||||
|
* clock is fixed at 17 October 2026 14:02 in Stockholm; the frame clock is advanced by hand so animations and
|
||||||
|
* snackbars render the same every run.
|
||||||
|
*/
|
||||||
|
object ScreenSnapshots {
|
||||||
|
const val QUALIFIERS = "w411dp-h891dp-xhdpi"
|
||||||
|
val clock = KvittClock.fixed(Instant.parse("2026-10-17T12:02:00Z"), TimeZone.of("Europe/Stockholm"))
|
||||||
|
private const val SETTLE_MILLIS = 1_000L
|
||||||
|
|
||||||
|
private data class Variant(val language: String, val dark: Boolean, val fontScale: Float) {
|
||||||
|
val suffix: String get() = "${language}_${if (dark) "dark" else "light"}" + if (fontScale > 1f) "_200" else ""
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalRoborazziApi::class)
|
||||||
|
fun snap(compose: ComposeContentTestRule, name: String, largeFont: Boolean, content: @Composable () -> Unit) {
|
||||||
|
val variants = listOf("sv", "en").flatMap { l -> listOf(false, true).map { Variant(l, it, 1f) } } +
|
||||||
|
if (largeFont) listOf(Variant("sv", dark = false, fontScale = 2f)) else emptyList()
|
||||||
|
var current by mutableStateOf(variants.first())
|
||||||
|
compose.mainClock.autoAdvance = false
|
||||||
|
compose.setContent {
|
||||||
|
val variant = current
|
||||||
|
val base = LocalContext.current
|
||||||
|
val baseConfiguration = LocalConfiguration.current
|
||||||
|
val context = remember(variant) {
|
||||||
|
val config = Configuration(baseConfiguration).apply {
|
||||||
|
setLocales(LocaleList(Locale.forLanguageTag(if (variant.language == "sv") "sv-SE" else "en-GB")))
|
||||||
|
fontScale = variant.fontScale
|
||||||
|
uiMode = (uiMode and Configuration.UI_MODE_NIGHT_MASK.inv()) or if (variant.dark) Configuration.UI_MODE_NIGHT_YES else Configuration.UI_MODE_NIGHT_NO
|
||||||
|
}
|
||||||
|
base.createConfigurationContext(config)
|
||||||
|
}
|
||||||
|
CompositionLocalProvider(
|
||||||
|
LocalContext provides context,
|
||||||
|
LocalConfiguration provides context.resources.configuration,
|
||||||
|
LocalResources provides context.resources,
|
||||||
|
LocalDensity provides Density(context),
|
||||||
|
LocalKvittClock provides clock,
|
||||||
|
) {
|
||||||
|
KvittTheme(darkTheme = variant.dark, content = content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
variants.forEach { variant ->
|
||||||
|
current = variant
|
||||||
|
compose.mainClock.advanceTimeBy(SETTLE_MILLIS)
|
||||||
|
captureScreenRoboImage("src/test/screenshots/${name}_${variant.suffix}.png")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs [block] with `Dispatchers.Main` unconfined, so a ViewModel's state is ready when it returns. */
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
fun <T> withMain(block: () -> T): T {
|
||||||
|
Dispatchers.setMain(UnconfinedTestDispatcher())
|
||||||
|
try {
|
||||||
|
return block()
|
||||||
|
} finally {
|
||||||
|
Dispatchers.resetMain()
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user