diff --git a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/settle/SettleScenarios.kt b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/settle/SettleScenarios.kt new file mode 100644 index 0000000..36e0e3c --- /dev/null +++ b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/settle/SettleScenarios.kt @@ -0,0 +1,191 @@ +package se.hagstedt.kvitt.fakes.settle + +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.AuditAction +import se.hagstedt.kvitt.model.AuditSource +import se.hagstedt.kvitt.model.ExpenseId +import se.hagstedt.kvitt.model.FieldChange +import se.hagstedt.kvitt.model.SettlementId +import se.hagstedt.kvitt.model.Share +import se.hagstedt.kvitt.model.SplitMode +import se.hagstedt.kvitt.model.TimelineLine +import se.hagstedt.kvitt.model.User +import se.hagstedt.kvitt.model.UserId + +/** + * Scenarios of `:feature:settle` (S-9 settle up, S-10 settlement detail; PLAN-M7d §5). Every balance comes from the + * scenario's expenses and settlements through the fakes' §9.3 rules, so the proposals are the real §9.4 ones. + * + * The base household (the design-spec frames): Jacob (the viewer) owes 3 084,50 kr in Huset and 1 350,00 kr in + * Japan 2027; Anna owes 1 184,50 kr in Vardag; the archived account Stugan is settled. Globally Jacob owes 3 250,00 kr. + */ +object SettleScenarios { + private val jacob: User = FakeScenarios.owner + private val anna: User = FakeScenarios.partner + + val vardag = account(0xb1, "Vardag", "2025-01-10T08:00:00.000Z") + val huset = account(0xb2, "Huset", "2025-02-01T08:00:00.000Z") + val japan = account(0xb3, "Japan 2027", "2026-03-15T08:00:00.000Z") + val stugan = account(0xb4, "Stugan", "2024-06-01T08:00:00.000Z", archived = true) + + /** Anna → Jacob 800,00 kr on 6 October, all to Vardag, note "Swish", created by Anna (frame S-10-settlement). */ + val swish = SettlementId(id(0xc1)) + + /** Jacob → Anna 2 500,00 kr over Huset and Japan 2027, no note, created, changed, and a pending local change. */ + val split = SettlementId(id(0xc2)) + + /** Anna → Jacob 450,00 kr, deleted by Anna on 17 October (frame S-10-settlement-deleted). */ + val deleted = SettlementId(id(0xc3)) + + /** Jacob → Anna 1 234 567,89 kr over six long-named accounts (long content). */ + val long = SettlementId(id(0xc4)) + + /** The settlement id that no scenario holds (the missing state). */ + val missing = SettlementId(id(0xcf)) + + /** S-9 global, loaded: the proposal Huset 3 084,50 + Japan 2027 165,50 and the info line about Vardag. */ + val proposal: FakeScenario = FakeScenarios.empty.copy( + accounts = listOf(stugan, vardag, huset, japan), + expenses = listOf( + expense(0xe1, huset, anna, 616_900, "2026-10-02"), + expense(0xe2, japan, anna, 270_000, "2026-10-05"), + expense(0xe3, vardag, jacob, 236_900, "2026-10-09"), + ), + lastSuccessAt = "2026-10-17T11:59:00.000Z", + today = "2026-10-17", + now = "2026-10-17T12:02:00.000Z", + ) + + /** S-9 global with a zero balance: the amount is empty and no rows are proposed. */ + val settledUp: FakeScenario = proposal.copy( + expenses = proposal.expenses + listOf(expense(0xe4, vardag, jacob, 650_000, "2026-10-12")), + ) + + /** S-9 with the partner owing globally: the direction defaults to "Anna betalar dig". */ + val partnerOwes: FakeScenario = proposal.copy( + expenses = listOf( + expense(0xe1, huset, jacob, 616_900, "2026-10-02"), + expense(0xe2, japan, jacob, 270_000, "2026-10-05"), + expense(0xe3, vardag, anna, 236_900, "2026-10-09"), + ), + ) + + /** S-9 offline with pending changes: settling up works unchanged (AC-11.6). */ + val offline: FakeScenario = proposal.copy( + online = false, + expenses = proposal.expenses.map { if (it.id.value == id(0xe3)) it.copy(pending = true) else it }, + lastSuccessAt = "2026-10-17T09:30:00.000Z", + ) + + /** S-10 and S-9 editing: the four settlements above on top of [proposal]. */ + val detail: FakeScenario = proposal.copy( + settlements = listOf( + FakeSettlement( + id = swish, from = anna.id, to = jacob.id, amountOre = 80_000, date = "2026-10-06", note = "Swish", + allocations = mapOf(vardag.id to 80_000L), createdAt = "2026-10-06T19:15:00.000Z", + timeline = listOf(TimelineLine(anna, AuditAction.CREATE, AuditSource.APP, "2026-10-06T19:15:00.000Z", emptyList(), pendingLocal = false)), + ), + FakeSettlement( + id = split, from = jacob.id, to = anna.id, amountOre = 250_000, date = "2026-10-14", note = "", + allocations = linkedMapOf(huset.id to 200_000L, japan.id to 50_000L), createdAt = "2026-10-14T07:40:00.000Z", + pending = true, + timeline = listOf( + TimelineLine(jacob, AuditAction.CREATE, AuditSource.APP, "2026-10-14T07:40:00.000Z", emptyList(), pendingLocal = false), + TimelineLine( + anna, AuditAction.UPDATE, AuditSource.APP, "2026-10-15T16:05:00.000Z", + listOf(FieldChange("amount_ore", "200000", "250000"), FieldChange("settlement_date", "2026-10-13", "2026-10-14")), + pendingLocal = false, + ), + ), + ), + FakeSettlement( + id = deleted, from = anna.id, to = jacob.id, amountOre = 45_000, date = "2026-10-11", note = "Swish för hyran", + allocations = mapOf(vardag.id to 45_000L), createdAt = "2026-10-11T10:20:00.000Z", deleted = true, + timeline = listOf( + TimelineLine(anna, AuditAction.CREATE, AuditSource.APP, "2026-10-11T10:20:00.000Z", emptyList(), pendingLocal = false), + TimelineLine(anna, AuditAction.DELETE, AuditSource.APP, "2026-10-17T07:12:00.000Z", emptyList(), pendingLocal = false), + ), + ), + ), + ) + + /** S-10 offline: the detail of [split] with the phone offline. */ + val detailOffline: FakeScenario = detail.copy(online = false, lastSuccessAt = "2026-10-17T09:30:00.000Z") + + private val longAccounts: List = listOf( + account(0xd1, "Sommarhuset i Rättvik med bryggbygget", "2025-01-02T08:00:00.000Z"), + account(0xd2, "Gemensamma hushållsutgifter och matinköp", "2025-01-03T08:00:00.000Z"), + account(0xd3, "Bilen, försäkringarna och parkeringstillstånden", "2025-01-04T08:00:00.000Z"), + account(0xd4, "Renoveringen av badrummet på övervåningen", "2025-01-05T08:00:00.000Z"), + account(0xd5, "Bröllopsresan till Nya Zeeland och Australien", "2025-01-06T08:00:00.000Z"), + account(0xd6, "Barnens fritidsaktiviteter och utrustning", "2025-01-07T08:00:00.000Z"), + ) + + /** Long content (S-9): six long-named accounts where Jacob owes 7-digit amounts (1 235 677,89 kr globally). */ + val longContent: FakeScenario = FakeScenarios.empty.copy( + accounts = longAccounts, + expenses = listOf( + expense(0xf1, longAccounts[0], anna, 104_691_356, "2026-10-01"), + expense(0xf2, longAccounts[1], anna, 82_469_134, "2026-10-02"), + expense(0xf3, longAccounts[2], anna, 37_530_864, "2026-10-03"), + expense(0xf4, longAccounts[3], anna, 24_691_356, "2026-10-04"), + expense(0xf5, longAccounts[4], anna, 6_913_578, "2026-10-05"), + expense(0xf6, longAccounts[5], jacob, 9_160_710, "2026-10-06"), + ), + lastSuccessAt = "2026-10-17T11:59:00.000Z", + today = "2026-10-17", + now = "2026-10-17T12:02:00.000Z", + ) + + /** Long content (S-10): Jacob → Anna 1 234 567,89 kr over the six long-named accounts, with a long note. */ + val longDetail: FakeScenario = longContent.copy( + settlements = listOf( + FakeSettlement( + id = long, from = jacob.id, to = anna.id, amountOre = 123_456_789, date = "2026-09-30", + note = "Överföring från sparkontot inför flytten, enligt överenskommelsen vi gjorde på midsommar", + allocations = linkedMapOf( + longAccounts[0].id to 50_000_000L, + longAccounts[1].id to 40_000_000L, + longAccounts[2].id to 15_000_000L, + longAccounts[3].id to 10_000_000L, + longAccounts[4].id to 5_000_000L, + longAccounts[5].id to 3_456_789L, + ), + createdAt = "2026-09-30T18:00:00.000Z", + timeline = listOf(TimelineLine(jacob, AuditAction.CREATE, AuditSource.APP, "2026-09-30T18:00:00.000Z", emptyList(), pendingLocal = false)), + ), + ), + ) + + /** The long scenario's accounts in created order. */ + val longAccountIds: List = longAccounts.map { it.id } + + private fun id(suffix: Int): String = "01920000-0000-7000-8000-" + suffix.toString(16).padStart(12, '0') + + private fun account(suffix: Int, name: String, createdAt: String, archived: Boolean = false): Account = + Account(AccountId(id(suffix)), name, mapOf(jacob.id to 5000, anna.id to 5000), createdAt, archived) + + /** An expense split 50/50 that [payer] paid: the other person owes half of it in [account]. */ + private fun expense(suffix: Int, account: Account, payer: User, amountOre: Long, date: String): FakeExpense { + val half = amountOre / 2 + val shares = listOf(jacob.id, anna.id).map { user: UserId -> Share(user, if (user == payer.id) amountOre - half else half, 5000) } + return FakeExpense( + id = ExpenseId(id(suffix)), + accountId = account.id, + categoryId = FakeScenarios.defaultCategories.first().id, + amountOre = amountOre, + description = "Inköp", + notes = "", + date = date, + payer = payer.id, + mode = SplitMode.DEFAULT, + shares = shares, + createdAt = "${date}T10:00:00.000Z", + ) + } +} diff --git a/android/feature/settle/build.gradle.kts b/android/feature/settle/build.gradle.kts index 44f373a..381601c 100644 --- a/android/feature/settle/build.gradle.kts +++ b/android/feature/settle/build.gradle.kts @@ -1,4 +1,23 @@ // :feature:settle — Settle up and settlement detail (S-9, S-10; M7d). Convention: android/README.md. +// Depends on :core:model and :core:design (added by the feature plugin); :core:fakes only for debug and tests. plugins { id("kvitt.android.feature") + alias(libs.plugins.roborazzi) +} + +roborazzi { + outputDir.set(file("src/test/screenshots")) +} + +dependencies { + // BackHandler: system back with unsaved changes asks "Slänga ändringarna?" (design-spec S-9). + implementation(libs.androidx.activity.compose) + + debugImplementation(libs.androidx.compose.ui.test.manifest) + 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) } diff --git a/android/feature/settle/src/main/kotlin/se/hagstedt/kvitt/feature/settle/AmountText.kt b/android/feature/settle/src/main/kotlin/se/hagstedt/kvitt/feature/settle/AmountText.kt new file mode 100644 index 0000000..5bbcde7 --- /dev/null +++ b/android/feature/settle/src/main/kotlin/se/hagstedt/kvitt/feature/settle/AmountText.kt @@ -0,0 +1,55 @@ +package se.hagstedt.kvitt.feature.settle + +import se.hagstedt.kvitt.design.format.AmountInput +import se.hagstedt.kvitt.design.format.KvittLanguage + +/** + * The text-field side of the §9.6 amount rules for S-9's amount field and allocation fields (system decimal keyboard, + * design-spec S-9 and §3.8). Fields show [AmountInput.display]; every edit comes back through [parseAmountText]. + */ +object AmountText { + /** Digits before the decimal point that can still be within 10 000 000,00 kr. */ + private const val MAX_WHOLE_DIGITS = 8 + + private const val ORE_PER_KRONA = 100L + + /** + * The typed [text] as an [AmountInput], or null when the edit breaks a §9.6 input rule (a sign or letter, a second + * decimal separator, a third decimal, or more than [AmountInput.MAX_AMOUNT_ORE]); the field then keeps its old + * value. Grouping spaces are ignored. Swedish accepts `,` and `.` as the decimal separator; English uses `.` + * and reads `,` as grouping (the separator the field itself shows). A leading `0` followed by a digit drops the `0`; + * a decimal separator first gives "0.". + */ + fun parse(text: String, language: KvittLanguage): AmountInput? { + val compact = text.filterNot { it.isWhitespace() || it == NBSP || it == NNBSP } + val normalised = when (language) { + KvittLanguage.SWEDISH -> compact.replace(',', '.') + KvittLanguage.ENGLISH -> compact.replace(",", "") + } + if (normalised.any { it != '.' && it !in '0'..'9' }) return null + if (normalised.count { it == '.' } > 1) return null + val whole = normalised.substringBefore('.').trimStart('0') + val hasPoint = '.' in normalised + val fraction = normalised.substringAfter('.', "") + if (fraction.length > 2 || whole.length > MAX_WHOLE_DIGITS) return null + val raw = when { + normalised.isEmpty() -> "" + hasPoint -> whole.ifEmpty { "0" } + "." + fraction + else -> whole.ifEmpty { "0" } + } + val input = AmountInput(raw) + val ore = input.ore + return if (ore != null && ore > AmountInput.MAX_AMOUNT_ORE) null else input + } + + /** A stored amount as the field shows it first, always with two decimals: 325 000 -> "3250.00". */ + fun of(ore: Long?): AmountInput { + if (ore == null) return AmountInput() + val whole = ore / ORE_PER_KRONA + val cents = ore % ORE_PER_KRONA + return AmountInput("$whole.${cents.toString().padStart(2, '0')}") + } + + private const val NBSP = ' ' + private const val NNBSP = ' ' +} diff --git a/android/feature/settle/src/main/kotlin/se/hagstedt/kvitt/feature/settle/Overlays.kt b/android/feature/settle/src/main/kotlin/se/hagstedt/kvitt/feature/settle/Overlays.kt new file mode 100644 index 0000000..6c4ee55 --- /dev/null +++ b/android/feature/settle/src/main/kotlin/se/hagstedt/kvitt/feature/settle/Overlays.kt @@ -0,0 +1,24 @@ +package se.hagstedt.kvitt.feature.settle + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.window.Dialog + +/** How a screen of this module shows a dialog or a bottom sheet over itself. */ +enum class OverlayKind { Dialog, Sheet } + +/** + * Hosts a dialog or sheet. The app uses [WindowOverlayHost] (a dialog window or a `ModalBottomSheet`); screenshot + * tests pass a host that draws the same content inside the screen, so a capture of the root shows it. + */ +typealias OverlayHost = @Composable (kind: OverlayKind, onDismiss: () -> Unit, content: @Composable () -> Unit) -> Unit + +@OptIn(ExperimentalMaterial3Api::class) +val WindowOverlayHost: OverlayHost = { kind, onDismiss, content -> + when (kind) { + OverlayKind.Dialog -> Dialog(onDismissRequest = onDismiss) { content() } + OverlayKind.Sheet -> ModalBottomSheet(onDismissRequest = onDismiss, containerColor = MaterialTheme.colorScheme.surfaceContainerLow) { content() } + } +} diff --git a/android/feature/settle/src/main/kotlin/se/hagstedt/kvitt/feature/settle/SettleEntry.kt b/android/feature/settle/src/main/kotlin/se/hagstedt/kvitt/feature/settle/SettleEntry.kt deleted file mode 100644 index af5052a..0000000 --- a/android/feature/settle/src/main/kotlin/se/hagstedt/kvitt/feature/settle/SettleEntry.kt +++ /dev/null @@ -1,14 +0,0 @@ -package se.hagstedt.kvitt.feature.settle - -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 SettleEntry(modifier: Modifier = Modifier) { - Box(modifier) -} diff --git a/android/feature/settle/src/main/kotlin/se/hagstedt/kvitt/feature/settle/SettleScreen.kt b/android/feature/settle/src/main/kotlin/se/hagstedt/kvitt/feature/settle/SettleScreen.kt new file mode 100644 index 0000000..5fe0d90 --- /dev/null +++ b/android/feature/settle/src/main/kotlin/se/hagstedt/kvitt/feature/settle/SettleScreen.kt @@ -0,0 +1,466 @@ +package se.hagstedt.kvitt.feature.settle + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SelectableDates +import androidx.compose.material3.Text +import androidx.compose.material3.rememberDatePickerState +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.platform.LocalResources +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.LocalDate +import kotlinx.datetime.plus +import se.hagstedt.kvitt.core.design.R as DesignR +import se.hagstedt.kvitt.design.KvittTheme +import se.hagstedt.kvitt.design.component.AccountTile +import se.hagstedt.kvitt.design.component.AllocationRow +import se.hagstedt.kvitt.design.component.AllocationWords +import se.hagstedt.kvitt.design.component.AmountStyle +import se.hagstedt.kvitt.design.component.AmountTone +import se.hagstedt.kvitt.design.component.ArchivedBadge +import se.hagstedt.kvitt.design.component.ButtonKind +import se.hagstedt.kvitt.design.component.ButtonSize +import se.hagstedt.kvitt.design.component.ConfirmDialogContent +import se.hagstedt.kvitt.design.component.DateChip +import se.hagstedt.kvitt.design.component.DirectionCard +import se.hagstedt.kvitt.design.component.DirectionWords +import se.hagstedt.kvitt.design.component.EditorChipRow +import se.hagstedt.kvitt.design.component.KvittButton +import se.hagstedt.kvitt.design.component.KvittDivider +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.LocalKvittClock +import se.hagstedt.kvitt.design.component.MoneyText +import se.hagstedt.kvitt.design.component.ProvideHousehold +import se.hagstedt.kvitt.design.component.RemainingIndicator +import se.hagstedt.kvitt.design.component.TopBarNavigation +import se.hagstedt.kvitt.design.component.detailDate +import se.hagstedt.kvitt.design.component.kvittLanguage +import se.hagstedt.kvitt.design.component.money +import se.hagstedt.kvitt.design.format.DateFormatter +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.Account +import se.hagstedt.kvitt.model.FieldError +import se.hagstedt.kvitt.model.User + +/** §9.6: the longest settlement note, in code points. */ +private const val NOTE_MAX = 200 + +/** §9.6: dates from 2000-01-01 to today + 365 days. */ +private val FIRST_DATE = LocalDate(2000, 1, 1) +private const val DAYS_AHEAD = 365 +private const val MILLIS_PER_DAY = 86_400_000L + +/** + * S-9 Settle up (design-spec S-9): global (no argument), per account ([SettleArgs.ACCOUNT_ID]) or editing + * ([SettleArgs.SETTLEMENT_ID]). [onSaved] goes back to S-4 or S-5, which shows "Betalningen sparades" + * (`snackbar_payment_saved`); [onClose] goes back without saving. + */ +@Composable +fun SettleScreen( + onClose: () -> Unit, + onSaved: () -> Unit, + modifier: Modifier = Modifier, + viewModel: SettleViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + LaunchedEffect(state.finish) { + when (state.finish) { + SettleFinish.Saved -> onSaved() + SettleFinish.Closed -> onClose() + null -> Unit + } + } + BackHandler(enabled = state.finish == null) { viewModel.onClose() } + SettleContent(state, viewModel, modifier) +} + +/** The stateless S-9, rendered by [SettleScreen], previews and screenshot tests. */ +@Composable +fun SettleContent(state: SettleUiState, actions: SettleActions, modifier: Modifier = Modifier, overlayHost: OverlayHost = WindowOverlayHost) { + val me = state.me + if (me == null) { + SettleScaffold(state, actions, modifier) { } + return + } + ProvideHousehold(me, state.partner) { + SettleScaffold(state, actions, modifier) { if (!state.loading) SettleBody(state, actions) } + when (state.dialog) { + SettleDialog.Confirm -> overlayHost(OverlayKind.Dialog, actions::onDismissDialog) { ConfirmSettlement(state, actions) } + SettleDialog.Discard -> overlayHost(OverlayKind.Dialog, actions::onDismissDialog) { + ConfirmDialogContent( + 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::onDismissDialog, + destructive = true, + ) + } + SettleDialog.AddAccount -> overlayHost(OverlayKind.Sheet, actions::onDismissDialog) { AddAccountSheet(state, actions) } + SettleDialog.Date -> SettleDatePicker(state.date, actions) + null -> Unit + } + } +} + +@Composable +private fun SettleScaffold(state: SettleUiState, actions: SettleActions, modifier: Modifier, body: @Composable () -> Unit) { + val title = when (state.mode) { + SettleMode.Global -> stringResource(R.string.settle_title) + SettleMode.Account -> stringResource(R.string.settle_title_account, state.fixedAccount?.name.orEmpty()) + SettleMode.Edit -> stringResource(R.string.settle_title_edit) + } + KvittScaffold( + modifier.fillMaxSize(), + topBar = { KvittTopAppBar(title, navigation = TopBarNavigation.Close, onNavigation = actions::onClose) }, + bottomBar = { + Column(Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surface).navigationBarsPadding()) { + KvittDivider() + KvittButton( + stringResource(R.string.settle_confirm), + actions::onConfirm, + Modifier.fillMaxWidth().padding(horizontal = KvittTheme.spacing.gutter, vertical = KvittTheme.spacing.detailGap), + size = ButtonSize.Medium, + icon = KvittIcon.Check, + enabled = state.canConfirm && !state.saving, + ) + } + }, + ) { padding -> + Column( + Modifier + .padding(padding) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(start = KvittTheme.spacing.gutter, end = KvittTheme.spacing.gutter, bottom = KvittTheme.spacing.s5), + verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.detailGap), + ) { body() } + } +} + +@Composable +private fun SettleBody(state: SettleUiState, actions: SettleActions) { + val payer = state.payer ?: return + val recipient = state.recipient ?: return + val language = kvittLanguage() + DirectionCard( + payer, + recipient, + DirectionWords(stringResource(R.string.settle_direction), directionSentence(payer, recipient, state.me), stringResource(R.string.settle_swap)), + actions::onSwap, + ) + KvittTextField( + value = state.amount.display(language), + onValueChange = { text -> AmountText.parse(text, language)?.let(actions::onAmount) }, + label = stringResource(R.string.settle_amount), + error = amountError(state.errors), + placeholder = MoneyFormatter.number(0L, language), + keyboardType = KeyboardType.Decimal, + textStyle = KvittTheme.typography.amountLarge, + suffix = stringResource(DesignR.string.common_kr_suffix), + ) + Text( + stringResource(R.string.settle_allocation), + Modifier.padding(start = KvittTheme.spacing.s2, top = KvittTheme.spacing.s2).semantics { heading() }, + style = MaterialTheme.typography.titleSmall, + ) + if (state.rows.isEmpty()) { + Text( + stringResource(R.string.settle_empty_hint), + Modifier.padding(horizontal = KvittTheme.spacing.s2), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + AllocationRows(state, actions) + } + allocationError(state.errors)?.let { ErrorLine(it) } + if (state.showRemaining) RemainingIndicator(state.remainingOre, stringResource(R.string.settle_remaining)) + if (state.showAddAccount || state.showProposeAgain) { + Row(horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2)) { + if (state.showAddAccount) KvittButton(stringResource(R.string.settle_add_account), actions::onAddAccountClick, kind = ButtonKind.Text, icon = KvittIcon.Add) + if (state.showProposeAgain) KvittButton(stringResource(R.string.settle_propose_again), actions::onProposeAgain, kind = ButtonKind.Text, icon = KvittIcon.Refresh) + } + } + untouchedText(state)?.let { text -> + Row(Modifier.padding(horizontal = KvittTheme.spacing.s2), horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2)) { + KvittIconImage(KvittIcon.Info, null, size = KvittDimens.iconChip, tint = MaterialTheme.colorScheme.onSurfaceVariant) + Text(text, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + EditorChipRow { DateChip(state.date, actions::onDateClick, keyPrefix = stringResource(R.string.settle_date) + " ") } + state.errorFor(SettleViewModel.FIELD_DATE)?.let { ErrorLine(stringResource(DesignR.string.error_date_out_of_range)) } + KvittTextField( + value = state.note, + onValueChange = actions::onNote, + label = stringResource(R.string.settle_note), + placeholder = stringResource(R.string.settle_note_hint), + error = state.errorFor(SettleViewModel.FIELD_NOTE)?.let { pluralStringResource(DesignR.plurals.error_too_long, NOTE_MAX, NOTE_MAX) }, + ) +} + +@Composable +private fun AllocationRows(state: SettleUiState, actions: SettleActions) { + val language = kvittLanguage() + val fixed = state.mode == SettleMode.Account + se.hagstedt.kvitt.design.component.SegmentedGroup(state.rows.size) { item -> + val row = state.rows[item.index] + val account = row.line.account + AllocationRow( + item = item, + line = row.line, + colorIndex = state.colorIndex(account.id), + amountText = row.input.display(language), + words = AllocationWords( + position = positionText(row, state), + fieldLabel = stringResource(R.string.settle_row_amount, account.name), + remove = stringResource(R.string.settle_row_remove, account.name), + overSettle = stringResource(R.string.settle_over_settle_warning, account.name), + ), + onAmount = { text -> AmountText.parse(text, language)?.let { actions.onRowAmount(account.id, it) } }, + onRemove = { actions.onRemoveRow(account.id) }, + fixed = fixed, + ) + } +} + +/** + * The payer's position in an account (design-spec §3.8): "Skuld 3 084,50 kr" when the payer owes there, otherwise the + * account's balance wording from the viewer's side ("Anna är skyldig dig 1 184,50 kr", "Kvitt"). + */ +@Composable +private fun positionText(row: SettleRow, state: SettleUiState): String { + val payerNet = row.line.payerNetOre + val partner = state.partner ?: return "" + if (payerNet < 0) return stringResource(R.string.settle_row_debt, money(payerNet)) + val viewerNet = if (state.payer?.id == state.me?.id) payerNet else -payerNet + return when { + viewerNet > 0 -> stringResource(DesignR.string.balance_partner_owes_you_amount, partner.displayName, money(viewerNet)) + viewerNet < 0 -> stringResource(DesignR.string.balance_you_owe_partner_amount, partner.displayName, money(viewerNet)) + else -> stringResource(DesignR.string.balance_account_settled) + } +} + +/** "Du betalar Anna" / "Anna betalar dig". */ +@Composable +private fun directionSentence(payer: User, recipient: User, me: User?): String = + if (payer.id == me?.id) stringResource(R.string.settle_you_pay, recipient.displayName) else stringResource(R.string.settle_partner_pays, payer.displayName) + +/** §9.4 info line: "Vardag lämnas som det är, eftersom Anna är skyldig dig där." */ +@Composable +private fun untouchedText(state: SettleUiState): String? { + val untouched = state.untouched + val partner = state.partner ?: return null + if (untouched.isEmpty()) return null + val names = joinNames(untouched.map { it.first.name }) + return if (untouched.first().second > 0) { + stringResource(R.string.settle_untouched_partner_owes, names, partner.displayName) + } else { + stringResource(R.string.settle_untouched_you_owe, names, partner.displayName) + } +} + +/** "A", "A och B", "A, B och C" (`common_and`, `common_separator_comma`). */ +@Composable +private fun joinNames(names: List): String { + if (names.size == 1) return names.first() + val separator = stringResource(DesignR.string.common_separator_comma) + return stringResource(DesignR.string.common_and, names.dropLast(1).joinToString(separator), names.last()) +} + +@Composable +private fun amountError(errors: List): String? { + val error = errors.firstOrNull { it.field == SettleViewModel.FIELD_AMOUNT }?.error ?: return null + return when (error) { + REQUIRED -> stringResource(DesignR.string.error_amount_required) + "amount_out_of_range" -> stringResource(DesignR.string.error_amount_out_of_range, money(1L), money(se.hagstedt.kvitt.design.format.AmountInput.MAX_AMOUNT_ORE)) + else -> stringResource(DesignR.string.error_generic_invalid) + } +} + +/** §14.5.3 tokens on the allocations (design-spec S-9 "Invalid → inline under the rows"). */ +@Composable +private fun allocationError(errors: List): String? { + val error = errors.firstOrNull { it.field == SettleViewModel.FIELD_ALLOCATIONS }?.error ?: return null + return when (error) { + "allocation_sum_mismatch" -> stringResource(R.string.settle_error_allocation_sum) + "allocation_not_positive" -> stringResource(R.string.settle_error_allocation_positive) + "no_allocations" -> stringResource(R.string.settle_error_no_allocations) + "too_many_allocations" -> stringResource(R.string.settle_error_too_many_allocations) + else -> stringResource(DesignR.string.error_generic_invalid) + } +} + +private const val REQUIRED = "required" + +/** An error as the fields show it: filled `error` icon and body-small in `error`. */ +@Composable +private fun ErrorLine(text: String) { + Row( + Modifier.padding(horizontal = KvittTheme.spacing.s4), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2), + ) { + KvittIconImage(KvittIcon.Error, null, filled = true, size = KvittDimens.iconHint, tint = MaterialTheme.colorScheme.error) + Text(text, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) + } +} + +/** + * AC-11.5 confirm dialog (design-spec S-9 `S-9-confirm-dialog`): "Du betalar Anna 3 250,00 kr idag, lördag 17 oktober + * 2026.", one line per allocation (name and amount) and the warning strip per warned row. + */ +@Composable +private fun ConfirmSettlement(state: SettleUiState, actions: SettleActions) { + val payer = state.payer ?: return + val recipient = state.recipient ?: return + val amount = money(state.amount.ore ?: 0L) + val today = LocalKvittClock.current.today + val dateText = if (state.date == today.toString()) { + stringResource(R.string.settle_confirm_date_today, detailDate(state.date)) + } else { + stringResource(R.string.settle_confirm_date, detailDate(state.date)) + } + val body = if (payer.id == state.me?.id) { + stringResource(R.string.settle_confirm_body_you_pay, recipient.displayName, amount, dateText) + } else { + stringResource(R.string.settle_confirm_body_partner_pays, payer.displayName, amount, dateText) + } + ConfirmDialogContent( + icon = KvittIcon.Handshake, + title = stringResource(R.string.settle_confirm_dialog_title), + body = body, + confirm = stringResource(R.string.settle_confirm), + dismiss = stringResource(DesignR.string.common_cancel), + onConfirm = actions::onConfirmDialog, + onDismiss = actions::onDismissDialog, + confirmIcon = KvittIcon.Check, + extra = { + Column(verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2)) { + state.rows.forEach { row -> + Row(verticalAlignment = Alignment.CenterVertically) { + Text(row.line.account.name, Modifier.weight(1f), style = MaterialTheme.typography.bodyLarge, maxLines = 2, overflow = TextOverflow.Ellipsis) + MoneyText(row.input.ore ?: 0L, AmountStyle.Small, AmountTone.Neutral) + } + } + } + state.overSettled.forEach { row -> WarningStrip(stringResource(R.string.settle_over_settle_warning, row.line.account.name)) } + }, + ) +} + +/** The over-settle strip (design-spec §3.8): `warning-container`, radius 12, filled `warning` 20 and the sentence. */ +@Composable +private fun WarningStrip(text: String) { + val colors = KvittTheme.colors + Row( + Modifier.fillMaxWidth().background(colors.warningContainer, KvittTheme.shapes.strip).padding(horizontal = KvittDimens.stripHorizontal, vertical = KvittDimens.stripVertical), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3), + ) { + KvittIconImage(KvittIcon.Warning, null, filled = true, size = KvittDimens.iconButton, tint = colors.onWarningContainer) + Text(text, style = MaterialTheme.typography.bodyMedium, color = colors.onWarningContainer) + } +} + +/** "Lägg till konto" sheet: every account not yet listed, archived ones with the badge (AC-11.3). */ +@Composable +private fun AddAccountSheet(state: SettleUiState, actions: SettleActions) { + Column( + Modifier.fillMaxWidth().selectableGroup().padding(horizontal = KvittTheme.spacing.gutter).padding(bottom = KvittTheme.spacing.s6), + verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s1), + ) { + Text( + stringResource(DesignR.string.editor_pick_account), + Modifier.padding(KvittTheme.spacing.s2).semantics { heading() }, + style = MaterialTheme.typography.titleLarge, + ) + state.addable.forEach { account -> AddAccountRow(account, state.colorIndex(account.id)) { actions.onAddAccount(account.id) } } + } +} + +@Composable +private fun AddAccountRow(account: Account, colorIndex: Int, onClick: () -> Unit) { + Row( + Modifier + .fillMaxWidth() + .heightIn(min = KvittTheme.spacing.rowMin) + .background(MaterialTheme.colorScheme.surfaceContainerLow, KvittTheme.shapes.strip) + .clickable(role = Role.Button, onClick = onClick) + .semantics(mergeDescendants = true) {} + .padding(KvittTheme.spacing.s2), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.rowTileGap), + ) { + AccountTile(account.name, colorIndex, size = KvittDimens.tileLedger, muted = account.archived) + Text(account.name, Modifier.weight(1f), style = MaterialTheme.typography.titleMedium, maxLines = 2, overflow = TextOverflow.Ellipsis) + if (account.archived) ArchivedBadge() + } +} + +/** The settlement date: Material date picker limited to §9.6 (2000-01-01 to today + 365 days). */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SettleDatePicker(isoDate: String, actions: SettleActions) { + val today = LocalKvittClock.current.today + val last = today.plus(DAYS_AHEAD, DateTimeUnit.DAY) + val selectable = remember(today) { + object : SelectableDates { + override fun isSelectableDate(utcTimeMillis: Long): Boolean { + val day = utcTimeMillis.floorDiv(MILLIS_PER_DAY) + return day in FIRST_DATE.toEpochDays()..last.toEpochDays() + } + + override fun isSelectableYear(year: Int): Boolean = year in FIRST_DATE.year..last.year + } + } + val initial = runCatching { DateFormatter.parseDate(isoDate) }.getOrDefault(today) + val picker = rememberDatePickerState(initialSelectedDateMillis = initial.toEpochDays() * MILLIS_PER_DAY, selectableDates = selectable) + val resources = LocalResources.current + DatePickerDialog( + onDismissRequest = actions::onDismissDialog, + confirmButton = { + KvittButton(resources.getString(DesignR.string.common_save), { + picker.selectedDateMillis?.let { actions.onDate(LocalDate.fromEpochDays(it.floorDiv(MILLIS_PER_DAY)).toString()) } ?: actions.onDismissDialog() + }, kind = ButtonKind.Text) + }, + dismissButton = { KvittButton(resources.getString(DesignR.string.common_cancel), actions::onDismissDialog, kind = ButtonKind.Text) }, + ) { DatePicker(picker) } +} diff --git a/android/feature/settle/src/main/kotlin/se/hagstedt/kvitt/feature/settle/SettleViewModel.kt b/android/feature/settle/src/main/kotlin/se/hagstedt/kvitt/feature/settle/SettleViewModel.kt new file mode 100644 index 0000000..0663f0f --- /dev/null +++ b/android/feature/settle/src/main/kotlin/se/hagstedt/kvitt/feature/settle/SettleViewModel.kt @@ -0,0 +1,275 @@ +package se.hagstedt.kvitt.feature.settle + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import se.hagstedt.kvitt.design.format.AmountInput +import se.hagstedt.kvitt.model.Account +import se.hagstedt.kvitt.model.AccountId +import se.hagstedt.kvitt.model.AccountRepository +import se.hagstedt.kvitt.model.AllocationLine +import se.hagstedt.kvitt.model.FieldError +import se.hagstedt.kvitt.model.HomeRepository +import se.hagstedt.kvitt.model.SaveResult +import se.hagstedt.kvitt.model.SettleRequest +import se.hagstedt.kvitt.model.SettleState +import se.hagstedt.kvitt.model.SettlementId +import se.hagstedt.kvitt.model.SettlementRepository +import se.hagstedt.kvitt.model.User +import javax.inject.Inject + +/** + * Navigation arguments of S-9 and S-10, read from the [SavedStateHandle] (`:app` puts them there, M8). S-9: none for + * the global settle-up, [ACCOUNT_ID] for the per-account variant, [SETTLEMENT_ID] for editing. S-10: [SETTLEMENT_ID]. + */ +object SettleArgs { + const val SETTLEMENT_ID = "settlementId" + const val ACCOUNT_ID = "accountId" +} + +/** Which S-9 variant is open: global (AC-11.3), per account (AC-11.4) or editing a settlement (AC-11.6). */ +enum class SettleMode { Global, Account, Edit } + +/** The dialogs and sheets of S-9. */ +enum class SettleDialog { Confirm, Discard, AddAccount, Date } + +/** How S-9 ended: saved (back with "Betalningen sparades") or closed without saving. */ +enum class SettleFinish { Saved, Closed } + +/** One allocation row: the repository's line and what the user typed for it (empty for a newly added account). */ +data class SettleRow(val line: AllocationLine, val input: AmountInput) + +/** + * Everything S-9 shows. [settle] fields come from `SettlementRepository.start`/`recompute` (§14.2.4); the feature never + * computes the proposal, the remaining amount, the warnings or [canConfirm] itself. [viewerNets] are the viewer's + * account balances from Home, used only for the §9.4 info line about accounts left untouched. + */ +data class SettleUiState( + val loading: Boolean = true, + val mode: SettleMode = SettleMode.Global, + val me: User? = null, + val partner: User? = null, + val payer: User? = null, + val recipient: User? = null, + val amount: AmountInput = AmountInput(), + val rows: List = emptyList(), + val remainingOre: Long = 0L, + val canConfirm: Boolean = false, + val date: String = "", + val note: String = "", + val rowsEdited: Boolean = false, + val fixedAccount: Account? = null, + val accounts: List = emptyList(), + val viewerNets: Map = emptyMap(), + val errors: List = emptyList(), + val dialog: SettleDialog? = null, + val dirty: Boolean = false, + val saving: Boolean = false, + val finish: SettleFinish? = null, +) { + /** Design-spec §2.4: an account's colour is its position in `AccountRepository.all()`. */ + fun colorIndex(id: AccountId): Int = accounts.indexOfFirst { it.id == id }.coerceAtLeast(0) + + /** "Lägg till konto": every non-deleted account not yet listed, archived included (AC-11.3). */ + val addable: List get() = accounts.filter { account -> rows.none { it.line.account.id == account.id } } + + val showAddAccount: Boolean get() = mode != SettleMode.Account && addable.isNotEmpty() + + val showProposeAgain: Boolean get() = mode != SettleMode.Account && rowsEdited + + val showRemaining: Boolean get() = mode != SettleMode.Account && rows.isNotEmpty() + + /** + * §9.4 Consequences: accounts where the payer is owed and that are not in the rows (global variant). The viewer's + * net sign of each says who owes there; all of them have the same sign because the payer is owed in each. + */ + val untouched: List> + get() { + val payer = payer ?: return emptyList() + if (mode == SettleMode.Account || rows.isEmpty()) return emptyList() + return accounts.mapNotNull { account -> + val viewerNet = viewerNets[account.id] ?: 0L + val payerNet = if (payer.id == me?.id) viewerNet else -viewerNet + account.takeIf { payerNet > 0 && rows.none { it.line.account.id == account.id } }?.let { it to viewerNet } + } + } + + val overSettled: List get() = rows.filter { it.line.overSettles } + + fun errorFor(field: String): String? = errors.firstOrNull { it.field == field }?.error +} + +/** The events of S-9, implemented by [SettleViewModel]. */ +interface SettleActions { + fun onAmount(input: AmountInput) + fun onSwap() + fun onRowAmount(account: AccountId, input: AmountInput) + fun onRemoveRow(account: AccountId) + fun onAddAccountClick() + fun onAddAccount(account: AccountId) + fun onProposeAgain() + fun onDateClick() + fun onDate(isoDate: String) + fun onNote(note: String) + fun onConfirm() + fun onConfirmDialog() + fun onDiscard() + fun onDismissDialog() + fun onClose() +} + +/** + * S-9's state holder. Defaults come from `start` (AC-11.3, AC-11.4, AC-11.6). Until the user edits a row, every amount + * or direction change asks `recompute` for a fresh proposal (`allocations = null`); afterwards the typed rows are sent + * and `recompute` only validates and warns. "Föreslå igen" and a direction swap propose again (design-spec S-9). + */ +@HiltViewModel +class SettleViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val settlements: SettlementRepository, + accounts: AccountRepository, + home: HomeRepository, +) : ViewModel(), SettleActions { + private val editing: SettlementId? = savedStateHandle.get(SettleArgs.SETTLEMENT_ID)?.let(::SettlementId) + private val fixedAccountId: AccountId? = savedStateHandle.get(SettleArgs.ACCOUNT_ID)?.let(::AccountId) + private val mutable = MutableStateFlow( + SettleUiState(mode = if (editing != null) SettleMode.Edit else if (fixedAccountId != null) SettleMode.Account else SettleMode.Global), + ) + val state: StateFlow = mutable.asStateFlow() + private var recomputing: Job? = null + + init { + viewModelScope.launch { + accounts.all().collect { all -> + mutable.update { s -> s.copy(accounts = all, fixedAccount = fixedAccountId?.let { id -> all.firstOrNull { it.id == id } }) } + } + } + viewModelScope.launch { + home.home().collect { h -> + val nets = (h.active + h.archived).associate { it.account.id to it.netOre } + mutable.update { it.copy(me = h.me, partner = h.partner, viewerNets = nets) } + } + } + viewModelScope.launch { + val start = settlements.start(editing, if (editing == null) fixedAccountId else null) + mutable.update { s -> s.withResult(start, propose = true).copy(loading = false, date = start.date, note = start.note, rowsEdited = editing != null) } + } + } + + override fun onAmount(input: AmountInput) = edit(propose = null) { it.copy(amount = input) } + + override fun onSwap() = edit(propose = true) { s -> s.copy(payer = s.recipient, recipient = s.payer, rowsEdited = false) } + + override fun onRowAmount(account: AccountId, input: AmountInput) = edit(propose = false) { s -> + s.copy(rows = s.rows.map { if (it.line.account.id == account) it.copy(input = input) else it }, rowsEdited = true) + } + + override fun onRemoveRow(account: AccountId) = edit(propose = false) { s -> + s.copy(rows = s.rows.filter { it.line.account.id != account }, rowsEdited = true) + } + + override fun onAddAccountClick() = mutable.update { it.copy(dialog = SettleDialog.AddAccount) } + + override fun onAddAccount(account: AccountId) = edit(propose = false) { s -> + val added = s.accounts.firstOrNull { it.id == account } + if (added == null || s.rows.any { it.line.account.id == account }) { + s.copy(dialog = null) + } else { + s.copy(rows = s.rows + SettleRow(AllocationLine(added, 0L, 0L, false), AmountInput()), rowsEdited = true, dialog = null) + } + } + + override fun onProposeAgain() = edit(propose = true) { it.copy(rowsEdited = false) } + + override fun onDateClick() = mutable.update { it.copy(dialog = SettleDialog.Date) } + + override fun onDate(isoDate: String) = mutable.update { s -> + s.copy(date = isoDate, dialog = null, dirty = true, errors = s.errors.filter { it.field != FIELD_DATE }) + } + + override fun onNote(note: String) = mutable.update { s -> s.copy(note = note, dirty = true, errors = s.errors.filter { it.field != FIELD_NOTE }) } + + /** AC-11.5: with a warned row the confirm dialog repeats the warnings; otherwise saving is direct. */ + override fun onConfirm() { + val s = mutable.value + if (!s.canConfirm || s.saving) return + if (s.overSettled.isNotEmpty()) mutable.update { it.copy(dialog = SettleDialog.Confirm) } else save() + } + + override fun onConfirmDialog() { + mutable.update { it.copy(dialog = null) } + save() + } + + override fun onDiscard() = mutable.update { it.copy(dialog = null, finish = SettleFinish.Closed) } + + override fun onDismissDialog() = mutable.update { it.copy(dialog = null) } + + /** Close with changes asks "Slänga ändringarna?" (design-spec S-9). */ + override fun onClose() = mutable.update { if (it.dirty) it.copy(dialog = SettleDialog.Discard) else it.copy(finish = SettleFinish.Closed) } + + private fun save() { + val request = request(mutable.value, propose = false) ?: return + mutable.update { it.copy(saving = true) } + viewModelScope.launch { + recomputing?.join() + when (val result = settlements.save(request(mutable.value, propose = false) ?: request)) { + is SaveResult.Saved -> mutable.update { it.copy(saving = false, finish = SettleFinish.Saved) } + is SaveResult.Invalid -> mutable.update { it.copy(saving = false, errors = result.errors) } + } + } + } + + /** + * Applies a user edit and recomputes. [propose] true asks for a fresh proposal, false sends the typed rows, null + * proposes while no row has been edited (an amount change). + */ + private fun edit(propose: Boolean?, transform: (SettleUiState) -> SettleUiState) { + mutable.update { transform(it).copy(dirty = true, errors = emptyList()) } + val current = mutable.value + if (current.loading) return + val fresh = propose ?: !current.rowsEdited + val request = request(current, fresh) ?: return + recomputing?.cancel() + recomputing = viewModelScope.launch { + val result = settlements.recompute(request) + mutable.update { it.withResult(result, fresh) } + } + } + + private fun request(s: SettleUiState, propose: Boolean): SettleRequest? { + val payer = s.payer ?: return null + val fixed = if (s.mode == SettleMode.Account) fixedAccountId else null + val allocations = if (propose || fixed != null) null else s.rows.associateTo(LinkedHashMap()) { it.line.account.id to (it.input.ore ?: 0L) } + return SettleRequest(editing, payer.id, s.amount.ore, fixed, allocations, s.date, s.note) + } + + companion object { + const val FIELD_AMOUNT = "amountOre" + const val FIELD_ALLOCATIONS = "allocations" + const val FIELD_NOTE = "note" + const val FIELD_DATE = "date" + } +} + +/** + * The repository's answer merged into the screen: direction, remaining, enablement and the lines. A proposal replaces + * the rows (each typed text set to its proposed amount); otherwise the typed texts stay and the lines (debts, + * warnings) are refreshed by account. + */ +internal fun SettleUiState.withResult(result: SettleState, propose: Boolean): SettleUiState { + val rows = if (propose) { + result.lines.map { SettleRow(it, AmountText.of(it.amountOre)) } + } else { + rows.map { row -> SettleRow(result.lines.firstOrNull { it.account.id == row.line.account.id } ?: row.line, row.input) } + } + val amount = if (loading) AmountText.of(result.amountOre) else amount + return copy(payer = result.payer, recipient = result.recipient, amount = amount, rows = rows, remainingOre = result.remainingOre, canConfirm = result.canConfirm) +} diff --git a/android/feature/settle/src/main/kotlin/se/hagstedt/kvitt/feature/settle/SettlementDetailScreen.kt b/android/feature/settle/src/main/kotlin/se/hagstedt/kvitt/feature/settle/SettlementDetailScreen.kt new file mode 100644 index 0000000..7cc3172 --- /dev/null +++ b/android/feature/settle/src/main/kotlin/se/hagstedt/kvitt/feature/settle/SettlementDetailScreen.kt @@ -0,0 +1,228 @@ +package se.hagstedt.kvitt.feature.settle + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +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.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import se.hagstedt.kvitt.core.design.R as DesignR +import se.hagstedt.kvitt.design.KvittTheme +import se.hagstedt.kvitt.design.component.AccountTile +import se.hagstedt.kvitt.design.component.AmountStyle +import se.hagstedt.kvitt.design.component.AmountTone +import se.hagstedt.kvitt.design.component.AvatarShape +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.ConfirmDialogContent +import se.hagstedt.kvitt.design.component.EmptyState +import se.hagstedt.kvitt.design.component.FactRow +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.LocalKvittHousehold +import se.hagstedt.kvitt.design.component.MoneyText +import se.hagstedt.kvitt.design.component.PersonAvatar +import se.hagstedt.kvitt.design.component.ProvideHousehold +import se.hagstedt.kvitt.design.component.SegmentedGroup +import se.hagstedt.kvitt.design.component.SyncPendingMarker +import se.hagstedt.kvitt.design.component.Timeline +import se.hagstedt.kvitt.design.component.TopBarNavigation +import se.hagstedt.kvitt.design.component.detailDate +import se.hagstedt.kvitt.design.component.kvittLanguage +import se.hagstedt.kvitt.design.component.personName +import se.hagstedt.kvitt.design.component.spokenMoney +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.SettlementDetailState +import se.hagstedt.kvitt.model.SettlementId +import kotlin.time.Instant + +/** + * S-10 Settlement detail (design-spec S-10) for [SettleArgs.SETTLEMENT_ID]. [onEdit] opens S-9 editing; after a + * confirmed delete [onDeleted] goes back, where "Betalningen togs bort" (`snackbar_payment_deleted`) is shown. + */ +@Composable +fun SettlementDetailScreen( + onBack: () -> Unit, + onEdit: (SettlementId) -> Unit, + onDeleted: () -> Unit, + modifier: Modifier = Modifier, + viewModel: SettlementDetailViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + LaunchedEffect(state.deletedByMe) { if (state.deletedByMe) onDeleted() } + SettlementDetailContent(state, viewModel, onBack, onEdit, modifier) +} + +/** The stateless S-10, rendered by [SettlementDetailScreen], previews and screenshot tests. */ +@Composable +fun SettlementDetailContent( + state: SettlementDetailUiState, + actions: SettlementDetailActions, + onBack: () -> Unit, + onEdit: (SettlementId) -> Unit, + modifier: Modifier = Modifier, + overlayHost: OverlayHost = WindowOverlayHost, +) { + val me = state.me + val detail = state.detail + val body: @Composable () -> Unit = { + KvittScaffold( + modifier.fillMaxSize(), + topBar = { + KvittTopAppBar(stringResource(R.string.settle_detail_title), navigation = TopBarNavigation.Back, onNavigation = onBack) { + if (state.actions && detail != null) { + IconButton({ onEdit(detail.id) }) { KvittIconImage(KvittIcon.Edit, stringResource(R.string.settle_title_edit)) } + IconButton(actions::onDelete) { KvittIconImage(KvittIcon.Delete, stringResource(R.string.settle_detail_delete)) } + } + } + }, + ) { padding -> + Column( + Modifier + .padding(padding) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(start = KvittTheme.spacing.gutter, end = KvittTheme.spacing.gutter, bottom = KvittTheme.spacing.s5), + verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.detailGap), + ) { + when { + state.loading || me == null -> Unit + detail == null -> EmptyState( + title = stringResource(R.string.settle_detail_missing), + body = stringResource(R.string.settle_detail_missing_body), + action = stringResource(R.string.settle_detail_sync_now), + actionIcon = KvittIcon.Sync, + actionKind = ButtonKind.Tonal, + onAction = actions::onSyncNow, + ) + else -> DetailBody(state, detail) + } + } + } + if (state.confirmDelete) { + overlayHost(OverlayKind.Dialog, actions::onDismissDialog) { + ConfirmDialogContent( + icon = KvittIcon.Delete, + title = stringResource(R.string.settle_detail_delete_title), + body = stringResource(R.string.settle_detail_delete_body), + confirm = stringResource(DesignR.string.common_delete), + dismiss = stringResource(DesignR.string.common_cancel), + onConfirm = actions::onConfirmDelete, + onDismiss = actions::onDismissDialog, + destructive = true, + ) + } + } + } + if (me != null) ProvideHousehold(me, state.partner) { body() } else body() +} + +@Composable +private fun DetailBody(state: SettlementDetailUiState, detail: SettlementDetailState) { + val faded = if (detail.deleted) Modifier.alpha(KvittDimens.DELETED_ALPHA) else Modifier + state.deletion?.let { line -> + val clock = LocalKvittClock.current + val time = DateFormatter.listDateTime(Instant.parse(line.clientAt), clock.zone, clock.today, kvittLanguage()) + Banner( + BannerKind.Neutral, + KvittIcon.Delete, + stringResource(R.string.settle_detail_deleted_banner, line.actor?.let { personName(it) }.orEmpty(), time), + body = stringResource(R.string.settle_detail_deleted_body), + iconTint = MaterialTheme.colorScheme.error, + ) + } + Hero(detail, faded) + val note = detail.note.trim() + val dateLabel = stringResource(R.string.settle_date) + val noteLabel = stringResource(R.string.settle_note) + SegmentedGroup(if (note.isEmpty()) 1 else 2, faded) { item -> + if (item.index == 0) { + FactRow(item, KvittIcon.Event, dateLabel, detailDate(detail.date)) + } else { + FactRow(item, KvittIcon.Notes, noteLabel, note) + } + } + Text( + stringResource(R.string.settle_allocation), + Modifier.padding(start = KvittTheme.spacing.s2, top = KvittTheme.spacing.s2).semantics { heading() }, + style = MaterialTheme.typography.titleSmall, + ) + SegmentedGroup(detail.allocations.size, faded) { item -> + val (account, amount) = detail.allocations[item.index] + Row( + Modifier + .fillMaxWidth() + .background(item.fill, item.shape) + .heightIn(min = KvittTheme.spacing.rowMin) + .semantics(mergeDescendants = true) {} + .padding(horizontal = KvittTheme.spacing.rowHorizontal, vertical = KvittTheme.spacing.rowVertical), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.rowTileGap), + ) { + AccountTile(account.name, state.colorIndex(account.id), size = KvittDimens.tileSmall, muted = account.archived) + Text(account.name, Modifier.weight(1f), style = MaterialTheme.typography.titleMedium, maxLines = 2, overflow = TextOverflow.Ellipsis) + MoneyText(amount, AmountStyle.Small, AmountTone.Neutral) + } + } + Timeline(detail.timeline) +} + +/** Hero (design-spec S-10): payer avatar 52 → arrow → recipient avatar 52, the sentence and the amount; one node. */ +@Composable +private fun Hero(detail: SettlementDetailState, modifier: Modifier) { + val viewerPaid = LocalKvittHousehold.current.isViewer(detail.from.id) + val text = if (viewerPaid) { + stringResource(R.string.settle_detail_you_paid_partner, detail.to.displayName) + } else { + stringResource(R.string.settle_detail_partner_paid_you, detail.from.displayName) + } + val spoken = "$text ${spokenMoney(detail.amountOre)}" + + if (detail.pending) stringResource(DesignR.string.common_separator_comma) + stringResource(DesignR.string.common_pending_sync) else "" + KvittCard( + modifier.fillMaxWidth().clearAndSetSemantics { contentDescription = spoken }, + color = KvittTheme.colors.settlementHero, + contentPadding = PaddingValues(horizontal = KvittTheme.spacing.s5, vertical = KvittTheme.spacing.s5), + verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3)) { + PersonAvatar(detail.from, KvittDimens.avatarSettings, shape = AvatarShape.Large) + KvittIconImage(KvittIcon.ArrowForward, null, size = KvittDimens.accountHeaderSettledIcon, tint = MaterialTheme.colorScheme.onSurfaceVariant) + PersonAvatar(detail.to, KvittDimens.avatarSettings, shape = AvatarShape.Large) + } + Text(text, style = MaterialTheme.typography.titleMedium, textAlign = TextAlign.Center) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2)) { + MoneyText(detail.amountOre, AmountStyle.Large, AmountTone.Neutral, textAlign = TextAlign.Center) + if (detail.pending) SyncPendingMarker(size = KvittDimens.iconButton, standalone = false) + } + } +} diff --git a/android/feature/settle/src/main/kotlin/se/hagstedt/kvitt/feature/settle/SettlementDetailViewModel.kt b/android/feature/settle/src/main/kotlin/se/hagstedt/kvitt/feature/settle/SettlementDetailViewModel.kt new file mode 100644 index 0000000..e1b7edb --- /dev/null +++ b/android/feature/settle/src/main/kotlin/se/hagstedt/kvitt/feature/settle/SettlementDetailViewModel.kt @@ -0,0 +1,98 @@ +package se.hagstedt.kvitt.feature.settle + +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.flowOf +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import se.hagstedt.kvitt.model.Account +import se.hagstedt.kvitt.model.AccountId +import se.hagstedt.kvitt.model.AccountRepository +import se.hagstedt.kvitt.model.AuditAction +import se.hagstedt.kvitt.model.HomeRepository +import se.hagstedt.kvitt.model.SettlementDetailState +import se.hagstedt.kvitt.model.SettlementId +import se.hagstedt.kvitt.model.SettlementRepository +import se.hagstedt.kvitt.model.SyncRepository +import se.hagstedt.kvitt.model.TimelineLine +import se.hagstedt.kvitt.model.User + +/** + * Everything S-10 shows. [detail] null after loading is the missing state (the S-8 missing layout, design-spec S-10). + */ +data class SettlementDetailUiState( + val loading: Boolean = true, + val detail: SettlementDetailState? = null, + val me: User? = null, + val partner: User? = null, + val accounts: List = emptyList(), + val confirmDelete: Boolean = false, + val deletedByMe: Boolean = false, +) { + /** Design-spec §2.4: an account's colour is its position in `AccountRepository.all()`. */ + fun colorIndex(id: AccountId): Int = accounts.indexOfFirst { it.id == id }.coerceAtLeast(0) + + /** "Ändra" and "Ta bort" exist only for a settlement that is on the phone and not deleted. */ + val actions: Boolean get() = detail != null && !detail.deleted + + /** The timeline entry that deleted the settlement, for the deleted banner. */ + val deletion: TimelineLine? get() = detail?.takeIf { it.deleted }?.timeline?.lastOrNull { it.action == AuditAction.DELETE } +} + +/** The events of S-10, implemented by [SettlementDetailViewModel]. */ +interface SettlementDetailActions { + fun onDelete() + fun onConfirmDelete() + fun onDismissDialog() + fun onSyncNow() +} + +/** S-10's state holder: the settlement, its allocations and timeline from `SettlementRepository.detail`. */ +@HiltViewModel +class SettlementDetailViewModel @javax.inject.Inject constructor( + savedStateHandle: SavedStateHandle, + private val settlements: SettlementRepository, + accounts: AccountRepository, + home: HomeRepository, + private val sync: SyncRepository, +) : ViewModel(), SettlementDetailActions { + val id: SettlementId? = savedStateHandle.get(SettleArgs.SETTLEMENT_ID)?.let(::SettlementId) + private val local = MutableStateFlow(SettlementDetailUiState()) + + val state: StateFlow = combine( + id?.let { settlements.detail(it) } ?: flowOf(null), + accounts.all(), + home.home(), + local, + ) { detail, all, h, l -> + l.copy(loading = false, detail = detail, accounts = all, me = h.me, partner = h.partner) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), SettlementDetailUiState()) + + override fun onDelete() = local.update { it.copy(confirmDelete = true) } + + override fun onConfirmDelete() { + local.update { it.copy(confirmDelete = false) } + val target = id ?: return + viewModelScope.launch { + settlements.delete(target) + local.update { it.copy(deletedByMe = true) } + } + } + + override fun onDismissDialog() = local.update { it.copy(confirmDelete = false) } + + override fun onSyncNow() { + viewModelScope.launch { sync.syncNow() } + } + + private companion object { + const val STOP_TIMEOUT_MS = 5_000L + } +} diff --git a/android/feature/settle/src/main/res/values-sv/strings.xml b/android/feature/settle/src/main/res/values-sv/strings.xml new file mode 100644 index 0000000..4207301 --- /dev/null +++ b/android/feature/settle/src/main/res/values-sv/strings.xml @@ -0,0 +1,49 @@ + + + + Gör upp + Ändra betalning + Gör upp %1$s + Du betalar %1$s + %1$s betalar dig + Byt riktning + Riktning + Belopp + Ni är kvitt just nu. + Skriv ett belopp så föreslår Kvitt en fördelning. + Fördelning + Skuld %1$s + Ta bort %1$s från fördelningen + Det här reglerar mer än som är skyldigt i %1$s + Kvar att fördela + Lägg till konto + %1$s lämnas som det är, eftersom %2$s är skyldig dig där. + %1$s lämnas som det är, eftersom du är skyldig %2$s där. + Föreslå igen + Datum + Anteckning + Till exempel Swish + Bekräfta + Bekräfta betalningen + Du betalar %1$s %2$s %3$s. + %1$s betalar dig %2$s %3$s. + idag, %1$s + med datum %1$s + Kontona måste bli lika med beloppet + Varje konto behöver minst 0,01 kr + Lägg till minst ett konto + Högst 50 konton + Betalning + %1$s betalade dig + Du betalade %1$s + Ta bort betalningen? + Saldona räknas om för er båda. + %1$s tog bort den här betalningen · %2$s + Den räknas inte längre i saldona. + Betalningen finns inte på telefonen än + Den kan ha lagts till nyss på en annan telefon. Synka för att hämta den. + Belopp för %1$s + Ta bort betalning + Synka nu + diff --git a/android/feature/settle/src/main/res/values/strings.xml b/android/feature/settle/src/main/res/values/strings.xml new file mode 100644 index 0000000..2cf0a0a --- /dev/null +++ b/android/feature/settle/src/main/res/values/strings.xml @@ -0,0 +1,49 @@ + + + + Settle up + Edit payment + Settle %1$s + You pay %1$s + %1$s pays you + Swap direction + Direction + Amount + You are all settled up right now. + Enter an amount and Kvitt suggests an allocation. + Allocation + Debt %1$s + Remove %1$s from the allocation + This settles more than is owed in %1$s + Left to allocate + Add account + %1$s is left as it is, because %2$s owes you there. + %1$s is left as it is, because you owe %2$s there. + Suggest again + Date + Note + For example Swish + Confirm + Confirm the payment + You pay %1$s %2$s %3$s. + %1$s pays you %2$s %3$s. + today, %1$s + dated %1$s + The accounts must add up to the amount + Each account needs at least 0,01 + Add at least one account + At most 50 accounts + Payment + %1$s paid you + You paid %1$s + Delete the payment? + Balances are recalculated for both of you. + %1$s deleted this payment · %2$s + It no longer counts in the balances. + This payment is not on the phone yet + It may have just been added on another phone. Sync to fetch it. + Amount for %1$s + Delete payment + Sync now + diff --git a/android/feature/settle/src/test/kotlin/se/hagstedt/kvitt/feature/settle/AmountTextTest.kt b/android/feature/settle/src/test/kotlin/se/hagstedt/kvitt/feature/settle/AmountTextTest.kt new file mode 100644 index 0000000..b46f154 --- /dev/null +++ b/android/feature/settle/src/test/kotlin/se/hagstedt/kvitt/feature/settle/AmountTextTest.kt @@ -0,0 +1,44 @@ +package se.hagstedt.kvitt.feature.settle + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import se.hagstedt.kvitt.design.format.KvittLanguage.ENGLISH +import se.hagstedt.kvitt.design.format.KvittLanguage.SWEDISH + +/** The §9.6 input rules of the S-9 amount fields. */ +class AmountTextTest { + @Test + fun acceptsDigitsAndOneDecimalSeparator() { + assertEquals(325_000L, AmountText.parse("3 250,00", SWEDISH)?.ore) + assertEquals(325_000L, AmountText.parse("3250.00", SWEDISH)?.ore) + assertEquals(325_050L, AmountText.parse("3,250.5", ENGLISH)?.ore) + assertEquals(50L, AmountText.parse(",5", SWEDISH)?.ore) + assertEquals("0.", AmountText.parse(",", SWEDISH)?.raw) + assertEquals("", AmountText.parse("", SWEDISH)?.raw) + } + + @Test + fun leadingZeroIsReplaced() { + assertEquals("5", AmountText.parse("05", SWEDISH)?.raw) + assertEquals("0", AmountText.parse("0", SWEDISH)?.raw) + } + + @Test + fun rejectsSignsLettersThirdDecimalSecondSeparatorAndTooMuch() { + assertNull(AmountText.parse("-5", SWEDISH)) + assertNull(AmountText.parse("5a", SWEDISH)) + assertNull(AmountText.parse("1,234", SWEDISH)) + assertNull(AmountText.parse("1,2,3", SWEDISH)) + assertNull(AmountText.parse("10000000,01", SWEDISH)) + assertNull(AmountText.parse("123456789", SWEDISH)) + assertEquals(1_000_000_000L, AmountText.parse("10 000 000,00", SWEDISH)?.ore) + } + + @Test + fun storedAmountsShowTwoDecimals() { + assertEquals("3\u00A0250,00", AmountText.of(325_000).display(SWEDISH)) + assertEquals("165.50", AmountText.of(16_550).display(ENGLISH)) + assertEquals("", AmountText.of(null).display(SWEDISH)) + } +} diff --git a/android/feature/settle/src/test/kotlin/se/hagstedt/kvitt/feature/settle/SettleViewModelTest.kt b/android/feature/settle/src/test/kotlin/se/hagstedt/kvitt/feature/settle/SettleViewModelTest.kt new file mode 100644 index 0000000..5f05e4a --- /dev/null +++ b/android/feature/settle/src/test/kotlin/se/hagstedt/kvitt/feature/settle/SettleViewModelTest.kt @@ -0,0 +1,194 @@ +package se.hagstedt.kvitt.feature.settle + +import androidx.lifecycle.SavedStateHandle +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import se.hagstedt.kvitt.design.format.AmountInput +import se.hagstedt.kvitt.fakes.FakeRepositories +import se.hagstedt.kvitt.fakes.FakeScenario +import se.hagstedt.kvitt.fakes.FakeScenarios +import se.hagstedt.kvitt.fakes.settle.SettleScenarios +import se.hagstedt.kvitt.model.SettlementId + +/** S-9's state holder: defaults, re-proposals, enablement, warnings, error mapping and saving (AC-11.3 to AC-11.6). */ +@OptIn(ExperimentalCoroutinesApi::class) +class SettleViewModelTest { + @Before + fun main() = Dispatchers.setMain(UnconfinedTestDispatcher()) + + @After + fun reset() = Dispatchers.resetMain() + + private fun vm(scenario: FakeScenario, args: Map = emptyMap()): Pair { + val repos = FakeRepositories(scenario) + return SettleViewModel(SavedStateHandle(args), repos.settlements, repos.accounts, repos.home) to repos + } + + private val huset = SettleScenarios.huset.id + private val japan = SettleScenarios.japan.id + + @Test + fun globalDefaultsAreTheProposal() { + val s = vm(SettleScenarios.proposal).first.state.value + assertFalse(s.loading) + assertEquals(SettleMode.Global, s.mode) + assertEquals(FakeScenarios.owner.id, s.payer?.id) + assertEquals(325_000L, s.amount.ore) + assertEquals(listOf(huset to 308_450L, japan to 16_550L), s.rows.map { it.line.account.id to it.input.ore }) + assertEquals(0L, s.remainingOre) + assertTrue(s.canConfirm) + assertEquals(listOf(SettleScenarios.vardag.id), s.untouched.map { it.first.id }) + assertTrue(s.untouched.single().second > 0) + assertFalse(s.showProposeAgain) + assertTrue(s.showAddAccount) + } + + @Test + fun zeroBalanceGivesEmptyAmountAndNoRows() { + val s = vm(SettleScenarios.settledUp).first.state.value + assertNull(s.amount.ore) + assertTrue(s.rows.isEmpty()) + assertFalse(s.canConfirm) + assertFalse(s.showRemaining) + assertTrue(s.untouched.isEmpty()) + } + + @Test + fun amountChangeReproposesUntilARowIsEdited() { + val (model) = vm(SettleScenarios.proposal) + model.onAmount(AmountInput("1000")) + assertEquals(listOf(huset to 100_000L), model.state.value.rows.map { it.line.account.id to it.input.ore }) + model.onRowAmount(huset, AmountInput("400")) + model.onAmount(AmountInput("500")) + val s = model.state.value + assertEquals(listOf(huset to 40_000L), s.rows.map { it.line.account.id to it.input.ore }) + assertEquals(10_000L, s.remainingOre) + assertFalse(s.canConfirm) + assertTrue(s.showProposeAgain) + model.onProposeAgain() + assertEquals(listOf(huset to 50_000L), model.state.value.rows.map { it.line.account.id to it.input.ore }) + assertFalse(model.state.value.showProposeAgain) + } + + @Test + fun overSettlingWarnsAndConfirmOpensTheDialog() { + val (model, repos) = vm(SettleScenarios.proposal) + model.onRowAmount(huset, AmountInput("1750")) + model.onRowAmount(japan, AmountInput("1500")) + val s = model.state.value + assertEquals(listOf(japan), s.overSettled.map { it.line.account.id }) + assertTrue(s.canConfirm) + model.onConfirm() + assertEquals(SettleDialog.Confirm, model.state.value.dialog) + model.onConfirmDialog() + assertEquals(SettleFinish.Saved, model.state.value.finish) + assertEquals(1, repos.store.current.settlements.size) + } + + @Test + fun saveWithoutWarningsIsDirect() { + val (model, repos) = vm(SettleScenarios.proposal) + model.onConfirm() + assertNull(model.state.value.dialog) + assertEquals(SettleFinish.Saved, model.state.value.finish) + assertEquals(mapOf(huset to 308_450L, japan to 16_550L), repos.store.current.settlements.single().allocations) + } + + @Test + fun removeAndAddAccountsUpdateTheRows() { + val (model) = vm(SettleScenarios.proposal) + model.onRemoveRow(japan) + assertEquals(16_550L, model.state.value.remainingOre) + assertTrue(model.state.value.addable.any { it.archived }) + model.onAddAccountClick() + assertEquals(SettleDialog.AddAccount, model.state.value.dialog) + model.onAddAccount(SettleScenarios.stugan.id) + val s = model.state.value + assertNull(s.dialog) + assertEquals(listOf(huset, SettleScenarios.stugan.id), s.rows.map { it.line.account.id }) + assertTrue(s.rows.last().input.isEmpty) + assertFalse(s.canConfirm) + } + + @Test + fun swapReproposesForTheOtherPayer() { + val (model) = vm(SettleScenarios.proposal) + model.onRowAmount(huset, AmountInput("1")) + model.onSwap() + val s = model.state.value + assertEquals(FakeScenarios.partner.id, s.payer?.id) + assertFalse(s.rowsEdited) + assertEquals(listOf(SettleScenarios.vardag.id), s.rows.map { it.line.account.id }) + assertTrue(s.rows.single().line.overSettles) + } + + @Test + fun perAccountHasOneFixedRowThatFollowsTheAmount() { + val (model) = vm(SettleScenarios.proposal, mapOf(SettleArgs.ACCOUNT_ID to huset.value)) + assertEquals(SettleMode.Account, model.state.value.mode) + assertEquals(308_450L, model.state.value.amount.ore) + model.onAmount(AmountInput("100")) + val s = model.state.value + assertEquals(listOf(huset to 10_000L), s.rows.map { it.line.account.id to it.line.amountOre }) + assertFalse(s.showAddAccount) + assertFalse(s.showRemaining) + assertTrue(s.untouched.isEmpty()) + } + + @Test + fun editingStartsFromTheSavedSettlement() { + val (model, repos) = vm(SettleScenarios.detail, mapOf(SettleArgs.SETTLEMENT_ID to SettleScenarios.split.value)) + val s = model.state.value + assertEquals(SettleMode.Edit, s.mode) + assertEquals(250_000L, s.amount.ore) + assertEquals(listOf(huset to 200_000L, japan to 50_000L), s.rows.map { it.line.account.id to it.input.ore }) + assertTrue(s.rowsEdited) + model.onConfirm() + assertEquals(SettleFinish.Saved, model.state.value.finish) + assertEquals(3, repos.store.current.settlements.size) + assertEquals(SettlementId(SettleScenarios.split.value), repos.store.current.settlements.last().id) + } + + @Test + fun invalidNoteIsReportedAndClearedOnEdit() { + val (model) = vm(SettleScenarios.proposal) + model.onNote("x".repeat(201)) + model.onConfirm() + assertEquals("validation_failed", model.state.value.errorFor(SettleViewModel.FIELD_NOTE)) + assertNull(model.state.value.finish) + model.onNote("Swish") + assertNull(model.state.value.errorFor(SettleViewModel.FIELD_NOTE)) + } + + @Test + fun closeAsksOnlyWithChanges() { + val (clean) = vm(SettleScenarios.proposal) + clean.onClose() + assertEquals(SettleFinish.Closed, clean.state.value.finish) + val (dirty) = vm(SettleScenarios.proposal) + dirty.onNote("Swish") + dirty.onClose() + assertEquals(SettleDialog.Discard, dirty.state.value.dialog) + dirty.onDiscard() + assertEquals(SettleFinish.Closed, dirty.state.value.finish) + } + + @Test + fun offlineWorksUnchanged() { + val (model, repos) = vm(SettleScenarios.offline) + assertTrue(model.state.value.canConfirm) + model.onConfirm() + assertEquals(SettleFinish.Saved, model.state.value.finish) + assertTrue(repos.store.current.settlements.single().pending) + } +} diff --git a/android/feature/settle/src/test/kotlin/se/hagstedt/kvitt/feature/settle/SettlementDetailViewModelTest.kt b/android/feature/settle/src/test/kotlin/se/hagstedt/kvitt/feature/settle/SettlementDetailViewModelTest.kt new file mode 100644 index 0000000..e331ee1 --- /dev/null +++ b/android/feature/settle/src/test/kotlin/se/hagstedt/kvitt/feature/settle/SettlementDetailViewModelTest.kt @@ -0,0 +1,79 @@ +package se.hagstedt.kvitt.feature.settle + +import androidx.lifecycle.SavedStateHandle +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import se.hagstedt.kvitt.fakes.FakeRepositories +import se.hagstedt.kvitt.fakes.settle.SettleScenarios +import se.hagstedt.kvitt.model.AuditAction + +/** S-10's state holder: the detail, the missing and deleted states and deleting (AC-11.6, AC-12.4, AC-12.5). */ +@OptIn(ExperimentalCoroutinesApi::class) +class SettlementDetailViewModelTest { + @Before + fun main() = Dispatchers.setMain(UnconfinedTestDispatcher()) + + @After + fun reset() = Dispatchers.resetMain() + + private fun vm(id: String, repos: FakeRepositories = FakeRepositories(SettleScenarios.detail)) = + SettlementDetailViewModel(SavedStateHandle(mapOf(SettleArgs.SETTLEMENT_ID to id)), repos.settlements, repos.accounts, repos.home, repos.sync) + + @Test + fun loadedShowsTheSettlementAndItsActions() = runTest { + val model = vm(SettleScenarios.swish.value) + val s = model.state.first { !it.loading } + assertEquals(80_000L, s.detail?.amountOre) + assertTrue(s.actions) + assertNull(s.deletion) + } + + @Test + fun missingSettlementHasNoDetailAndNoActions() = runTest { + val s = vm(SettleScenarios.missing.value).state.first { !it.loading } + assertNull(s.detail) + assertFalse(s.actions) + } + + @Test + fun deletedHasTheDeletionLineAndNoActions() = runTest { + val s = vm(SettleScenarios.deleted.value).state.first { !it.loading } + assertFalse(s.actions) + assertEquals(AuditAction.DELETE, s.deletion?.action) + } + + @Test + fun pendingChangeEndsTheTimeline() = runTest { + val s = vm(SettleScenarios.split.value).state.first { !it.loading } + assertTrue(s.detail?.pending == true) + assertTrue(s.detail?.timeline?.last()?.pendingLocal == true) + } + + @Test + fun deleteAsksThenDeletes() = runTest { + val repos = FakeRepositories(SettleScenarios.detail) + val model = vm(SettleScenarios.swish.value, repos) + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { model.state.collect {} } + model.onDelete() + assertTrue(model.state.value.confirmDelete) + model.onDismissDialog() + assertFalse(model.state.value.confirmDelete) + model.onDelete() + model.onConfirmDelete() + assertTrue(model.state.value.deletedByMe) + assertTrue(repos.store.current.settlements.first { it.id == SettleScenarios.swish }.deleted) + } +} diff --git a/android/feature/settle/src/test/kotlin/se/hagstedt/kvitt/feature/settle/screenshot/ScreenSnapshots.kt b/android/feature/settle/src/test/kotlin/se/hagstedt/kvitt/feature/settle/screenshot/ScreenSnapshots.kt new file mode 100644 index 0000000..115f0e4 --- /dev/null +++ b/android/feature/settle/src/test/kotlin/se/hagstedt/kvitt/feature/settle/screenshot/ScreenSnapshots.kt @@ -0,0 +1,107 @@ +package se.hagstedt.kvitt.feature.settle.screenshot + +import android.content.res.Configuration +import android.os.LocaleList +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +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.material3.MaterialTheme +import androidx.compose.material3.Surface +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.Alignment +import androidx.compose.ui.Modifier +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.test.onRoot +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.github.takahirom.roborazzi.captureRoboImage +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 se.hagstedt.kvitt.feature.settle.OverlayHost +import se.hagstedt.kvitt.feature.settle.OverlayKind +import java.util.Locale +import kotlin.time.Instant + +/** + * Captures a whole screen once per theme and language: `src/test/screenshots/__.png`, plus + * `_sv_light_200.png` with [largeFont] (200 % font scale, Swedish, light, like the spec's 200 % frames). Pattern + * of `:core:design`'s `Snapshots` (M6): content set once, configuration switched between captures. The screen is the + * Galaxy S26's 411 dp wide and [height] high; the clock is fixed at 17 October 2026 14:02 in Stockholm. + */ +object ScreenSnapshots { + val clock = KvittClock.fixed(Instant.parse("2026-10-17T12:02:00Z"), TimeZone.of("Europe/Stockholm")) + + /** Normal phone height in dp; long-content states pass a taller one so every row is in the capture. */ + val PHONE_HEIGHT: Dp = 891.dp + + private const val SCRIM_ALPHA = 0.38f + + 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 "" + } + + /** Draws dialogs and sheets inside the screen over a 38 % scrim (design-spec §3.11), so the root capture shows them. */ + val inlineOverlays: OverlayHost = { kind, _, content -> + Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.scrim.copy(alpha = SCRIM_ALPHA))) { + when (kind) { + OverlayKind.Dialog -> Box(Modifier.align(Alignment.Center).padding(horizontal = KvittTheme.spacing.s6)) { content() } + OverlayKind.Sheet -> Surface( + Modifier.align(Alignment.BottomCenter).fillMaxWidth(), + shape = KvittTheme.shapes.sheetTop, + color = MaterialTheme.colorScheme.surfaceContainerLow, + ) { Box(Modifier.padding(top = KvittTheme.spacing.s6)) { content() } } + } + } + } + + fun snap(compose: ComposeContentTestRule, name: String, height: Dp = PHONE_HEIGHT, largeFont: Boolean = false, content: @Composable BoxScope.() -> 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.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) { + Box(Modifier.size(411.dp, height).background(MaterialTheme.colorScheme.surface), content = content) + } + } + } + variants.forEach { variant -> + current = variant + compose.waitForIdle() + compose.onRoot().captureRoboImage("src/test/screenshots/${name}_${variant.suffix}.png") + } + } +} diff --git a/android/feature/settle/src/test/kotlin/se/hagstedt/kvitt/feature/settle/screenshot/SettleScreenshotTest.kt b/android/feature/settle/src/test/kotlin/se/hagstedt/kvitt/feature/settle/screenshot/SettleScreenshotTest.kt new file mode 100644 index 0000000..31a01f9 --- /dev/null +++ b/android/feature/settle/src/test/kotlin/se/hagstedt/kvitt/feature/settle/screenshot/SettleScreenshotTest.kt @@ -0,0 +1,124 @@ +package se.hagstedt.kvitt.feature.settle.screenshot + +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.lifecycle.SavedStateHandle +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.design.format.AmountInput +import se.hagstedt.kvitt.fakes.FakeRepositories +import se.hagstedt.kvitt.fakes.FakeScenario +import se.hagstedt.kvitt.fakes.settle.SettleScenarios +import se.hagstedt.kvitt.feature.settle.SettleArgs +import se.hagstedt.kvitt.feature.settle.SettleContent +import se.hagstedt.kvitt.feature.settle.SettleViewModel +import se.hagstedt.kvitt.feature.settle.SettlementDetailContent +import se.hagstedt.kvitt.feature.settle.SettlementDetailViewModel + +/** One S-9 or S-10 state: the scenario, the navigation arguments and the user's steps before the capture. */ +private sealed interface Case { + val height: Dp + val largeFont: Boolean +} + +private data class Settle( + val scenario: FakeScenario, + val args: Map = emptyMap(), + override val height: Dp = ScreenSnapshots.PHONE_HEIGHT, + override val largeFont: Boolean = false, + val steps: SettleViewModel.() -> Unit = {}, +) : Case + +private data class Detail( + val scenario: FakeScenario, + val id: String, + override val height: Dp = ScreenSnapshots.PHONE_HEIGHT, + override val largeFont: Boolean = false, + val steps: SettlementDetailViewModel.() -> Unit = {}, +) : Case + +private fun amount(raw: String) = AmountInput(raw) + +private val husetId = SettleScenarios.huset.id +private val japanId = SettleScenarios.japan.id +private val longHeight = 1640.dp + +/** + * Every state of S-9 and S-10 (design-spec S-9, S-10; PLAN-M7d §5) × light/dark × Swedish/English, plus 200 % for the + * long S-9 state. The screens run on their real ViewModels over `FakeRepositories`, so what is captured is what the + * state holders produce. Re-record with `tools/gradle-serial.sh :feature:settle:recordRoborazziDebug`. + */ +private val cases: Map = linkedMapOf( + "settle_loaded" to Settle(SettleScenarios.proposal), + "settle_adjusted" to Settle(SettleScenarios.proposal) { onRowAmount(husetId, amount("1750")); onRowAmount(japanId, amount("1500")) }, + "settle_remaining" to Settle(SettleScenarios.proposal) { onRowAmount(husetId, amount("2000")); onRowAmount(japanId, amount("1000")) }, + "settle_account" to Settle(SettleScenarios.proposal, mapOf(SettleArgs.ACCOUNT_ID to husetId.value)), + "settle_swapped" to Settle(SettleScenarios.proposal) { onSwap() }, + "settle_add_account" to Settle(SettleScenarios.proposal) { onAddAccountClick() }, + "settle_confirm_dialog" to Settle(SettleScenarios.proposal) { + onRowAmount(husetId, amount("1750")); onRowAmount(japanId, amount("1500")); onConfirm() + }, + "settle_empty" to Settle(SettleScenarios.settledUp), + "settle_edit" to Settle(SettleScenarios.detail, mapOf(SettleArgs.SETTLEMENT_ID to SettleScenarios.split.value)), + "settle_validation_error" to Settle(SettleScenarios.proposal) { + onNote("Swish ".repeat(40)); onConfirm() + }, + "settle_offline" to Settle(SettleScenarios.offline), + "settle_long" to Settle(SettleScenarios.longContent, height = longHeight, largeFont = true), + "detail_loaded" to Detail(SettleScenarios.detail, SettleScenarios.swish.value), + "detail_several_pending" to Detail(SettleScenarios.detail, SettleScenarios.split.value), + "detail_deleted" to Detail(SettleScenarios.detail, SettleScenarios.deleted.value), + "detail_offline" to Detail(SettleScenarios.detailOffline, SettleScenarios.split.value), + "detail_delete_dialog" to Detail(SettleScenarios.detail, SettleScenarios.swish.value) { onDelete() }, + "detail_missing" to Detail(SettleScenarios.detail, SettleScenarios.missing.value), + "detail_long" to Detail(SettleScenarios.longDetail, SettleScenarios.long.value, height = longHeight), +) + +@RunWith(ParameterizedRobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [36], qualifiers = "w411dp-h2400dp-xxhdpi") +class SettleScreenshotTest(private val name: String) { + @get:Rule + val compose = createComposeRule() + + @Test + fun screen() { + when (val case = cases.getValue(name)) { + is Settle -> { + val repos = FakeRepositories(case.scenario) + val vm = SettleViewModel(SavedStateHandle(case.args), repos.settlements, repos.accounts, repos.home) + compose.waitForIdle() + vm.apply(case.steps) + compose.waitForIdle() + ScreenSnapshots.snap(compose, name, case.height, case.largeFont) { + val state by vm.state.collectAsState() + SettleContent(state, vm, overlayHost = ScreenSnapshots.inlineOverlays) + } + } + is Detail -> { + val repos = FakeRepositories(case.scenario) + val vm = SettlementDetailViewModel( + SavedStateHandle(mapOf(SettleArgs.SETTLEMENT_ID to case.id)), repos.settlements, repos.accounts, repos.home, repos.sync, + ) + vm.apply(case.steps) + ScreenSnapshots.snap(compose, name, case.height, case.largeFont) { + val state by vm.state.collectAsState() + SettlementDetailContent(state, vm, onBack = {}, onEdit = {}, overlayHost = ScreenSnapshots.inlineOverlays) + } + } + } + } + + companion object { + @JvmStatic + @ParameterizedRobolectricTestRunner.Parameters(name = "{0}") + fun names(): List> = cases.keys.map { arrayOf(it) } + } +}