M7d: feature settle, S-9 settle up and S-10 settlement detail
SettleScreen (global, per account, editing) and SettlementDetailScreen with Hilt ViewModels over the :core:model interfaces; proposals, remaining, warnings and enablement come from SettlementRepository. Fake scenarios in fakes/settle/, 19 states x sv/en x light/dark goldens (+ 200 % for long), unit tests for amount input and both state holders. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
@@ -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<Account> = 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<AccountId> = 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",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 = ' '
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
-14
@@ -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)
|
||||
}
|
||||
+466
@@ -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>): 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<FieldError>): 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<FieldError>): 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) }
|
||||
}
|
||||
+275
@@ -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<SettleRow> = 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<Account> = emptyList(),
|
||||
val viewerNets: Map<AccountId, Long> = emptyMap(),
|
||||
val errors: List<FieldError> = 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<Account> 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<Pair<Account, Long>>
|
||||
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<SettleRow> 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<String>(SettleArgs.SETTLEMENT_ID)?.let(::SettlementId)
|
||||
private val fixedAccountId: AccountId? = savedStateHandle.get<String>(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<SettleUiState> = 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)
|
||||
}
|
||||
+228
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+98
@@ -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<Account> = 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<String>(SettleArgs.SETTLEMENT_ID)?.let(::SettlementId)
|
||||
private val local = MutableStateFlow(SettlementDetailUiState())
|
||||
|
||||
val state: StateFlow<SettlementDetailUiState> = 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- :feature:settle strings (S-9, S-10). Source: docs/ux/strings.csv, keys with module=settle; the last three keys are
|
||||
proposed in the M7d report and not yet in strings.csv. -->
|
||||
<resources>
|
||||
<string name="settle_title">Gör upp</string>
|
||||
<string name="settle_title_edit">Ändra betalning</string>
|
||||
<string name="settle_title_account">Gör upp %1$s</string>
|
||||
<string name="settle_you_pay">Du betalar %1$s</string>
|
||||
<string name="settle_partner_pays">%1$s betalar dig</string>
|
||||
<string name="settle_swap">Byt riktning</string>
|
||||
<string name="settle_direction">Riktning</string>
|
||||
<string name="settle_amount">Belopp</string>
|
||||
<string name="settle_empty_settled">Ni är kvitt just nu.</string>
|
||||
<string name="settle_empty_hint">Skriv ett belopp så föreslår Kvitt en fördelning.</string>
|
||||
<string name="settle_allocation">Fördelning</string>
|
||||
<string name="settle_row_debt">Skuld %1$s</string>
|
||||
<string name="settle_row_remove">Ta bort %1$s från fördelningen</string>
|
||||
<string name="settle_over_settle_warning">Det här reglerar mer än som är skyldigt i %1$s</string>
|
||||
<string name="settle_remaining">Kvar att fördela</string>
|
||||
<string name="settle_add_account">Lägg till konto</string>
|
||||
<string name="settle_untouched_partner_owes">%1$s lämnas som det är, eftersom %2$s är skyldig dig där.</string>
|
||||
<string name="settle_untouched_you_owe">%1$s lämnas som det är, eftersom du är skyldig %2$s där.</string>
|
||||
<string name="settle_propose_again">Föreslå igen</string>
|
||||
<string name="settle_date">Datum</string>
|
||||
<string name="settle_note">Anteckning</string>
|
||||
<string name="settle_note_hint">Till exempel Swish</string>
|
||||
<string name="settle_confirm">Bekräfta</string>
|
||||
<string name="settle_confirm_dialog_title">Bekräfta betalningen</string>
|
||||
<string name="settle_confirm_body_you_pay">Du betalar %1$s %2$s %3$s.</string>
|
||||
<string name="settle_confirm_body_partner_pays">%1$s betalar dig %2$s %3$s.</string>
|
||||
<string name="settle_confirm_date_today">idag, %1$s</string>
|
||||
<string name="settle_confirm_date">med datum %1$s</string>
|
||||
<string name="settle_error_allocation_sum">Kontona måste bli lika med beloppet</string>
|
||||
<string name="settle_error_allocation_positive">Varje konto behöver minst 0,01 kr</string>
|
||||
<string name="settle_error_no_allocations">Lägg till minst ett konto</string>
|
||||
<string name="settle_error_too_many_allocations">Högst 50 konton</string>
|
||||
<string name="settle_detail_title">Betalning</string>
|
||||
<string name="settle_detail_partner_paid_you">%1$s betalade dig</string>
|
||||
<string name="settle_detail_you_paid_partner">Du betalade %1$s</string>
|
||||
<string name="settle_detail_delete_title">Ta bort betalningen?</string>
|
||||
<string name="settle_detail_delete_body">Saldona räknas om för er båda.</string>
|
||||
<string name="settle_detail_deleted_banner">%1$s tog bort den här betalningen · %2$s</string>
|
||||
<string name="settle_detail_deleted_body">Den räknas inte längre i saldona.</string>
|
||||
<string name="settle_detail_missing">Betalningen finns inte på telefonen än</string>
|
||||
<string name="settle_detail_missing_body">Den kan ha lagts till nyss på en annan telefon. Synka för att hämta den.</string>
|
||||
<string name="settle_row_amount">Belopp för %1$s</string>
|
||||
<string name="settle_detail_delete">Ta bort betalning</string>
|
||||
<string name="settle_detail_sync_now">Synka nu</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,49 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- :feature:settle strings (S-9, S-10). Source: docs/ux/strings.csv, keys with module=settle; the last three keys are
|
||||
proposed in the M7d report and not yet in strings.csv. -->
|
||||
<resources>
|
||||
<string name="settle_title">Settle up</string>
|
||||
<string name="settle_title_edit">Edit payment</string>
|
||||
<string name="settle_title_account">Settle %1$s</string>
|
||||
<string name="settle_you_pay">You pay %1$s</string>
|
||||
<string name="settle_partner_pays">%1$s pays you</string>
|
||||
<string name="settle_swap">Swap direction</string>
|
||||
<string name="settle_direction">Direction</string>
|
||||
<string name="settle_amount">Amount</string>
|
||||
<string name="settle_empty_settled">You are all settled up right now.</string>
|
||||
<string name="settle_empty_hint">Enter an amount and Kvitt suggests an allocation.</string>
|
||||
<string name="settle_allocation">Allocation</string>
|
||||
<string name="settle_row_debt">Debt %1$s</string>
|
||||
<string name="settle_row_remove">Remove %1$s from the allocation</string>
|
||||
<string name="settle_over_settle_warning">This settles more than is owed in %1$s</string>
|
||||
<string name="settle_remaining">Left to allocate</string>
|
||||
<string name="settle_add_account">Add account</string>
|
||||
<string name="settle_untouched_partner_owes">%1$s is left as it is, because %2$s owes you there.</string>
|
||||
<string name="settle_untouched_you_owe">%1$s is left as it is, because you owe %2$s there.</string>
|
||||
<string name="settle_propose_again">Suggest again</string>
|
||||
<string name="settle_date">Date</string>
|
||||
<string name="settle_note">Note</string>
|
||||
<string name="settle_note_hint">For example Swish</string>
|
||||
<string name="settle_confirm">Confirm</string>
|
||||
<string name="settle_confirm_dialog_title">Confirm the payment</string>
|
||||
<string name="settle_confirm_body_you_pay">You pay %1$s %2$s %3$s.</string>
|
||||
<string name="settle_confirm_body_partner_pays">%1$s pays you %2$s %3$s.</string>
|
||||
<string name="settle_confirm_date_today">today, %1$s</string>
|
||||
<string name="settle_confirm_date">dated %1$s</string>
|
||||
<string name="settle_error_allocation_sum">The accounts must add up to the amount</string>
|
||||
<string name="settle_error_allocation_positive">Each account needs at least 0,01</string>
|
||||
<string name="settle_error_no_allocations">Add at least one account</string>
|
||||
<string name="settle_error_too_many_allocations">At most 50 accounts</string>
|
||||
<string name="settle_detail_title">Payment</string>
|
||||
<string name="settle_detail_partner_paid_you">%1$s paid you</string>
|
||||
<string name="settle_detail_you_paid_partner">You paid %1$s</string>
|
||||
<string name="settle_detail_delete_title">Delete the payment?</string>
|
||||
<string name="settle_detail_delete_body">Balances are recalculated for both of you.</string>
|
||||
<string name="settle_detail_deleted_banner">%1$s deleted this payment · %2$s</string>
|
||||
<string name="settle_detail_deleted_body">It no longer counts in the balances.</string>
|
||||
<string name="settle_detail_missing">This payment is not on the phone yet</string>
|
||||
<string name="settle_detail_missing_body">It may have just been added on another phone. Sync to fetch it.</string>
|
||||
<string name="settle_row_amount">Amount for %1$s</string>
|
||||
<string name="settle_detail_delete">Delete payment</string>
|
||||
<string name="settle_detail_sync_now">Sync now</string>
|
||||
</resources>
|
||||
+44
@@ -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))
|
||||
}
|
||||
}
|
||||
+194
@@ -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<String, String> = emptyMap()): Pair<SettleViewModel, FakeRepositories> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
+79
@@ -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)
|
||||
}
|
||||
}
|
||||
+107
@@ -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/<name>_<sv|en>_<light|dark>.png`, plus
|
||||
* `<name>_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")
|
||||
}
|
||||
}
|
||||
}
|
||||
+124
@@ -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<String, String> = 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<String, Case> = 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<Array<Any>> = cases.keys.map { arrayOf<Any>(it) }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user