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:
2026-10-18 10:44:43 +02:00
parent ea7d494ffa
commit 7380e50ae3
16 changed files with 2002 additions and 14 deletions
+19
View File
@@ -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() }
}
}
@@ -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)
}
@@ -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) }
}
@@ -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)
}
@@ -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)
}
}
}
@@ -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>
@@ -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))
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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")
}
}
}
@@ -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) }
}
}