M7c: feature expense, S-7 editor and S-8 detail with timeline

Expense editor (S-7) and expense detail (S-8) in :feature:expense against the
:core:model interfaces and :core:design components: defaults (AC-8.6), keypad
input (§9.6), the four split modes with live preview (AC-8.5, AC-8.7, AC-8.8),
category suggestion with the picked-by-hand flag (CS-2, CS-3, CS-7), quick row
from frequentCategoryIds (D-059, D-060), inline errors, delete and discard
confirmations; detail with hero, facts, timeline, recurring, deleted and missing
states. Fake scenarios in fakes/expense, view-model tests and 127 goldens.

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:54:16 +02:00
parent 0e1d99df93
commit 0b140aa3ad
15 changed files with 2520 additions and 14 deletions
@@ -0,0 +1,266 @@
package se.hagstedt.kvitt.fakes.expense
import se.hagstedt.kvitt.fakes.FakeExpense
import se.hagstedt.kvitt.fakes.FakeScenario
import se.hagstedt.kvitt.fakes.FakeScenarios
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.Category
import se.hagstedt.kvitt.model.CategoryId
import se.hagstedt.kvitt.model.ColorToken
import se.hagstedt.kvitt.model.DefaultCategoryKey
import se.hagstedt.kvitt.model.ExpenseDraft
import se.hagstedt.kvitt.model.ExpenseId
import se.hagstedt.kvitt.model.FieldChange
import se.hagstedt.kvitt.model.RecurringTemplate
import se.hagstedt.kvitt.model.Share
import se.hagstedt.kvitt.model.SplitMode
import se.hagstedt.kvitt.model.TemplateId
import se.hagstedt.kvitt.model.TimelineLine
import se.hagstedt.kvitt.model.UserId
/**
* Scenarios of `:feature:expense` (S-7 expense editor, S-8 expense detail; PLAN-M7c §5), as in the design spec's
* frames: Jacob (the viewer) and Anna, the accounts Vardag (50/50), Huset (60/40), Japan 2027 and the archived Gotland
* 2026, and the expenses the frames show. The phone's clock is 17 October 2026 14:02 in Stockholm.
*/
object ExpenseScenarios {
private val jacob = FakeScenarios.owner
private val anna = FakeScenarios.partner
private fun category(key: DefaultCategoryKey): Category = FakeScenarios.defaultCategories.single { it.defaultKey == key }
val groceries = category(DefaultCategoryKey.GROCERIES)
val restaurants = category(DefaultCategoryKey.RESTAURANTS)
val housing = category(DefaultCategoryKey.HOUSING)
val transport = category(DefaultCategoryKey.TRANSPORT)
val entertainment = category(DefaultCategoryKey.ENTERTAINMENT)
val shopping = category(DefaultCategoryKey.SHOPPING)
val other = category(DefaultCategoryKey.OTHER)
private fun shares(jacobBp: Int) = mapOf(jacob.id to jacobBp, anna.id to 10_000 - jacobBp)
val vardag = Account(AccountId("01920000-0000-7000-8000-0000000000b1"), "Vardag", shares(5000), "2026-01-04T09:00:00.000Z", archived = false)
val huset = Account(AccountId("01920000-0000-7000-8000-0000000000b2"), "Huset", shares(6000), "2026-01-05T09:00:00.000Z", archived = false)
val japan = Account(AccountId("01920000-0000-7000-8000-0000000000b3"), "Japan 2027", shares(5000), "2026-03-01T09:00:00.000Z", archived = false)
val gotland = Account(AccountId("01920000-0000-7000-8000-0000000000b4"), "Gotland 2026", shares(5000), "2026-05-20T09:00:00.000Z", archived = true)
val sommarstugan = Account(
AccountId("01920000-0000-7000-8000-0000000000b5"),
"Sommarstugan i Österåker 2027",
shares(6000),
"2026-10-01T09:00:00.000Z",
archived = false,
)
/** A custom category with a long name (long-content states). */
val renovation = Category(CategoryId("01920000-0000-7000-8000-0000000000c2"), null, "Renovering av sommarstugan", "build", ColorToken.AMBER, 110, archived = false)
val icaId = ExpenseId("01920000-0000-7000-8000-0000000000e1")
val systembolagetId = ExpenseId("01920000-0000-7000-8000-0000000000e2")
val slKortId = ExpenseId("01920000-0000-7000-8000-0000000000e3")
val hyraId = ExpenseId("01920000-0000-7000-8000-0000000000e4")
val ullaredId = ExpenseId("01920000-0000-7000-8000-0000000000e5")
val trananId = ExpenseId("01920000-0000-7000-8000-0000000000e6")
val snickareId = ExpenseId("01920000-0000-7000-8000-0000000000e7")
/** An id the phone does not hold (S-8 missing). */
val missingId = ExpenseId("01920000-0000-7000-8000-0000000000ef")
private val hyraTemplate = TemplateId("01920000-0000-7000-8000-0000000000f1")
private fun created(by: UserId?, at: String, source: AuditSource = AuditSource.APP) =
TimelineLine(if (by == null) null else if (by == jacob.id) jacob else anna, AuditAction.CREATE, source, at, emptyList(), pendingLocal = false)
private fun changed(by: UserId, at: String, vararg changes: FieldChange) =
TimelineLine(if (by == jacob.id) jacob else anna, AuditAction.UPDATE, AuditSource.APP, at, changes.toList(), pendingLocal = false)
private fun even(amount: Long) = listOf(Share(jacob.id, amount / 2, 5000), Share(anna.id, amount - amount / 2, 5000))
/** S-8 loaded + pending: Anna paid, account default 50/50, notes, created and changed twice, one change pending. */
val ica = FakeExpense(
id = icaId,
accountId = vardag.id,
categoryId = groceries.id,
amountOre = 124_760,
description = "ICA Maxi Lindhagen",
notes = "Storhandling inför middagen med Lindqvists",
date = "2026-10-16",
payer = anna.id,
mode = SplitMode.DEFAULT,
shares = even(124_760),
createdAt = "2026-10-16T12:02:00.000Z",
pending = true,
timeline = listOf(
created(anna.id, "2026-10-16T12:02:00.000Z"),
changed(jacob.id, "2026-10-16T17:40:00.000Z", FieldChange("amount_ore", "114760", "124760")),
changed(anna.id, "2026-10-16T18:05:00.000Z", FieldChange("category_id", "other", "groceries")),
),
)
/** History for the category suggestion: "Systembolaget" was Nöje before (CS-5 history exact). */
val systembolaget = FakeExpense(
id = systembolagetId,
accountId = vardag.id,
categoryId = entertainment.id,
amountOre = 41_200,
description = "Systembolaget",
notes = "",
date = "2026-10-10",
payer = jacob.id,
mode = SplitMode.DEFAULT,
shares = even(41_200),
createdAt = "2026-10-10T15:20:00.000Z",
timeline = listOf(created(jacob.id, "2026-10-10T15:20:00.000Z")),
)
/** Full split on Anna, paid by Jacob (S-7 full + notes in edit mode, S-8 full). */
val slKort = FakeExpense(
id = slKortId,
accountId = vardag.id,
categoryId = transport.id,
amountOre = 106_000,
description = "SL-kort 30 dagar, Anna",
notes = "Månadskort för oktober, köpt i SL-appen",
date = "2026-10-14",
payer = jacob.id,
mode = SplitMode.FULL,
shares = listOf(Share(jacob.id, 0, 0), Share(anna.id, 106_000, 10_000)),
createdAt = "2026-10-14T07:30:00.000Z",
timeline = listOf(created(jacob.id, "2026-10-14T07:30:00.000Z")),
)
/** Generated by the template "Hyra" (AC-8.11), later edited by Anna. */
val hyra = FakeExpense(
id = hyraId,
accountId = huset.id,
categoryId = housing.id,
amountOre = 1_450_000,
description = "Hyra september",
notes = "",
date = "2026-09-25",
payer = jacob.id,
mode = SplitMode.DEFAULT,
shares = listOf(Share(jacob.id, 870_000, 6000), Share(anna.id, 580_000, 4000)),
createdAt = "2026-09-25T04:00:00.000Z",
recurringTemplateId = hyraTemplate,
timeline = listOf(
created(null, "2026-09-25T04:00:00.000Z", AuditSource.RECURRING),
changed(anna.id, "2026-09-26T08:15:00.000Z", FieldChange("description", "Hyra", "Hyra september")),
),
)
/** Exact amounts: Jacob 400,00 kr, Anna 660,00 kr; Anna paid. */
val ullared = FakeExpense(
id = ullaredId,
accountId = vardag.id,
categoryId = shopping.id,
amountOre = 106_000,
description = "Ullared",
notes = "",
date = "2026-10-03",
payer = anna.id,
mode = SplitMode.EXACT,
shares = listOf(Share(jacob.id, 40_000, null), Share(anna.id, 66_000, null)),
createdAt = "2026-10-03T16:45:00.000Z",
timeline = listOf(created(anna.id, "2026-10-03T16:45:00.000Z")),
)
/** Custom ratio 70/30, moved from Vardag to Huset; the last change is waiting to sync (S-8 timeline). */
val tranan = FakeExpense(
id = trananId,
accountId = huset.id,
categoryId = restaurants.id,
amountOre = 156_000,
description = "Middag på Tranan",
notes = "",
date = "2026-10-12",
payer = jacob.id,
mode = SplitMode.RATIO,
shares = listOf(Share(jacob.id, 109_200, 7000), Share(anna.id, 46_800, 3000)),
createdAt = "2026-10-12T19:10:00.000Z",
pending = true,
timeline = listOf(
created(jacob.id, "2026-10-12T19:10:00.000Z"),
changed(
anna.id,
"2026-10-13T07:45:00.000Z",
FieldChange("amount_ore", "120000", "156000"),
FieldChange("account_id", "Vardag", "Huset"),
),
changed(jacob.id, "2026-10-13T20:30:00.000Z", FieldChange("split_mode", "default", "ratio")),
),
)
/** Long content: a 7-digit amount, a long description, account and category name, and a long history. */
val snickare = FakeExpense(
id = snickareId,
accountId = sommarstugan.id,
categoryId = renovation.id,
amountOre = 123_456_789,
description = "Snickare Bergström, nytt golv i hallen, köket och vardagsrummet",
notes = "Offert 2026-08-30. Andra delbetalningen av tre; den sista betalas när besiktningen är klar i november.",
date = "2026-10-02",
payer = anna.id,
mode = SplitMode.DEFAULT,
shares = listOf(Share(jacob.id, 74_074_073, 6000), Share(anna.id, 49_382_716, 4000)),
createdAt = "2026-10-02T10:00:00.000Z",
pending = true,
timeline = listOf(
created(anna.id, "2026-10-02T10:00:00.000Z"),
changed(jacob.id, "2026-10-02T18:30:00.000Z", FieldChange("amount_ore", "120000000", "123456789")),
changed(anna.id, "2026-10-03T07:05:00.000Z", FieldChange("description", "Snickare", "Snickare Bergström, nytt golv i hallen, köket och vardagsrummet")),
changed(anna.id, "2026-10-03T07:06:00.000Z", FieldChange("category_id", "home", "Renovering av sommarstugan")),
changed(jacob.id, "2026-10-04T21:15:00.000Z", FieldChange("expense_date", "2026-10-01", "2026-10-02"), FieldChange("payer_user_id", "Jacob", "Anna")),
changed(anna.id, "2026-10-05T08:00:00.000Z", FieldChange("notes", null, "Offert 2026-08-30. Andra delbetalningen av tre; den sista betalas när besiktningen är klar i november.")),
changed(jacob.id, "2026-10-06T12:40:00.000Z", FieldChange("account_id", "Huset", "Sommarstugan i Österåker 2027")),
),
)
private val hyraDraft = ExpenseDraft(null, huset.id, housing.id, 1_450_000, "Hyra", "", "2026-09-25", jacob.id, SplitMode.DEFAULT, null, null, null)
/** Loaded, online and synced except the pending rows; Vardag was the last account used, groceries its last category. */
val household: FakeScenario = FakeScenarios.empty.copy(
accounts = listOf(vardag, huset, japan, gotland),
expenses = listOf(ica, systembolaget, slKort, hyra, ullared, tranan),
templates = listOf(RecurringTemplate(hyraTemplate, hyraDraft, 25, "2026-01", null, paused = false, nextOccurrence = "2026-10-25")),
now = "2026-10-17T12:02:00.000Z",
defaultAccountId = vardag.id,
lastCategoryFor = mapOf(vardag.id to groceries.id, huset.id to housing.id),
)
/** The same household without a connection (S-7 saving still allowed, S-8 unchanged). */
val offline: FakeScenario = household.copy(online = false)
/** Long names and amounts: the long account and category exist, the long expense is in the long account. */
val long: FakeScenario = household.copy(
accounts = household.accounts + sommarstugan,
categories = household.categories + renovation,
expenses = household.expenses + snickare,
defaultAccountId = sommarstugan.id,
lastCategoryFor = household.lastCategoryFor + (sommarstugan.id to renovation.id),
)
/** ICA Maxi deleted by Anna on 17 October 09:12 (S-8 deleted, opened from a deletion notification). */
val deleted: FakeScenario = household.copy(
expenses = household.expenses.map { e ->
if (e.id != icaId) e else e.copy(
deleted = true,
pending = false,
timeline = e.timeline + TimelineLine(anna, AuditAction.DELETE, AuditSource.APP, "2026-10-17T07:12:00.000Z", emptyList(), pendingLocal = false),
)
},
)
/**
* No category to fall back to (AC-8.6, v0.9): `other` is archived and nothing was used on this phone, so a new
* expense starts without a category until the user picks one.
*/
val noDefaultCategory: FakeScenario = household.copy(
categories = household.categories.map { if (it.id == other.id) it.copy(archived = true) else it },
expenses = emptyList(),
templates = emptyList(),
lastCategoryFor = emptyMap(),
)
}
+17
View File
@@ -1,4 +1,21 @@
// :feature:expense — Expense editor, expense detail and timeline (S-7, S-8; M7c). Convention: android/README.md.
plugins {
id("kvitt.android.feature")
alias(libs.plugins.roborazzi)
}
roborazzi {
outputDir.set(file("src/test/screenshots"))
}
dependencies {
// BackHandler: back with the keypad up hides it instead of closing the editor (design-spec §3.7).
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,274 @@
package se.hagstedt.kvitt.feature.expense
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
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.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import se.hagstedt.kvitt.design.KvittTheme
import se.hagstedt.kvitt.core.design.R as DesignR
import se.hagstedt.kvitt.design.component.AmountStyle
import se.hagstedt.kvitt.design.component.AmountTone
import se.hagstedt.kvitt.design.component.BadgeKind
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.CategoryTile
import se.hagstedt.kvitt.design.component.EmptyState
import se.hagstedt.kvitt.design.component.FactRow
import se.hagstedt.kvitt.design.component.KvittBadge
import se.hagstedt.kvitt.design.component.KvittIconImage
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.NeedsConnectionHint
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.SplitBar
import se.hagstedt.kvitt.design.component.SplitPreview
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.categoryName
import se.hagstedt.kvitt.design.component.defaultModeLabel
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.shareText
import se.hagstedt.kvitt.design.component.spokenMoney
import se.hagstedt.kvitt.design.component.splitModeLabel
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.AuditAction
import se.hagstedt.kvitt.model.ExpenseDetailState
import se.hagstedt.kvitt.model.ExpenseId
import se.hagstedt.kvitt.model.SplitMode
import se.hagstedt.kvitt.model.User
import kotlin.time.Instant
/** What S-8 can ask its state holder to do; [ExpenseDetailViewModel] implements it. */
interface ExpenseDetailActions {
fun askDelete()
fun dismissDelete()
fun confirmDelete()
fun syncNow()
}
/**
* S-8, the expense detail with its timeline (design-spec §4 S-8; SPEC AC-8.11, AC-12.4, AC-12.5). [onEdit] opens S-7
* in edit mode; [onDeleted] goes back with the snackbar text "Utgiften togs bort".
*/
@Composable
fun ExpenseDetailScreen(
onBack: () -> Unit,
onEdit: (ExpenseId) -> Unit,
onDeleted: (String) -> Unit,
modifier: Modifier = Modifier,
viewModel: ExpenseDetailViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val deleted = stringResource(DesignR.string.snackbar_expense_deleted)
LaunchedEffect(viewModel) {
viewModel.eventFlow.collect { event -> if (event == DetailEvent.Deleted) onDeleted(deleted) }
}
ExpenseDetailContent(state, viewModel, onBack, onEdit, modifier)
}
/** The stateless S-8 layout. */
@Composable
fun ExpenseDetailContent(
state: ExpenseDetailUiState,
actions: ExpenseDetailActions,
onBack: () -> Unit,
onEdit: (ExpenseId) -> Unit,
modifier: Modifier = Modifier,
) {
val detail = state.detail
val me = state.me
Surface(modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {
if (me == null || state.loading) return@Surface
ProvideHousehold(me, state.partner) {
Column(Modifier.fillMaxSize().statusBarsPadding().navigationBarsPadding()) {
KvittTopAppBar(title = "", navigation = TopBarNavigation.Back, onNavigation = onBack) {
if (detail != null && !detail.deleted) {
IconButton({ onEdit(detail.id) }) { KvittIconImage(KvittIcon.Edit, stringResource(R.string.expense_editor_edit)) }
IconButton(actions::askDelete) { KvittIconImage(KvittIcon.Delete, stringResource(R.string.expense_editor_delete)) }
}
}
Column(
Modifier
.weight(1f)
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(start = KvittTheme.spacing.gutter, end = KvittTheme.spacing.gutter, bottom = KvittTheme.spacing.s6),
verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.detailGap),
) {
if (detail == null) Missing(state, actions) else Loaded(detail, state)
}
}
if (state.confirmDelete) DeleteExpenseDialog(actions::confirmDelete, actions::dismissDelete)
}
}
}
@Composable
private fun ColumnScope.Missing(state: ExpenseDetailUiState, actions: ExpenseDetailActions) {
EmptyState(
title = stringResource(R.string.expense_detail_missing),
body = stringResource(R.string.expense_detail_missing_body),
action = stringResource(R.string.expense_detail_sync_now),
actionIcon = KvittIcon.Sync,
actionKind = ButtonKind.Tonal,
onAction = { if (state.online && !state.syncing) actions.syncNow() },
)
if (!state.online) NeedsConnectionHint(Modifier.align(Alignment.CenterHorizontally))
}
@Composable
private fun Loaded(detail: ExpenseDetailState, state: ExpenseDetailUiState) {
val me = state.me ?: return
val partner = state.partner ?: return
if (detail.deleted) DeletedBanner(detail)
val faded = if (detail.deleted) Modifier.alpha(KvittDimens.DELETED_ALPHA) else Modifier
Hero(detail, faded)
val showNotes = detail.notes.isNotEmpty()
SegmentedGroup(if (showNotes) FACTS_WITH_NOTES else FACTS, faded) { item ->
when (item.index) {
0 -> FactRow(item, KvittIcon.Event, stringResource(R.string.expense_detail_date), detailDate(detail.date))
1 -> FactRow(
item,
KvittIcon.Person,
stringResource(R.string.expense_detail_paid_by),
personName(detail.payer),
valueLeading = { PersonAvatar(detail.payer, KvittDimens.avatarChip) },
)
2 -> {
val mode = modeLabel(detail, me, partner)
val shares = shareText(detail.shares, me, partner, spoken = true)
FactRow(
item,
KvittIcon.CallSplit,
stringResource(R.string.expense_detail_split),
mode,
spoken = stringResource(R.string.expense_detail_split) + ": " + mode + stringResource(DesignR.string.common_separator_comma) + shares,
extra = {
Column(Modifier.padding(top = KvittTheme.spacing.s2), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2)) {
SplitBar(viewerPercent(detail, me), thin = true)
SplitPreview(detail.shares, me, partner, showBar = false)
}
},
)
}
else -> FactRow(item, KvittIcon.Notes, stringResource(R.string.expense_detail_notes), detail.notes)
}
}
Timeline(detail.timeline)
}
/** Hero card: category tile 60, description over "Mat & hushåll · Vardag", amount with the pending marker, recurring badge. */
@Composable
private fun Hero(detail: ExpenseDetailState, modifier: Modifier) {
val categoryLabel = categoryName(detail.category)
val subtitle = categoryLabel + stringResource(DesignR.string.common_separator_dot) + detail.account.name
val spoken = listOfNotNull(
detail.description,
categoryLabel,
detail.account.name,
spokenMoney(detail.amountOre),
if (detail.pending) stringResource(DesignR.string.common_pending_sync).lowercase() else null,
detail.recurringTemplateName?.let { stringResource(R.string.expense_detail_recurring, it) },
).joinToString(stringResource(DesignR.string.common_separator_comma))
Column(
modifier
.fillMaxWidth()
.background(KvittTheme.colors.categoryContainer(detail.category.color), KvittTheme.shapes.card)
.clearAndSetSemantics { contentDescription = spoken }
.padding(KvittTheme.spacing.s5),
verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3),
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s4)) {
CategoryTile(detail.category, KvittDimens.tileHero, archived = detail.deleted || detail.category.archived)
Column(Modifier.weight(1f)) {
Text(detail.description, style = MaterialTheme.typography.titleLarge)
Text(subtitle, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2)) {
MoneyText(detail.amountOre, AmountStyle.Large, AmountTone.Neutral)
if (detail.pending) SyncPendingMarker(size = KvittDimens.iconButton, standalone = false)
}
detail.recurringTemplateName?.let {
KvittBadge(stringResource(R.string.expense_detail_recurring, it), kind = BadgeKind.Lantern, icon = KvittIcon.EventRepeat)
}
}
}
/** "Anna tog bort den här utgiften · 17 okt. 09:12" from the timeline's delete entry (design-spec S-8 deleted). */
@Composable
private fun DeletedBanner(detail: ExpenseDetailState) {
val entry = detail.timeline.lastOrNull { it.action == AuditAction.DELETE && !it.pendingLocal }
val clock = LocalKvittClock.current
val language = kvittLanguage()
val actor = entry?.actor?.let { personName(it) } ?: personName(detail.payer)
val time = entry?.let { DateFormatter.listDateTime(Instant.parse(it.clientAt), clock.zone, clock.today, language) }
val title = if (time == null) {
stringResource(DesignR.string.timeline_deleted, actor)
} else {
stringResource(R.string.expense_detail_deleted_banner, actor, time)
}
Banner(BannerKind.Neutral, KvittIcon.Delete, title, body = stringResource(R.string.expense_detail_deleted_body), iconTint = MaterialTheme.colorScheme.error)
}
/** "Kontots standard 50/50" / "Egen fördelning 70/30" / "Exakta belopp" / "Allt på Anna" (design-spec S-8). */
@Composable
private fun modeLabel(detail: ExpenseDetailState, me: User, partner: User): String {
val language = kvittLanguage()
val mineBp = detail.shares.firstOrNull { it.userId == me.id }?.shareBp
val ratio = mineBp?.let { stringResource(DesignR.string.common_ratio, MoneyFormatter.basisPoints(it, language), MoneyFormatter.basisPoints(BP_TOTAL - it, language)) }
return when (detail.mode) {
SplitMode.DEFAULT -> if (ratio != null) stringResource(DesignR.string.editor_split_mode_default, ratio) else defaultModeLabel(HALF_PERCENT)
SplitMode.RATIO -> splitModeLabel(SplitMode.RATIO, "") + (ratio?.let { " $it" } ?: "")
SplitMode.EXACT -> splitModeLabel(SplitMode.EXACT, "")
SplitMode.FULL -> {
val bearer = detail.shares.firstOrNull { it.shareOre > 0 }?.userId
if (bearer == me.id) stringResource(R.string.expense_split_full_on_you) else stringResource(DesignR.string.editor_split_full_on, partner.displayName)
}
}
}
private fun viewerPercent(detail: ExpenseDetailState, me: User): Float {
val total = detail.shares.sumOf { it.shareOre }
val mine = detail.shares.firstOrNull { it.userId == me.id }?.shareOre ?: 0L
return if (total == 0L) HALF_PERCENT.toFloat() else mine * FULL_PERCENT / total
}
private const val FACTS = 3
private const val FACTS_WITH_NOTES = 4
private const val BP_TOTAL = 10_000
private const val HALF_PERCENT = 50
private const val FULL_PERCENT = 100f
@@ -0,0 +1,107 @@
package se.hagstedt.kvitt.feature.expense
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
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.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import se.hagstedt.kvitt.model.ConnectivityRepository
import se.hagstedt.kvitt.model.ExpenseDetailState
import se.hagstedt.kvitt.model.ExpenseId
import se.hagstedt.kvitt.model.ExpenseRepository
import se.hagstedt.kvitt.model.HomeRepository
import se.hagstedt.kvitt.model.SyncRepository
import se.hagstedt.kvitt.model.User
import javax.inject.Inject
/**
* What S-8 renders. [detail] null after loading is the missing state (the row is not on the phone yet); a deleted row
* shows the deleted state (AC-8.11, design-spec S-8).
*/
data class ExpenseDetailUiState(
val loading: Boolean = true,
val detail: ExpenseDetailState? = null,
val me: User? = null,
val partner: User? = null,
val online: Boolean = true,
val confirmDelete: Boolean = false,
val syncing: Boolean = false,
)
/** One-off results of S-8. */
sealed interface DetailEvent {
data object Deleted : DetailEvent
}
/** S-8 state holder. Argument: [ARG_EXPENSE_ID]. */
@HiltViewModel
class ExpenseDetailViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val expenses: ExpenseRepository,
home: HomeRepository,
connectivity: ConnectivityRepository,
private val sync: SyncRepository,
) : ViewModel(), ExpenseDetailActions {
private val id: ExpenseId? = savedStateHandle.get<String>(ARG_EXPENSE_ID)?.let(::ExpenseId)
private val local = MutableStateFlow(Local())
private val events = Channel<DetailEvent>(Channel.BUFFERED)
private data class Local(val confirmDelete: Boolean = false, val syncing: Boolean = false)
val eventFlow: Flow<DetailEvent> = events.receiveAsFlow()
val state: StateFlow<ExpenseDetailUiState> = combine(
id?.let { expenses.detail(it) } ?: flowOf(null),
home.home(),
connectivity.online,
local,
) { detail, h, online, l ->
ExpenseDetailUiState(
loading = false,
detail = detail,
me = h.me,
partner = h.partner,
online = online,
confirmDelete = l.confirmDelete,
syncing = l.syncing,
)
}.stateIn(viewModelScope, SharingStarted.Eagerly, ExpenseDetailUiState())
override fun askDelete() = local.update { it.copy(confirmDelete = true) }
override fun dismissDelete() = local.update { it.copy(confirmDelete = false) }
/** AC-8.9: confirmed soft delete, then back with "Utgiften togs bort". */
override fun confirmDelete() {
val target = id ?: return
local.update { it.copy(confirmDelete = false) }
viewModelScope.launch {
expenses.delete(target)
events.send(DetailEvent.Deleted)
}
}
/** Missing state: "Synka nu"; the screen fills in when the row arrives. */
override fun syncNow() {
local.update { it.copy(syncing = true) }
viewModelScope.launch {
sync.syncNow()
local.update { it.copy(syncing = false) }
}
}
companion object {
/** Navigation argument: the expense to show. */
const val ARG_EXPENSE_ID = "expenseId"
}
}
@@ -0,0 +1,491 @@
package se.hagstedt.kvitt.feature.expense
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDefaults
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SelectableDates
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
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.design.KvittTheme
import se.hagstedt.kvitt.core.design.R as DesignR
import se.hagstedt.kvitt.design.component.AccountChip
import se.hagstedt.kvitt.design.component.AccountPicker
import se.hagstedt.kvitt.design.component.AmountDisplay
import se.hagstedt.kvitt.design.component.AmountKeypad
import se.hagstedt.kvitt.design.component.ButtonKind
import se.hagstedt.kvitt.design.component.CategoryGrid
import se.hagstedt.kvitt.design.component.CategoryQuickRow
import se.hagstedt.kvitt.design.component.CompactEditorSummary
import se.hagstedt.kvitt.design.component.ConfirmDialog
import se.hagstedt.kvitt.design.component.ConfirmDialogContent
import se.hagstedt.kvitt.design.component.DateChip
import se.hagstedt.kvitt.design.component.DescriptionField
import se.hagstedt.kvitt.design.component.EditorChipRow
import se.hagstedt.kvitt.design.component.ExactSplitEditor
import se.hagstedt.kvitt.design.component.FullSplitPicker
import se.hagstedt.kvitt.design.component.KvittButton
import se.hagstedt.kvitt.design.component.KvittTopAppBar
import se.hagstedt.kvitt.design.component.NotesField
import se.hagstedt.kvitt.design.component.PayerChip
import se.hagstedt.kvitt.design.component.ProvideHousehold
import se.hagstedt.kvitt.design.component.RatioEditor
import se.hagstedt.kvitt.design.component.RatioField
import se.hagstedt.kvitt.design.component.SplitBar
import se.hagstedt.kvitt.design.component.SplitChip
import se.hagstedt.kvitt.design.component.SplitPreview
import se.hagstedt.kvitt.design.component.SplitSection
import se.hagstedt.kvitt.design.component.TopBarNavigation
import se.hagstedt.kvitt.design.component.defaultModeLabel
import se.hagstedt.kvitt.design.component.kvittLanguage
import se.hagstedt.kvitt.design.component.money
import se.hagstedt.kvitt.design.component.splitModeLabel
import se.hagstedt.kvitt.design.format.AmountInput
import se.hagstedt.kvitt.design.format.AmountKey
import se.hagstedt.kvitt.design.icon.KvittIcon
import se.hagstedt.kvitt.model.AccountId
import se.hagstedt.kvitt.model.CategoryId
import se.hagstedt.kvitt.model.ExpenseId
import se.hagstedt.kvitt.model.FieldError
import se.hagstedt.kvitt.model.SplitMode
import se.hagstedt.kvitt.model.UserId
/** What S-7 can ask its state holder to do; [ExpenseEditorViewModel] implements it. */
interface ExpenseEditorActions {
fun onKey(key: AmountKey): Boolean
fun focusKeypad(target: KeypadTarget?)
fun onDescription(text: String)
fun onNotes(text: String)
fun expandNotes()
fun onCategory(id: CategoryId)
fun onAccount(id: AccountId)
fun onDate(iso: String)
fun togglePayer()
fun toggleSplit()
fun onMode(mode: SplitMode)
fun onViewerPercent(percent: Int)
fun onExactTyped(user: UserId)
fun onFullOn(user: UserId)
fun showOverlay(overlay: EditorOverlay)
fun dismissOverlay()
fun close()
fun back()
fun discard()
fun confirmDelete()
fun save()
}
/**
* S-7, the full-screen expense editor (design-spec §4 S-7, §5). [onSaved] gets the saved id and the snackbar text for
* the opener ("Utgiften sparades" / offline wording for a new expense, null after an edit: back to S-8 without one);
* [onDeleted] gets "Utgiften togs bort" and goes back past S-8 to its opener.
*/
@Composable
fun ExpenseEditorScreen(
onClose: () -> Unit,
onSaved: (ExpenseId, String?) -> Unit,
onDeleted: (String) -> Unit,
modifier: Modifier = Modifier,
viewModel: ExpenseEditorViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val descriptionFocus = remember { FocusRequester() }
val saved = stringResource(DesignR.string.snackbar_expense_saved)
val savedOffline = stringResource(DesignR.string.snackbar_expense_saved_offline)
val deleted = stringResource(DesignR.string.snackbar_expense_deleted)
LaunchedEffect(viewModel) {
viewModel.eventFlow.collect { event ->
when (event) {
is EditorEvent.Saved -> onSaved(event.id, if (!event.isNew) null else if (event.offline) savedOffline else saved)
EditorEvent.Deleted -> onDeleted(deleted)
EditorEvent.Closed -> onClose()
EditorEvent.FocusDescription -> descriptionFocus.requestFocus()
}
}
}
LaunchedEffect(state.missing) { if (state.missing) onClose() }
BackHandler { viewModel.back() }
ExpenseEditorContent(state, viewModel, modifier, descriptionFocus)
}
/** The stateless S-7 layout: app bar, scrolling fields, the keypad, and the overlay of [ExpenseEditorState.overlay]. */
@Composable
fun ExpenseEditorContent(
state: ExpenseEditorState,
actions: ExpenseEditorActions,
modifier: Modifier = Modifier,
descriptionFocus: FocusRequester = remember { FocusRequester() },
) {
val me = state.me
val partner = state.partner
val form = state.form
Surface(modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {
if (me == null || partner == null || form == null) return@Surface
ProvideHousehold(me, partner) {
EditorBody(state, actions, descriptionFocus)
EditorOverlays(state, actions)
}
}
}
@Composable
private fun EditorBody(state: ExpenseEditorState, actions: ExpenseEditorActions, descriptionFocus: FocusRequester) {
val me = state.me ?: return
val partner = state.partner ?: return
val form = state.form ?: return
val language = kvittLanguage()
val focusManager = LocalFocusManager.current
val haptics = LocalHapticFeedback.current
val scroll = rememberScrollState()
val splitRequester = remember { BringIntoViewRequester() }
var amountHeight by remember { mutableIntStateOf(0) }
val compact by remember { derivedStateOf { amountHeight > 0 && scroll.value > amountHeight } }
val scrolled by remember { derivedStateOf { scroll.value > 0 } }
LaunchedEffect(state.splitReveal) { if (state.splitReveal > 0) splitRequester.bringIntoView() }
fun keypad(target: KeypadTarget) {
focusManager.clearFocus()
actions.focusKeypad(target)
}
Column(Modifier.fillMaxSize().statusBarsPadding().navigationBarsPadding().imePadding()) {
KvittTopAppBar(
title = stringResource(if (state.isEdit) R.string.expense_editor_edit else R.string.expense_editor_new),
navigation = TopBarNavigation.Close,
onNavigation = actions::close,
scrolled = scrolled,
) {
KvittButton(stringResource(DesignR.string.common_save), actions::save, icon = KvittIcon.Check, enabled = state.canSave)
Spacer(Modifier.width(KvittTheme.spacing.s3))
}
val category = state.category
if (compact && category != null) {
CompactEditorSummary(category, form.description, form.amount.ore ?: 0L, { keypad(KeypadTarget.Amount) })
}
Column(
Modifier
.weight(1f)
.fillMaxWidth()
.verticalScroll(scroll)
.padding(start = KvittTheme.spacing.gutter, end = KvittTheme.spacing.gutter, bottom = KvittTheme.spacing.s4),
verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.sectionGap),
) {
AmountDisplay(
form.amount.display(language),
focused = state.keypad == KeypadTarget.Amount,
error = errorText(state.errors, ExpenseEditorViewModel.FIELD_AMOUNT),
onClick = { keypad(KeypadTarget.Amount) },
modifier = Modifier.onSizeChanged { amountHeight = it.height },
)
DescriptionField(
form.description,
actions::onDescription,
error = errorText(state.errors, ExpenseEditorViewModel.FIELD_DESCRIPTION),
onDone = actions::save,
modifier = Modifier.focusRequester(descriptionFocus).onFocusChanged { if (it.hasFocus) actions.focusKeypad(null) },
)
CategoryQuickRow(state.quickRow, form.categoryId, { actions.onCategory(it.id) }, { actions.showOverlay(EditorOverlay.CategoryGrid) })
EditorChipRow {
state.account?.let { AccountChip(it.name, state.accountColorIndex, { actions.showOverlay(EditorOverlay.AccountPicker) }) }
DateChip(form.date, { actions.showOverlay(EditorOverlay.DatePicker) })
val payer = if (form.payer == me.id) me else partner
PayerChip(payer, if (payer == me) partner else me, actions::togglePayer)
SplitChip(modeChipLabel(state), state.splitOpen, actions::toggleSplit)
}
errorText(state.errors, ExpenseEditorViewModel.FIELD_DATE)?.let { ErrorLine(it) }
if (!state.splitOpen) {
SplitPreview(state.preview, me, partner)
} else {
SplitSection(
form.mode,
defaultLabel(state),
actions::onMode,
actions::toggleSplit,
Modifier.bringIntoViewRequester(splitRequester),
) { SplitBody(state, actions, ::keypad) }
}
NotesField(
form.notes,
actions::onNotes,
state.notesExpanded,
actions::expandNotes,
Modifier.onFocusChanged { if (it.hasFocus) actions.focusKeypad(null) },
)
errorText(state.errors, ExpenseEditorViewModel.FIELD_NOTES)?.let { ErrorLine(it) }
if (state.isEdit) {
KvittButton(stringResource(R.string.expense_editor_delete), { actions.showOverlay(EditorOverlay.Delete) }, kind = ButtonKind.DangerText, icon = KvittIcon.Delete)
}
}
val target = state.keypad
if (target != null) {
AmountKeypad(
onKey = { key -> if (!actions.onKey(key)) haptics.performHapticFeedback(HapticFeedbackType.Reject) },
compact = target != KeypadTarget.Amount,
)
}
}
}
@Composable
private fun SplitBody(state: ExpenseEditorState, actions: ExpenseEditorActions, keypad: (KeypadTarget) -> Unit) {
val me = state.me ?: return
val partner = state.partner ?: return
val form = state.form ?: return
val language = kvittLanguage()
when (form.mode) {
SplitMode.DEFAULT -> {
SplitBar(accountViewerPercent(state).toFloat())
SplitPreview(state.preview, me, partner)
}
SplitMode.RATIO -> RatioEditor(
me,
partner,
viewerPercent = ((form.ratioViewer.ore ?: 0L) / PERCENT_HUNDREDTHS).toInt(),
viewerText = form.ratioViewer.display(language),
partnerText = form.ratioPartner.display(language),
focused = when (state.keypad) {
KeypadTarget.RatioViewer -> RatioField.Viewer
KeypadTarget.RatioPartner -> RatioField.Partner
else -> null
},
onSlider = actions::onViewerPercent,
onFocus = { keypad(if (it == RatioField.Viewer) KeypadTarget.RatioViewer else KeypadTarget.RatioPartner) },
onPreset = actions::onViewerPercent,
shares = state.preview,
error = errorText(state.errors, ExpenseEditorViewModel.FIELD_SPLIT),
)
SplitMode.EXACT -> ExactSplitEditor(
me,
partner,
typed = form.exactTyped,
typedText = form.exactInput.display(language),
derivedOre = state.preview?.firstOrNull { it.userId != form.exactTyped }?.shareOre,
focused = state.keypad == KeypadTarget.Exact,
amountOre = form.amount.ore,
typedOre = form.exactInput.ore,
onTypedChange = actions::onExactTyped,
onFocus = { keypad(KeypadTarget.Exact) },
)
SplitMode.FULL -> FullSplitPicker(me, partner, form.fullOn, actions::onFullOn, state.preview)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun EditorOverlays(state: ExpenseEditorState, actions: ExpenseEditorActions) {
when (state.overlay) {
EditorOverlay.None -> Unit
EditorOverlay.AccountPicker, EditorOverlay.CategoryGrid ->
ModalBottomSheet(actions::dismissOverlay, sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)) {
EditorSheetContent(state, actions)
}
EditorOverlay.DatePicker, EditorOverlay.Discard, EditorOverlay.Delete -> EditorDialog(state, actions, inline = false)
}
}
/** The content of the account or category sheet (design-spec §3.7 `AccountPickerSheet`, `CategoryGridSheet`). */
@Composable
internal fun EditorSheetContent(state: ExpenseEditorState, actions: ExpenseEditorActions) {
val form = state.form ?: return
val partner = state.partner ?: return
val padding = Modifier.padding(start = KvittTheme.spacing.gutter, end = KvittTheme.spacing.gutter, bottom = KvittTheme.spacing.s6)
when (state.overlay) {
EditorOverlay.AccountPicker -> AccountPicker(state.accounts, partner, form.accountId, { actions.onAccount(it.summary.account.id) }, padding)
EditorOverlay.CategoryGrid -> CategoryGrid(state.categories, form.categoryId, { actions.onCategory(it.id) }, padding)
else -> Unit
}
}
/**
* The date picker, discard and delete dialogs of S-7. [inline] draws the dialog's content without a window (the
* screenshot tests, as `ConfirmDialogContent` in `:core:design`).
*/
@Composable
internal fun EditorDialog(state: ExpenseEditorState, actions: ExpenseEditorActions, inline: Boolean) {
val form = state.form ?: return
when (state.overlay) {
EditorOverlay.DatePicker -> EditorDatePicker(form.date, state.today, actions, inline)
EditorOverlay.Discard -> {
val title = stringResource(if (state.isEdit) DesignR.string.common_discard_title else R.string.expense_editor_discard_title)
val confirm = stringResource(DesignR.string.common_discard)
val dismiss = stringResource(DesignR.string.common_keep_editing)
if (inline) {
ConfirmDialogContent(KvittIcon.Delete, title, "", confirm, dismiss, actions::discard, actions::dismissOverlay, destructive = true)
} else {
ConfirmDialog(KvittIcon.Delete, title, "", confirm, dismiss, actions::discard, actions::dismissOverlay, destructive = true)
}
}
EditorOverlay.Delete -> DeleteExpenseDialog(actions::confirmDelete, actions::dismissOverlay, inline)
else -> Unit
}
}
/** "Ta bort utgiften?" / "Den försvinner för er båda." → "Ta bort" (AC-8.9; S-7 and S-8). */
@Composable
internal fun DeleteExpenseDialog(onConfirm: () -> Unit, onDismiss: () -> Unit, inline: Boolean = false) {
val title = stringResource(R.string.expense_detail_delete_title)
val body = stringResource(R.string.expense_detail_delete_body)
val confirm = stringResource(DesignR.string.common_delete)
val dismiss = stringResource(DesignR.string.common_cancel)
if (inline) {
ConfirmDialogContent(KvittIcon.Delete, title, body, confirm, dismiss, onConfirm, onDismiss, destructive = true)
} else {
ConfirmDialog(KvittIcon.Delete, title, body, confirm, dismiss, onConfirm, onDismiss, destructive = true)
}
}
/** Material `DatePickerDialog` limited to the §9.6 range: 2000-01-01 to today + 365 days. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun EditorDatePicker(date: String, today: String, actions: ExpenseEditorActions, inline: Boolean) {
// The picker's calendar is built for the locale it was first composed in; a new locale gets a new picker.
key(LocalConfiguration.current.locales) { LocalizedDatePicker(date, today, actions, inline) }
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun LocalizedDatePicker(date: String, today: String, actions: ExpenseEditorActions, inline: Boolean) {
val first = remember { LocalDate(FIRST_YEAR, 1, 1).toEpochDays() * DAY_MILLIS }
val last = remember(today) { LocalDate.parse(today).plus(MAX_DAYS_AHEAD, DateTimeUnit.DAY).toEpochDays() * DAY_MILLIS }
val picker = rememberDatePickerState(
initialSelectedDateMillis = LocalDate.parse(date).toEpochDays() * DAY_MILLIS,
selectableDates = object : SelectableDates {
override fun isSelectableDate(utcTimeMillis: Long): Boolean = utcTimeMillis in first..last
},
)
val confirm: @Composable () -> Unit = {
TextButton({ picker.selectedDateMillis?.let { actions.onDate(LocalDate.fromEpochDays(it / DAY_MILLIS).toString()) } ?: actions.dismissOverlay() }) {
Text(stringResource(android.R.string.ok))
}
}
val dismiss: @Composable () -> Unit = { TextButton(actions::dismissOverlay) { Text(stringResource(DesignR.string.common_cancel)) } }
if (inline) {
Surface(shape = DatePickerDefaults.shape, color = DatePickerDefaults.colors().containerColor) {
Column {
DatePicker(picker)
Row(Modifier.align(Alignment.End).padding(KvittTheme.spacing.s2)) {
dismiss()
confirm()
}
}
}
} else {
DatePickerDialog(onDismissRequest = actions::dismissOverlay, confirmButton = confirm, dismissButton = dismiss) { DatePicker(picker) }
}
}
@Composable
private fun ErrorLine(text: String) {
Text(text, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(horizontal = KvittTheme.spacing.s4))
}
/** The viewer's percent of the account's default ratio (bp in `Account.defaultShares`, §14.2.4). */
private fun accountViewerPercent(state: ExpenseEditorState): Int {
val me = state.me ?: return HALF
val bp = state.account?.defaultShares?.get(me.id) ?: return HALF
return (bp / PERCENT_HUNDREDTHS).toInt()
}
@Composable
private fun defaultLabel(state: ExpenseEditorState): String = defaultModeLabel(accountViewerPercent(state))
/** The Delning chip: "Kontots standard 50/50", "Egen fördelning 70/30", "Exakta belopp", "Allt på Anna" (design-spec §3.7). */
@Composable
private fun modeChipLabel(state: ExpenseEditorState): String {
val form = state.form ?: return ""
return when (form.mode) {
SplitMode.DEFAULT -> defaultLabel(state)
SplitMode.RATIO -> {
val language = kvittLanguage()
val viewer = form.ratioViewer.ore ?: return splitModeLabel(form.mode, "")
val ratio = stringResource(DesignR.string.common_ratio, form.ratioViewer.display(language), AmountInput.of(AmountInput.MAX_PERCENT_HUNDREDTHS - viewer).display(language))
splitModeLabel(form.mode, "") + " " + ratio
}
SplitMode.EXACT -> splitModeLabel(form.mode, "")
SplitMode.FULL -> {
val partner = state.partner
when {
form.fullOn == state.me?.id -> stringResource(R.string.expense_split_full_on_you)
partner == null -> splitModeLabel(form.mode, "")
else -> stringResource(DesignR.string.editor_split_full_on, partner.displayName)
}
}
}
}
/** The inline text of the first error on [field] (design-spec S-7 "Interactions"), or null. */
@Composable
internal fun errorText(errors: List<FieldError>, field: String): String? {
val error = errors.firstOrNull { it.field == field } ?: return null
return when (field) {
ExpenseEditorViewModel.FIELD_AMOUNT -> when (error.error) {
ExpenseEditorViewModel.REQUIRED -> stringResource(DesignR.string.error_amount_required)
else -> stringResource(DesignR.string.error_amount_out_of_range, money(MIN_AMOUNT_ORE), money(AmountInput.MAX_AMOUNT_ORE))
}
ExpenseEditorViewModel.FIELD_DESCRIPTION -> when (error.error) {
ExpenseEditorViewModel.REQUIRED -> stringResource(DesignR.string.error_description_required)
else -> pluralStringResource(DesignR.plurals.error_too_long, MAX_DESCRIPTION, MAX_DESCRIPTION)
}
ExpenseEditorViewModel.FIELD_NOTES -> pluralStringResource(DesignR.plurals.error_too_long, MAX_NOTES, MAX_NOTES)
ExpenseEditorViewModel.FIELD_DATE -> stringResource(DesignR.string.error_date_out_of_range)
ExpenseEditorViewModel.FIELD_SPLIT -> when (error.error) {
"exact_sum_mismatch", "exact_negative" -> stringResource(DesignR.string.error_exact_share_too_large)
"bp_out_of_range", "bp_sum_not_10000" -> stringResource(DesignR.string.error_ratio_invalid)
else -> stringResource(DesignR.string.error_generic_invalid)
}
else -> stringResource(DesignR.string.error_generic_invalid)
}
}
private const val PERCENT_HUNDREDTHS = 100L
private const val HALF = 50
private const val MIN_AMOUNT_ORE = 1L
private const val MAX_DESCRIPTION = 100
private const val MAX_NOTES = 1000
private const val FIRST_YEAR = 2000
private const val MAX_DAYS_AHEAD = 365
private const val DAY_MILLIS = 86_400_000L
@@ -0,0 +1,561 @@
package se.hagstedt.kvitt.feature.expense
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.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import se.hagstedt.kvitt.design.component.AccountChoice
import se.hagstedt.kvitt.design.component.quickRowCategories
import se.hagstedt.kvitt.design.format.AmountInput
import se.hagstedt.kvitt.design.format.AmountKey
import se.hagstedt.kvitt.model.Account
import se.hagstedt.kvitt.model.AccountId
import se.hagstedt.kvitt.model.AccountRepository
import se.hagstedt.kvitt.model.AccountSummary
import se.hagstedt.kvitt.model.Category
import se.hagstedt.kvitt.model.CategoryId
import se.hagstedt.kvitt.model.ConnectivityRepository
import se.hagstedt.kvitt.model.ExpenseDraft
import se.hagstedt.kvitt.model.ExpenseEditorOptions
import se.hagstedt.kvitt.model.ExpenseId
import se.hagstedt.kvitt.model.ExpenseRepository
import se.hagstedt.kvitt.model.FieldError
import se.hagstedt.kvitt.model.HomeRepository
import se.hagstedt.kvitt.model.SaveResult
import se.hagstedt.kvitt.model.Share
import se.hagstedt.kvitt.model.SplitMode
import se.hagstedt.kvitt.model.User
import se.hagstedt.kvitt.model.UserId
import javax.inject.Inject
/** Which keypad-driven field has the in-app keypad (design-spec §3.7, §5.2); null hides the keypad. */
enum class KeypadTarget { Amount, RatioViewer, RatioPartner, Exact }
/** What is shown over the editor: a sheet, the date picker or a confirmation (S-7). */
enum class EditorOverlay { None, AccountPicker, CategoryGrid, DatePicker, Discard, Delete }
/**
* The values the user edits (S-7). Percent fields hold hundredths of a percent in an [AmountInput] (so "60" is 6000
* bp) and money fields öre; all parsing is string-exact (SPEC §9.6).
*/
data class EditorForm(
val id: ExpenseId?,
val accountId: AccountId?,
val categoryId: CategoryId?,
val amount: AmountInput,
val description: String,
val notes: String,
val date: String,
val payer: UserId,
val mode: SplitMode,
val ratioViewer: AmountInput,
val ratioPartner: AmountInput,
val exactTyped: UserId,
val exactInput: AmountInput,
val fullOn: UserId,
)
/** The editor's whole state as S-7 renders it. [ready] is false until the options (and, editing, the expense) are read. */
data class ExpenseEditorState(
val ready: Boolean = false,
val missing: Boolean = false,
val isEdit: Boolean = false,
val me: User? = null,
val partner: User? = null,
val form: EditorForm? = null,
val accounts: List<AccountChoice> = emptyList(),
val account: Account? = null,
val accountColorIndex: Int = 0,
val categories: List<Category> = emptyList(),
val quickRow: List<Category> = emptyList(),
val category: Category? = null,
val preview: List<Share>? = null,
val keypad: KeypadTarget? = null,
val splitOpen: Boolean = false,
val splitReveal: Int = 0,
val notesExpanded: Boolean = false,
val overlay: EditorOverlay = EditorOverlay.None,
val errors: List<FieldError> = emptyList(),
val online: Boolean = true,
val today: String = "",
val saving: Boolean = false,
) {
/** The typed exact share exceeds the amount (AC-8.7): inline error, saving blocked. */
val exactTooLarge: Boolean
get() {
val f = form ?: return false
val typed = f.exactInput.ore ?: return false
val amount = f.amount.ore ?: return false
return f.mode == SplitMode.EXACT && typed > amount
}
/** "Spara": amount > 0, a description, a category and a computable preview (design-spec S-7). */
val canSave: Boolean
get() {
val f = form ?: return false
return !saving && (f.amount.ore ?: 0L) > 0 && f.description.isNotBlank() && f.categoryId != null && f.accountId != null &&
preview != null && !exactTooLarge
}
}
/** One-off results the entry point turns into navigation. */
sealed interface EditorEvent {
/** Saved: [isNew] goes back to the opener with a snackbar ([offline] picks its wording), an edit back to S-8. */
data class Saved(val id: ExpenseId, val isNew: Boolean, val offline: Boolean) : EditorEvent
data object Deleted : EditorEvent
data object Closed : EditorEvent
/** Validation moved focus to the description field. */
data object FocusDescription : EditorEvent
}
/**
* S-7 state holder (design-spec §4 S-7 and §5; SPEC AC-8.3 to AC-8.9, §9.11 CS-1 … CS-7). Arguments: [ARG_EXPENSE_ID]
* edits that expense; otherwise a new expense, in [ARG_ACCOUNT_ID] when opened from an account (S-5).
*/
@HiltViewModel
class ExpenseEditorViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val expenses: ExpenseRepository,
home: HomeRepository,
accounts: AccountRepository,
connectivity: ConnectivityRepository,
) : ViewModel(), ExpenseEditorActions {
private val editId: ExpenseId? = savedStateHandle.get<String>(ARG_EXPENSE_ID)?.let(::ExpenseId)
private val openerAccount: AccountId? = savedStateHandle.get<String>(ARG_ACCOUNT_ID)?.let(::AccountId)
private data class Environment(val options: ExpenseEditorOptions, val summaries: List<AccountSummary>, val all: List<Account>, val online: Boolean)
private data class Ui(
val form: EditorForm? = null,
val initial: EditorForm? = null,
val missing: Boolean = false,
val frequent: List<CategoryId> = emptyList(),
val pickedByHand: Boolean = false,
val keypad: KeypadTarget? = null,
val splitOpen: Boolean = false,
val splitReveal: Int = 0,
val notesExpanded: Boolean = false,
val overlay: EditorOverlay = EditorOverlay.None,
val errors: List<FieldError> = emptyList(),
val saving: Boolean = false,
)
private val environment = MutableStateFlow<Environment?>(null)
private val ui = MutableStateFlow(Ui())
private val events = Channel<EditorEvent>(Channel.BUFFERED)
private var suggestion: Job? = null
/** One-off events (navigation, focus). */
val eventFlow: Flow<EditorEvent> = events.receiveAsFlow()
val state: StateFlow<ExpenseEditorState> = combine(environment, ui) { env, u -> derive(env, u) }
.stateIn(viewModelScope, SharingStarted.Eagerly, ExpenseEditorState())
init {
viewModelScope.launch {
combine(
expenses.editorOptions(),
home.home().map { it.active + it.archived },
accounts.all(),
connectivity.online,
) { options, summaries, all, online -> Environment(options, summaries, all, online) }
.collect { env ->
val first = environment.value == null
environment.value = env
if (first) open(env.options)
}
}
}
private suspend fun open(options: ExpenseEditorOptions) {
val me = options.me.id
val partner = options.partner.id
if (editId != null) {
val draft = expenses.load(editId)
if (draft == null) {
ui.update { it.copy(missing = true) }
return
}
val viewerBp = draft.ratioBp?.get(me)
val exactMine = draft.exactOre?.get(me)
val form = EditorForm(
id = draft.id,
accountId = draft.accountId,
categoryId = draft.categoryId,
amount = draft.amountOre?.let(::moneyInput) ?: AmountInput(),
description = draft.description,
notes = draft.notes,
date = draft.date,
payer = draft.payer,
mode = draft.mode,
ratioViewer = viewerBp?.let { AmountInput.of(it.toLong()) } ?: AmountInput(),
ratioPartner = viewerBp?.let { AmountInput.of(BP_TOTAL - it) } ?: AmountInput(),
exactTyped = me,
exactInput = exactMine?.let(::moneyInput) ?: AmountInput(),
fullOn = draft.fullOn ?: if (draft.payer == me) partner else me,
)
ui.update {
it.copy(
form = form,
initial = form,
frequent = draft.accountId?.let { a -> options.frequentCategoryIds[a] }.orEmpty(),
// CS-1: editing never suggests; the saved category stays.
pickedByHand = true,
splitOpen = draft.mode != SplitMode.DEFAULT,
notesExpanded = draft.notes.isNotEmpty(),
)
}
} else {
val activeIds = options.accounts.map { it.id }
val accountId = openerAccount?.takeIf { it in activeIds } ?: options.defaultAccountId
val form = EditorForm(
id = null,
accountId = accountId,
categoryId = accountId?.let { options.defaultCategoryFor[it] },
amount = AmountInput(),
description = "",
notes = "",
date = options.today,
payer = me,
mode = SplitMode.DEFAULT,
ratioViewer = AmountInput(),
ratioPartner = AmountInput(),
exactTyped = me,
exactInput = AmountInput(),
fullOn = partner,
)
ui.update {
it.copy(form = form, initial = form, frequent = accountId?.let { a -> options.frequentCategoryIds[a] }.orEmpty(), keypad = KeypadTarget.Amount)
}
}
}
private fun derive(env: Environment?, u: Ui): ExpenseEditorState {
if (env == null) return ExpenseEditorState()
val options = env.options
if (u.missing) return ExpenseEditorState(ready = true, missing = true, isEdit = true, online = env.online, today = options.today)
val form = u.form ?: return ExpenseEditorState(online = env.online, today = options.today)
val summaries = env.summaries.associateBy { it.account.id }
val colorIndex = env.all.withIndex().associate { (i, a) -> a.id to i }
val choices = options.accounts.map { a -> AccountChoice(summaries[a.id] ?: AccountSummary(a, 0, 0), colorIndex[a.id] ?: 0) }
val account = form.accountId?.let { id -> options.accounts.firstOrNull { it.id == id } ?: env.all.firstOrNull { it.id == id } }
val active = options.categories.filter { !it.archived }.sortedBy { it.sortOrder }
// D-059/D-060: the account's used categories, filled up to five from sort_order; the selected one takes the fifth place.
val frequent = u.frequent.ifEmpty { active.take(QUICK_ROW).map { it.id } }
return ExpenseEditorState(
ready = true,
isEdit = form.id != null,
me = options.me,
partner = options.partner,
form = form,
accounts = choices,
account = account,
accountColorIndex = account?.let { colorIndex[it.id] } ?: 0,
categories = active,
quickRow = quickRowCategories(active, frequent, form.categoryId),
category = form.categoryId?.let { id -> options.categories.firstOrNull { it.id == id } },
preview = expenses.preview(draftOf(form, options.me.id, options.partner.id)),
keypad = u.keypad,
splitOpen = u.splitOpen,
splitReveal = u.splitReveal,
notesExpanded = u.notesExpanded,
overlay = u.overlay,
errors = u.errors,
online = env.online,
today = options.today,
saving = u.saving,
)
}
private fun editForm(change: (EditorForm) -> EditorForm) = ui.update { u -> u.form?.let { u.copy(form = change(it)) } ?: u }
private fun clearError(vararg fields: String) = ui.update { u -> u.copy(errors = u.errors.filter { it.field !in fields }) }
/** A key of the in-app keypad for the focused field; false when the key is rejected (§9.6, reject haptic). */
override fun onKey(key: AmountKey): Boolean {
val target = ui.value.keypad ?: return false
val form = ui.value.form ?: return false
return when (target) {
KeypadTarget.Amount -> {
val next = form.amount.press(key) ?: return false
editForm { it.copy(amount = next) }
clearError(FIELD_AMOUNT, FIELD_SPLIT)
true
}
KeypadTarget.Exact -> {
val next = form.exactInput.press(key) ?: return false
editForm { it.copy(exactInput = next) }
clearError(FIELD_SPLIT)
true
}
KeypadTarget.RatioViewer, KeypadTarget.RatioPartner -> {
val typed = if (target == KeypadTarget.RatioViewer) form.ratioViewer else form.ratioPartner
val next = typed.press(key, AmountInput.MAX_PERCENT_HUNDREDTHS) ?: return false
val other = next.ore?.let { AmountInput.of(AmountInput.MAX_PERCENT_HUNDREDTHS - it) } ?: AmountInput()
editForm {
if (target == KeypadTarget.RatioViewer) it.copy(ratioViewer = next, ratioPartner = other) else it.copy(ratioPartner = next, ratioViewer = other)
}
clearError(FIELD_SPLIT)
true
}
}
}
/** Gives the keypad to [target] (a tap on a keypad field), or hides it (null: a text field took focus). */
override fun focusKeypad(target: KeypadTarget?) = ui.update { it.copy(keypad = target) }
override fun onDescription(text: String) {
editForm { it.copy(description = text) }
clearError(FIELD_DESCRIPTION)
suggest()
}
override fun onNotes(text: String) {
editForm { it.copy(notes = text) }
clearError(FIELD_NOTES)
}
override fun expandNotes() = ui.update { it.copy(notesExpanded = true, keypad = null) }
/** CS-2: any tap on a category tile, also the selected one, marks the category as picked by hand. */
override fun onCategory(id: CategoryId) {
suggestion?.cancel()
ui.update { u -> u.copy(pickedByHand = true, overlay = EditorOverlay.None, form = u.form?.copy(categoryId = id), errors = u.errors.filter { it.field != FIELD_CATEGORY }) }
}
/** AC-8.8: in mode `default` the new account's ratio applies by itself (the draft carries no ratio); UX-9 re-reads the row. */
override fun onAccount(id: AccountId) {
val options = environment.value?.options ?: return
ui.update { u -> u.copy(overlay = EditorOverlay.None, frequent = options.frequentCategoryIds[id].orEmpty(), form = u.form?.copy(accountId = id)) }
clearError(FIELD_ACCOUNT)
suggest()
}
override fun onDate(iso: String) {
ui.update { u -> u.copy(overlay = EditorOverlay.None, form = u.form?.copy(date = iso)) }
clearError(FIELD_DATE)
}
/** AC-8.3: one tap toggles the payer between the two users. */
override fun togglePayer() {
val options = environment.value?.options ?: return
editForm { it.copy(payer = if (it.payer == options.me.id) options.partner.id else options.me.id) }
}
/** Opens or closes the split section (design-spec §5.4); opening scrolls it into view. */
override fun toggleSplit() = ui.update { u ->
if (u.splitOpen) u.copy(splitOpen = false, keypad = if (u.keypad in SPLIT_TARGETS) null else u.keypad)
else u.copy(splitOpen = true, splitReveal = u.splitReveal + 1)
}
/** One tap picks a mode (design-spec §5.4): ratio starts at the account's ratio, exact with the viewer's field focused, full on the person who did not pay. */
override fun onMode(mode: SplitMode) {
val env = environment.value ?: return
val me = env.options.me.id
val partner = env.options.partner.id
val current = ui.value.form ?: return
val account = current.accountId?.let { id -> env.all.firstOrNull { it.id == id } }
val viewerBp = (account?.defaultShares?.get(me) ?: HALF_BP).toLong()
ui.update { u ->
val form = u.form ?: return@update u
val next = when (mode) {
SplitMode.DEFAULT -> form.copy(mode = mode)
SplitMode.RATIO -> form.copy(mode = mode, ratioViewer = AmountInput.of(viewerBp), ratioPartner = AmountInput.of(BP_TOTAL - viewerBp))
SplitMode.EXACT -> form.copy(mode = mode, exactTyped = me, exactInput = AmountInput())
SplitMode.FULL -> form.copy(mode = mode, fullOn = if (form.payer == me) partner else me)
}
u.copy(
form = next,
splitReveal = u.splitReveal + 1,
keypad = when (mode) {
SplitMode.EXACT -> KeypadTarget.Exact
else -> if (u.keypad in SPLIT_TARGETS) null else u.keypad
},
errors = u.errors.filter { it.field != FIELD_SPLIT },
)
}
}
/** Ratio slider and presets: whole percent for the viewer. */
override fun onViewerPercent(percent: Int) {
val bp = percent.coerceIn(0, PERCENT) * PERCENT.toLong()
editForm { it.copy(ratioViewer = AmountInput.of(bp), ratioPartner = AmountInput.of(BP_TOTAL - bp)) }
clearError(FIELD_SPLIT)
}
/** AC-8.7: tapping the read-only field makes that person's share the typed one. */
override fun onExactTyped(user: UserId) {
editForm { it.copy(exactTyped = user, exactInput = AmountInput()) }
ui.update { it.copy(keypad = KeypadTarget.Exact) }
}
override fun onFullOn(user: UserId) = editForm { it.copy(fullOn = user) }
override fun showOverlay(overlay: EditorOverlay) = ui.update { it.copy(overlay = overlay, keypad = if (overlay == EditorOverlay.None) it.keypad else null) }
override fun dismissOverlay() = ui.update { it.copy(overlay = EditorOverlay.None) }
/** Close or back: with changes asks first ("Slänga utgiften?"), without changes closes. */
override fun close() {
val u = ui.value
if (u.form != null && u.form != u.initial) ui.update { it.copy(overlay = EditorOverlay.Discard) } else events.trySend(EditorEvent.Closed)
}
/** Back with the keypad up hides it instead of closing (design-spec §3.7). */
override fun back() {
when {
ui.value.overlay != EditorOverlay.None -> dismissOverlay()
ui.value.keypad != null -> focusKeypad(null)
else -> close()
}
}
override fun discard() {
ui.update { it.copy(overlay = EditorOverlay.None) }
events.trySend(EditorEvent.Closed)
}
override fun confirmDelete() {
val id = ui.value.form?.id ?: return
ui.update { it.copy(overlay = EditorOverlay.None) }
viewModelScope.launch {
expenses.delete(id)
events.send(EditorEvent.Deleted)
}
}
/** "Spara" or the description's Done. Invalid fields come back inline; focus moves to the first one. */
override fun save() {
val current = state.value
val form = current.form ?: return
val me = current.me ?: return
val partner = current.partner ?: return
if (!current.canSave) {
focusFirstInvalid(localErrors(current))
return
}
ui.update { it.copy(saving = true) }
viewModelScope.launch {
when (val result = expenses.save(draftOf(form, me.id, partner.id))) {
is SaveResult.Saved -> {
ui.update { it.copy(saving = false, initial = it.form) }
events.send(EditorEvent.Saved(result.id, isNew = form.id == null, offline = !current.online))
}
is SaveResult.Invalid -> {
ui.update { it.copy(saving = false) }
focusFirstInvalid(result.errors)
}
}
}
}
private fun localErrors(s: ExpenseEditorState): List<FieldError> {
val f = s.form ?: return emptyList()
return buildList {
if ((f.amount.ore ?: 0L) <= 0L) add(FieldError(FIELD_AMOUNT, REQUIRED))
if (f.description.isBlank()) add(FieldError(FIELD_DESCRIPTION, REQUIRED))
if (f.categoryId == null) add(FieldError(FIELD_CATEGORY, REQUIRED))
if (s.exactTooLarge) add(FieldError(FIELD_SPLIT, EXACT_SUM_MISMATCH))
}
}
private fun focusFirstInvalid(errors: List<FieldError>) {
ui.update { it.copy(errors = errors) }
val fields = errors.map { it.field }.toSet()
when {
FIELD_AMOUNT in fields -> focusKeypad(KeypadTarget.Amount)
FIELD_DESCRIPTION in fields -> {
focusKeypad(null)
events.trySend(EditorEvent.FocusDescription)
}
FIELD_SPLIT in fields -> ui.update { it.copy(splitOpen = true, splitReveal = it.splitReveal + 1) }
}
}
/** CS-3: while the category was not picked by hand, re-run the suggestion (150 ms debounce) on description and account changes. */
private fun suggest() {
if (ui.value.pickedByHand || editId != null) return
suggestion?.cancel()
suggestion = viewModelScope.launch {
delay(SUGGESTION_DEBOUNCE_MS)
val form = ui.value.form ?: return@launch
val options = environment.value?.options ?: expenses.editorOptions().first()
val suggested = expenses.suggestCategory(form.accountId, form.description)
// A null result leaves the step-4 default (CS-5 step 4; CS-7 for an empty description).
val category = suggested ?: form.accountId?.let { options.defaultCategoryFor[it] }
if (!ui.value.pickedByHand) ui.update { u -> u.copy(form = u.form?.copy(categoryId = category)) }
}
}
companion object {
/** Navigation argument: the id of the expense to edit (absent for a new expense). */
const val ARG_EXPENSE_ID = "expenseId"
/** Navigation argument: the account a new expense starts in when opened from S-5. */
const val ARG_ACCOUNT_ID = "accountId"
const val FIELD_AMOUNT = "amountOre"
const val FIELD_DESCRIPTION = "description"
const val FIELD_NOTES = "notes"
const val FIELD_DATE = "date"
const val FIELD_SPLIT = "split"
const val FIELD_ACCOUNT = "accountId"
const val FIELD_CATEGORY = "categoryId"
const val REQUIRED = "required"
const val EXACT_SUM_MISMATCH = "exact_sum_mismatch"
/** design-spec §5.3. */
const val SUGGESTION_DEBOUNCE_MS = 150L
private const val BP_TOTAL = 10_000L
private const val HALF_BP = 5000
private const val PERCENT = 100
private const val QUICK_ROW = 5
private val SPLIT_TARGETS = setOf(KeypadTarget.RatioViewer, KeypadTarget.RatioPartner, KeypadTarget.Exact)
/** A stored amount as editable text with both decimals when it has öre: 124 760 -> "1247.60", 41 200 -> "412". */
fun moneyInput(ore: Long): AmountInput {
val cents = ore % ORE_PER_KRONA
return AmountInput(if (cents == 0L) (ore / ORE_PER_KRONA).toString() else "${ore / ORE_PER_KRONA}." + cents.toString().padStart(2, '0'))
}
private const val ORE_PER_KRONA = 100L
/** The draft of §14.2.4 from the form: exact derives the other share as amount minus the typed one (AC-8.7). */
fun draftOf(form: EditorForm, me: UserId, partner: UserId): ExpenseDraft {
val amount = form.amount.ore
val viewerBp = form.ratioViewer.ore
val typed = form.exactInput.ore
val other = if (form.exactTyped == me) partner else me
return ExpenseDraft(
id = form.id,
accountId = form.accountId,
categoryId = form.categoryId,
amountOre = amount,
description = form.description,
notes = form.notes,
date = form.date,
payer = form.payer,
mode = form.mode,
ratioBp = if (form.mode == SplitMode.RATIO && viewerBp != null) mapOf(me to viewerBp.toInt(), partner to (BP_TOTAL - viewerBp).toInt()) else null,
exactOre = if (form.mode == SplitMode.EXACT && amount != null && typed != null && typed <= amount) mapOf(form.exactTyped to typed, other to amount - typed) else null,
fullOn = if (form.mode == SplitMode.FULL) form.fullOn else null,
)
}
}
}
@@ -1,14 +0,0 @@
package se.hagstedt.kvitt.feature.expense
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 ExpenseEntry(modifier: Modifier = Modifier) {
Box(modifier)
}
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- :feature:expense strings, keys from docs/ux/strings.csv (module=expense). -->
<resources>
<string name="expense_editor_new">Ny utgift</string>
<string name="expense_editor_edit">Ändra utgift</string>
<string name="expense_editor_date">Datum</string>
<string name="expense_editor_delete">Ta bort utgift</string>
<string name="expense_editor_discard_title">Slänga utgiften?</string>
<string name="expense_detail_title">Utgift</string>
<string name="expense_detail_account">Konto</string>
<string name="expense_detail_date">Datum</string>
<string name="expense_detail_paid_by">Betalt av</string>
<string name="expense_detail_split">Delning</string>
<string name="expense_detail_notes">Anteckning</string>
<string name="expense_detail_recurring">Återkommande: %1$s</string>
<string name="expense_detail_delete_title">Ta bort utgiften?</string>
<string name="expense_detail_delete_body">Den försvinner för er båda.</string>
<string name="expense_detail_deleted_banner">%1$s tog bort den här utgiften · %2$s</string>
<string name="expense_detail_deleted_body">Den räknas inte längre i saldon eller statistik.</string>
<string name="expense_detail_missing">Utgiften finns inte på telefonen än</string>
<string name="expense_detail_missing_body">Den kan ha lagts till nyss på en annan telefon. Synka för att hämta den.</string>
<!-- Not in strings.csv yet (proposed in the M7c report): S-8 "Synka nu" and the split label "Allt på dig". -->
<string name="expense_detail_sync_now">Synka nu</string>
<string name="expense_split_full_on_you">Allt på dig</string>
</resources>
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- :feature:expense strings, keys from docs/ux/strings.csv (module=expense). -->
<resources>
<string name="expense_editor_new">New expense</string>
<string name="expense_editor_edit">Edit expense</string>
<string name="expense_editor_date">Date</string>
<string name="expense_editor_delete">Delete expense</string>
<string name="expense_editor_discard_title">Discard the expense?</string>
<string name="expense_detail_title">Expense</string>
<string name="expense_detail_account">Account</string>
<string name="expense_detail_date">Date</string>
<string name="expense_detail_paid_by">Paid by</string>
<string name="expense_detail_split">Split</string>
<string name="expense_detail_notes">Notes</string>
<string name="expense_detail_recurring">Recurring: %1$s</string>
<string name="expense_detail_delete_title">Delete the expense?</string>
<string name="expense_detail_delete_body">It disappears for both of you.</string>
<string name="expense_detail_deleted_banner">%1$s deleted this expense · %2$s</string>
<string name="expense_detail_deleted_body">It no longer counts in balances or analytics.</string>
<string name="expense_detail_missing">This expense is not on the phone yet</string>
<string name="expense_detail_missing_body">It may have just been added on another phone. Sync to fetch it.</string>
<!-- Not in strings.csv yet (proposed in the M7c report): S-8 "Synka nu" and the split label "Allt på dig". -->
<string name="expense_detail_sync_now">Sync now</string>
<string name="expense_split_full_on_you">All on you</string>
</resources>
@@ -0,0 +1,87 @@
package se.hagstedt.kvitt.feature.expense
import androidx.lifecycle.SavedStateHandle
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
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.FakeScenario
import se.hagstedt.kvitt.fakes.expense.ExpenseScenarios
import se.hagstedt.kvitt.model.ExpenseId
/** S-8 state holder: loaded, pending timeline line, deleted, missing, delete flow. */
@OptIn(ExperimentalCoroutinesApi::class)
class ExpenseDetailViewModelTest {
private val dispatcher = UnconfinedTestDispatcher()
@Before
fun setUp() = Dispatchers.setMain(dispatcher)
@After
fun tearDown() = Dispatchers.resetMain()
private fun TestScope.detail(id: ExpenseId, scenario: FakeScenario = ExpenseScenarios.household): Pair<ExpenseDetailViewModel, FakeRepositories> {
val fakes = FakeRepositories(scenario)
val vm = ExpenseDetailViewModel(SavedStateHandle(mapOf(ExpenseDetailViewModel.ARG_EXPENSE_ID to id.value)), fakes.expenses, fakes.home, fakes.connectivity, fakes.sync)
advanceUntilIdle()
return vm to fakes
}
@Test
fun loaded_withThePendingLineLast() = runTest(dispatcher) {
val (vm, _) = detail(ExpenseScenarios.icaId)
val d = vm.state.value.detail
assertFalse(vm.state.value.loading)
assertEquals("ICA Maxi Lindhagen", d?.description)
assertTrue(d?.pending == true)
assertTrue(d?.timeline?.last()?.pendingLocal == true)
assertEquals(4, d?.timeline?.size)
}
@Test
fun recurring_namesTheTemplate() = runTest(dispatcher) {
val (vm, _) = detail(ExpenseScenarios.hyraId)
assertEquals("Hyra", vm.state.value.detail?.recurringTemplateName)
}
@Test
fun missing_isNullAfterLoading() = runTest(dispatcher) {
val (vm, _) = detail(ExpenseScenarios.missingId)
assertFalse(vm.state.value.loading)
assertNull(vm.state.value.detail)
}
@Test
fun deleted_staysVisibleAsDeleted() = runTest(dispatcher) {
val (vm, _) = detail(ExpenseScenarios.icaId, ExpenseScenarios.deleted)
assertTrue(vm.state.value.detail?.deleted == true)
}
@Test
fun delete_asksThenDeletes() = runTest(dispatcher) {
val (vm, fakes) = detail(ExpenseScenarios.trananId)
vm.askDelete()
assertTrue(vm.state.value.confirmDelete)
vm.dismissDelete()
assertFalse(vm.state.value.confirmDelete)
vm.askDelete()
vm.confirmDelete()
advanceUntilIdle()
assertEquals(DetailEvent.Deleted, vm.eventFlow.first())
assertTrue(fakes.store.current.expenses.single { it.id == ExpenseScenarios.trananId }.deleted)
assertTrue(vm.state.value.detail?.deleted == true)
}
}
@@ -0,0 +1,295 @@
package se.hagstedt.kvitt.feature.expense
import androidx.lifecycle.SavedStateHandle
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
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.design.format.AmountKey
import se.hagstedt.kvitt.fakes.FakeRepositories
import se.hagstedt.kvitt.fakes.FakeScenario
import se.hagstedt.kvitt.fakes.FakeScenarios
import se.hagstedt.kvitt.fakes.expense.ExpenseScenarios
import se.hagstedt.kvitt.model.FieldError
import se.hagstedt.kvitt.model.Share
import se.hagstedt.kvitt.model.SplitMode
/** S-7 state holder: defaults, keypad input, split modes, the category suggestion, enablement and error mapping. */
@OptIn(ExperimentalCoroutinesApi::class)
class ExpenseEditorViewModelTest {
private val dispatcher = UnconfinedTestDispatcher()
private val jacob = FakeScenarios.owner.id
private val anna = FakeScenarios.partner.id
@Before
fun setUp() = Dispatchers.setMain(dispatcher)
@After
fun tearDown() = Dispatchers.resetMain()
private fun TestScope.editor(scenario: FakeScenario = ExpenseScenarios.household, args: Map<String, Any?> = emptyMap()): Pair<ExpenseEditorViewModel, FakeRepositories> {
val fakes = FakeRepositories(scenario)
val vm = ExpenseEditorViewModel(SavedStateHandle(args), fakes.expenses, fakes.home, fakes.accounts, fakes.connectivity)
advanceUntilIdle()
return vm to fakes
}
private fun ExpenseEditorViewModel.type(vararg keys: String) = keys.forEach { key ->
onKey(if (key == ".") AmountKey.Decimal else AmountKey.Digit(key.toInt()))
}
@Test
fun newExpense_appliesTheDefaults() = runTest(dispatcher) {
val (vm, _) = editor()
val s = vm.state.value
assertTrue(s.ready)
assertFalse(s.isEdit)
assertEquals(ExpenseScenarios.vardag.id, s.form?.accountId)
assertEquals(ExpenseScenarios.groceries.id, s.form?.categoryId)
assertEquals(jacob, s.form?.payer)
assertEquals(SplitMode.DEFAULT, s.form?.mode)
assertEquals("2026-10-17", s.form?.date)
assertEquals(KeypadTarget.Amount, s.keypad)
assertNull(s.preview)
assertFalse(s.canSave)
}
@Test
fun openedFromAnAccount_startsInThatAccount() = runTest(dispatcher) {
val (vm, _) = editor(args = mapOf(ExpenseEditorViewModel.ARG_ACCOUNT_ID to ExpenseScenarios.huset.id.value))
assertEquals(ExpenseScenarios.huset.id, vm.state.value.form?.accountId)
assertEquals(ExpenseScenarios.housing.id, vm.state.value.form?.categoryId)
}
@Test
fun tenSecondPath_amountDescriptionSuggestionSave() = runTest(dispatcher) {
val (vm, fakes) = editor()
vm.type("4", "1", "2")
assertEquals(41_200L, vm.state.value.form?.amount?.ore)
assertEquals(listOf(Share(jacob, 20_600, 5000), Share(anna, 20_600, 5000)), vm.state.value.preview)
assertFalse(vm.state.value.canSave)
vm.onDescription("Systembolaget")
advanceUntilIdle()
assertEquals(ExpenseScenarios.entertainment.id, vm.state.value.form?.categoryId)
assertTrue(vm.state.value.canSave)
vm.save()
advanceUntilIdle()
val event = vm.eventFlow.first()
assertTrue(event is EditorEvent.Saved && event.isNew && !event.offline)
assertEquals(1, fakes.store.current.expenses.count { it.description == "Systembolaget" && it.pending })
}
@Test
fun suggestion_stopsOncePickedByHand_andClearingReturnsTheDefault() = runTest(dispatcher) {
val (vm, _) = editor()
vm.onDescription("Systembolaget")
advanceUntilIdle()
vm.onDescription("")
advanceUntilIdle()
assertEquals(ExpenseScenarios.groceries.id, vm.state.value.form?.categoryId)
vm.onCategory(ExpenseScenarios.shopping.id)
vm.onDescription("Systembolaget")
advanceUntilIdle()
assertEquals(ExpenseScenarios.shopping.id, vm.state.value.form?.categoryId)
}
@Test
fun keypad_rejectsAThirdDecimalAndAmountsAboveTheLimit() = runTest(dispatcher) {
val (vm, _) = editor()
vm.type("1", "2", ".", "5", "0")
assertFalse(vm.onKey(AmountKey.Digit(1)))
assertFalse(vm.onKey(AmountKey.Decimal))
assertEquals(1_250L, vm.state.value.form?.amount?.ore)
vm.onKey(AmountKey.Clear)
vm.type("1", "0", "0", "0", "0", "0", "0", "0")
assertFalse(vm.onKey(AmountKey.Digit(1)))
assertEquals(1_000_000_000L, vm.state.value.form?.amount?.ore)
}
@Test
fun exactShareLargerThanTheAmount_blocksSaving() = runTest(dispatcher) {
val (vm, _) = editor()
vm.type("1", "0", "6", "0")
vm.onDescription("Ullared")
vm.toggleSplit()
vm.onMode(SplitMode.EXACT)
assertEquals(KeypadTarget.Exact, vm.state.value.keypad)
vm.type("1", "2", "0", "0")
assertTrue(vm.state.value.exactTooLarge)
assertNull(vm.state.value.preview)
assertFalse(vm.state.value.canSave)
vm.onKey(AmountKey.Delete)
vm.onKey(AmountKey.Clear)
vm.type("4", "0", "0")
advanceUntilIdle()
assertEquals(listOf(Share(jacob, 40_000, null), Share(anna, 66_000, null)), vm.state.value.preview)
assertTrue(vm.state.value.canSave)
}
@Test
fun ratio_typingOneSideSetsTheOther_andStartsAtTheAccountRatio() = runTest(dispatcher) {
val (vm, _) = editor(args = mapOf(ExpenseEditorViewModel.ARG_ACCOUNT_ID to ExpenseScenarios.huset.id.value))
vm.type("1", "0", "0")
vm.onMode(SplitMode.RATIO)
assertEquals(6_000L, vm.state.value.form?.ratioViewer?.ore)
vm.focusKeypad(KeypadTarget.RatioPartner)
vm.onKey(AmountKey.Clear)
vm.type("2", "5")
assertEquals(7_500L, vm.state.value.form?.ratioViewer?.ore)
assertEquals(listOf(Share(jacob, 7_500, 7500), Share(anna, 2_500, 2500)), vm.state.value.preview)
vm.type("1")
assertFalse(vm.onKey(AmountKey.Digit(5)))
}
@Test
fun full_defaultsToThePersonWhoDidNotPay() = runTest(dispatcher) {
val (vm, _) = editor()
vm.onMode(SplitMode.FULL)
assertEquals(anna, vm.state.value.form?.fullOn)
vm.togglePayer()
vm.onMode(SplitMode.FULL)
assertEquals(jacob, vm.state.value.form?.fullOn)
}
@Test
fun accountChange_inDefaultMode_recopiesTheRatio() = runTest(dispatcher) {
val (vm, _) = editor()
vm.type("1", "0", "0")
vm.onAccount(ExpenseScenarios.huset.id)
advanceUntilIdle()
assertEquals(listOf(Share(jacob, 6_000, 6000), Share(anna, 4_000, 4000)), vm.state.value.preview)
// CS-3: the suggestion re-runs on an account change (empty description: the AC-8.6 default of Huset).
assertEquals(ExpenseScenarios.housing.id, vm.state.value.form?.categoryId)
}
@Test
fun quickRow_usesTheAccountsFrequentCategories_selectedTakesTheFifthPlace() = runTest(dispatcher) {
val (vm, _) = editor()
val frequent = listOf(ExpenseScenarios.groceries, ExpenseScenarios.transport, ExpenseScenarios.entertainment, ExpenseScenarios.shopping)
assertEquals(frequent.map { it.id }, vm.state.value.quickRow.take(4).map { it.id })
assertEquals(5, vm.state.value.quickRow.size)
vm.onCategory(ExpenseScenarios.other.id)
assertEquals(frequent.map { it.id } + ExpenseScenarios.other.id, vm.state.value.quickRow.map { it.id })
}
@Test
fun offlineSave_isAllowed_andReportsOffline() = runTest(dispatcher) {
val (vm, _) = editor(ExpenseScenarios.offline)
vm.type("5", "0")
vm.onDescription("Pressbyrån")
advanceUntilIdle()
vm.save()
advanceUntilIdle()
val event = vm.eventFlow.first()
assertTrue(event is EditorEvent.Saved && event.offline)
}
@Test
fun noDefaultCategory_showsNoCategory_andBlocksSaving() = runTest(dispatcher) {
val (vm, _) = editor(ExpenseScenarios.noDefaultCategory)
vm.type("5", "0")
vm.onDescription("Okänt")
advanceUntilIdle()
assertNull(vm.state.value.form?.categoryId)
assertFalse(vm.state.value.canSave)
vm.save()
assertEquals(FieldError(ExpenseEditorViewModel.FIELD_CATEGORY, "required"), vm.state.value.errors.single())
}
@Test
fun missingAmountAndDescription_mapToRequired_andFocusTheAmount() = runTest(dispatcher) {
val (vm, _) = editor()
vm.focusKeypad(null)
vm.save()
val fields = vm.state.value.errors.associate { it.field to it.error }
assertEquals("required", fields[ExpenseEditorViewModel.FIELD_AMOUNT])
assertEquals("required", fields[ExpenseEditorViewModel.FIELD_DESCRIPTION])
assertEquals(KeypadTarget.Amount, vm.state.value.keypad)
vm.type("1")
assertNull(vm.state.value.errors.firstOrNull { it.field == ExpenseEditorViewModel.FIELD_AMOUNT })
}
@Test
fun dateTooFarAhead_comesBackAsValidationFailed() = runTest(dispatcher) {
val (vm, _) = editor()
vm.type("1", "0")
vm.onDescription("Biljetter")
advanceUntilIdle()
vm.onDate("2028-01-01")
vm.save()
advanceUntilIdle()
assertEquals(FieldError(ExpenseEditorViewModel.FIELD_DATE, "validation_failed"), vm.state.value.errors.single())
}
@Test
fun editMode_loadsTheExpense_keepsTheCategory_andDeletes() = runTest(dispatcher) {
val (vm, fakes) = editor(args = mapOf(ExpenseEditorViewModel.ARG_EXPENSE_ID to ExpenseScenarios.slKortId.value))
val s = vm.state.value
assertTrue(s.isEdit)
assertEquals(SplitMode.FULL, s.form?.mode)
assertEquals(anna, s.form?.fullOn)
assertTrue(s.splitOpen)
assertTrue(s.notesExpanded)
assertNull(s.keypad)
vm.onDescription("Systembolaget")
advanceUntilIdle()
assertEquals(ExpenseScenarios.transport.id, vm.state.value.form?.categoryId)
vm.showOverlay(EditorOverlay.Delete)
vm.confirmDelete()
advanceUntilIdle()
assertEquals(EditorEvent.Deleted, vm.eventFlow.first())
assertTrue(fakes.store.current.expenses.single { it.id == ExpenseScenarios.slKortId }.deleted)
}
@Test
fun editSave_reportsAnEdit() = runTest(dispatcher) {
val (vm, _) = editor(args = mapOf(ExpenseEditorViewModel.ARG_EXPENSE_ID to ExpenseScenarios.ullaredId.value))
assertEquals(40_000L, vm.state.value.form?.exactInput?.ore)
assertEquals(listOf(Share(jacob, 40_000, null), Share(anna, 66_000, null)), vm.state.value.preview)
vm.togglePayer()
vm.save()
advanceUntilIdle()
val event = vm.eventFlow.first()
assertTrue(event is EditorEvent.Saved && !event.isNew)
}
@Test
fun editedAmounts_keepBothDecimals() = runTest(dispatcher) {
val (vm, _) = editor(args = mapOf(ExpenseEditorViewModel.ARG_EXPENSE_ID to ExpenseScenarios.icaId.value))
assertEquals("1247.60", vm.state.value.form?.amount?.raw)
assertEquals("412", ExpenseEditorViewModel.moneyInput(41_200).raw)
assertEquals("0.05", ExpenseEditorViewModel.moneyInput(5).raw)
}
@Test
fun close_asksFirstOnlyWithChanges_andBackHidesTheKeypadFirst() = runTest(dispatcher) {
val (vm, _) = editor()
vm.back()
assertNull(vm.state.value.keypad)
vm.close()
assertEquals(EditorEvent.Closed, vm.eventFlow.first())
vm.onDescription("Taxi")
vm.close()
assertEquals(EditorOverlay.Discard, vm.state.value.overlay)
vm.discard()
assertEquals(EditorEvent.Closed, vm.eventFlow.first())
}
@Test
fun editingADeletedExpense_isMissing() = runTest(dispatcher) {
val (vm, _) = editor(ExpenseScenarios.deleted, mapOf(ExpenseEditorViewModel.ARG_EXPENSE_ID to ExpenseScenarios.icaId.value))
assertTrue(vm.state.value.missing)
}
}
@@ -0,0 +1,170 @@
package se.hagstedt.kvitt.feature.expense.screenshot
import androidx.compose.runtime.Composable
import androidx.lifecycle.SavedStateHandle
import se.hagstedt.kvitt.design.format.AmountKey
import se.hagstedt.kvitt.fakes.FakeRepositories
import se.hagstedt.kvitt.fakes.FakeScenario
import se.hagstedt.kvitt.fakes.FakeScenarios
import se.hagstedt.kvitt.fakes.expense.ExpenseScenarios
import se.hagstedt.kvitt.design.component.ProvideHousehold
import se.hagstedt.kvitt.feature.expense.DeleteExpenseDialog
import se.hagstedt.kvitt.feature.expense.EditorDialog
import se.hagstedt.kvitt.feature.expense.EditorOverlay
import se.hagstedt.kvitt.feature.expense.EditorSheetContent
import se.hagstedt.kvitt.feature.expense.ExpenseDetailActions
import se.hagstedt.kvitt.feature.expense.ExpenseDetailContent
import se.hagstedt.kvitt.feature.expense.ExpenseDetailViewModel
import se.hagstedt.kvitt.feature.expense.ExpenseEditorActions
import se.hagstedt.kvitt.feature.expense.ExpenseEditorContent
import se.hagstedt.kvitt.feature.expense.ExpenseEditorViewModel
import se.hagstedt.kvitt.feature.expense.KeypadTarget
import se.hagstedt.kvitt.model.AccountId
import se.hagstedt.kvitt.model.CategoryId
import se.hagstedt.kvitt.model.ExpenseId
import se.hagstedt.kvitt.model.SplitMode
import se.hagstedt.kvitt.model.UserId
/** A screenshot case: its golden name, whether it has a 200 % golden, and its content. */
class Case(val name: String, val largeFont: Boolean = false, val content: () -> @Composable () -> Unit)
/** Actions that do nothing: goldens render a fixed state. */
object NoActions : ExpenseEditorActions, ExpenseDetailActions {
override fun onKey(key: AmountKey) = true
override fun focusKeypad(target: KeypadTarget?) = Unit
override fun onDescription(text: String) = Unit
override fun onNotes(text: String) = Unit
override fun expandNotes() = Unit
override fun onCategory(id: CategoryId) = Unit
override fun onAccount(id: AccountId) = Unit
override fun onDate(iso: String) = Unit
override fun togglePayer() = Unit
override fun toggleSplit() = Unit
override fun onMode(mode: SplitMode) = Unit
override fun onViewerPercent(percent: Int) = Unit
override fun onExactTyped(user: UserId) = Unit
override fun onFullOn(user: UserId) = Unit
override fun showOverlay(overlay: EditorOverlay) = Unit
override fun dismissOverlay() = Unit
override fun close() = Unit
override fun back() = Unit
override fun discard() = Unit
override fun confirmDelete() = Unit
override fun save() = Unit
override fun askDelete() = Unit
override fun dismissDelete() = Unit
override fun syncNow() = Unit
}
private fun ExpenseEditorViewModel.type(text: String) = text.forEach { c ->
onKey(if (c == '.') AmountKey.Decimal else AmountKey.Digit(c.digitToInt()))
}
/** S-7 in [scenario] after [steps] (driven through the real view model and the fakes). */
private fun editor(
name: String,
scenario: FakeScenario = ExpenseScenarios.household,
edit: ExpenseId? = null,
largeFont: Boolean = false,
steps: ExpenseEditorViewModel.(idle: () -> Unit) -> Unit = {},
) = Case(name, largeFont) {
val state = ScreenSnapshots.build { idle ->
val fakes = FakeRepositories(scenario)
val args = if (edit == null) emptyMap() else mapOf(ExpenseEditorViewModel.ARG_EXPENSE_ID to edit.value)
val vm = ExpenseEditorViewModel(SavedStateHandle(args), fakes.expenses, fakes.home, fakes.accounts, fakes.connectivity)
idle()
vm.steps(idle)
idle()
vm.state.value
}
when (state.overlay) {
EditorOverlay.None -> ({ ExpenseEditorContent(state, NoActions) })
EditorOverlay.AccountPicker, EditorOverlay.CategoryGrid -> ({
OverlayFrame(sheet = true, { ExpenseEditorContent(state.copy(overlay = EditorOverlay.None), NoActions) }) {
ProvideHousehold(FakeScenarios.owner, FakeScenarios.partner) { EditorSheetContent(state, NoActions) }
}
})
else -> ({
OverlayFrame(sheet = false, { ExpenseEditorContent(state.copy(overlay = EditorOverlay.None), NoActions) }) { EditorDialog(state, NoActions, inline = true) }
})
}
}
/** S-8 of [id] in [scenario]. */
private fun detail(
name: String,
id: ExpenseId,
scenario: FakeScenario = ExpenseScenarios.household,
largeFont: Boolean = false,
steps: ExpenseDetailViewModel.() -> Unit = {},
) = Case(name, largeFont) {
val state = ScreenSnapshots.build { idle ->
val fakes = FakeRepositories(scenario)
val vm = ExpenseDetailViewModel(SavedStateHandle(mapOf(ExpenseDetailViewModel.ARG_EXPENSE_ID to id.value)), fakes.expenses, fakes.home, fakes.connectivity, fakes.sync)
idle()
vm.steps()
vm.state.value
}
if (!state.confirmDelete) {
({ ExpenseDetailContent(state, NoActions, {}, {}) })
} else {
({ OverlayFrame(sheet = false, { ExpenseDetailContent(state.copy(confirmDelete = false), NoActions, {}, {}) }) { DeleteExpenseDialog({}, {}, inline = true) } })
}
}
private val tenSecond: ExpenseEditorViewModel.(() -> Unit) -> Unit = { idle ->
type("412")
onDescription("Systembolaget")
idle()
}
/** Every S-7 state of design-spec §4 S-7 and PLAN-M7c §5 (phone height). */
val editorCases = listOf(
editor("s7_new_defaults"),
editor("s7_new", largeFont = true, steps = tenSecond),
editor("s7_edit", edit = ExpenseScenarios.icaId),
editor("s7_split_default") { idle -> tenSecond(idle); focusKeypad(null); toggleSplit() },
editor("s7_split_ratio") { idle ->
onAccount(ExpenseScenarios.huset.id); type("1560"); onDescription("Middag på Tranan"); idle()
onCategory(ExpenseScenarios.restaurants.id); toggleSplit(); onMode(SplitMode.RATIO); onViewerPercent(70); focusKeypad(KeypadTarget.RatioViewer)
},
editor("s7_split_exact_error") { idle ->
type("1060"); onDescription("Ullared"); idle(); onCategory(ExpenseScenarios.shopping.id); toggleSplit(); onMode(SplitMode.EXACT); type("1200")
},
editor("s7_split_full_other") { idle -> tenSecond(idle); focusKeypad(null); toggleSplit(); onMode(SplitMode.FULL) },
editor("s7_split_full_payer") { idle -> tenSecond(idle); focusKeypad(null); toggleSplit(); onMode(SplitMode.FULL); onFullOn(FakeScenarios.owner.id) },
editor("s7_full_notes_edit", edit = ExpenseScenarios.slKortId),
editor("s7_errors_missing") { _ -> focusKeypad(null); save(); focusKeypad(null) },
editor("s7_notes_expanded") { idle -> tenSecond(idle); focusKeypad(null); expandNotes(); onNotes("Till middagen på lördag") },
editor("s7_account_picker") { idle -> tenSecond(idle); showOverlay(EditorOverlay.AccountPicker) },
editor("s7_category_grid") { idle -> tenSecond(idle); showOverlay(EditorOverlay.CategoryGrid) },
// September: the picker's today marker (the real clock, not the fixed one) stays out of the golden.
editor("s7_date_picker", edit = ExpenseScenarios.hyraId) { _ -> showOverlay(EditorOverlay.DatePicker) },
editor("s7_delete_confirm", edit = ExpenseScenarios.icaId) { _ -> showOverlay(EditorOverlay.Delete) },
editor("s7_discard_confirm") { idle -> tenSecond(idle); close() },
editor("s7_offline", scenario = ExpenseScenarios.offline, steps = tenSecond),
editor("s7_no_category", scenario = ExpenseScenarios.noDefaultCategory) { idle -> type("89"); onDescription("Loppis"); idle() },
editor("s7_long", scenario = ExpenseScenarios.long, largeFont = true) { idle ->
type("1234567.89"); onDescription("Snickare Bergström, nytt golv i hallen, köket och vardagsrummet"); idle()
},
editor("s7_long_edit", scenario = ExpenseScenarios.long, edit = ExpenseScenarios.snickareId),
)
/** Every S-8 state of design-spec §4 S-8 and PLAN-M7c §5 (phone height). */
val detailCases = listOf(
detail("s8_loaded_pending", ExpenseScenarios.icaId, largeFont = true),
detail("s8_ratio_timeline", ExpenseScenarios.trananId),
detail("s8_exact", ExpenseScenarios.ullaredId),
detail("s8_full", ExpenseScenarios.slKortId),
detail("s8_recurring", ExpenseScenarios.hyraId),
detail("s8_deleted", ExpenseScenarios.icaId, ExpenseScenarios.deleted),
detail("s8_missing", ExpenseScenarios.missingId),
detail("s8_missing_offline", ExpenseScenarios.missingId, ExpenseScenarios.offline),
detail("s8_offline", ExpenseScenarios.icaId, ExpenseScenarios.offline),
detail("s8_delete_confirm", ExpenseScenarios.trananId) { askDelete() },
)
/** Long content on a tall frame so the whole screen shows. */
val tallCases = listOf(
detail("s8_long", ExpenseScenarios.snickareId, ExpenseScenarios.long),
)
@@ -0,0 +1,55 @@
package se.hagstedt.kvitt.feature.expense.screenshot
import androidx.compose.ui.test.junit4.v2.createComposeRule
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.ParameterizedRobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/**
* S-7 and S-8 goldens: every state × light/dark × Swedish/English, plus 200 % for the states the design spec draws at
* 200 % (PLAN-M7c §5, §7). Frame: Galaxy S26, 411 × 891 dp. Re-record with
* `tools/gradle-serial.sh :feature:expense:recordRoborazziDebug` and open every changed PNG.
*/
@RunWith(ParameterizedRobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [36], qualifiers = "w411dp-h891dp-xxhdpi")
class ExpenseScreenshotTest(private val name: String) {
@get:Rule
val compose = createComposeRule()
@Test
fun screen() {
val case = (editorCases + detailCases).first { it.name == name }
ScreenSnapshots.snap(compose, case.name, case.largeFont, case.content())
}
companion object {
@JvmStatic
@ParameterizedRobolectricTestRunner.Parameters(name = "{0}")
fun cases(): List<Array<Any>> = (editorCases + detailCases).map { arrayOf<Any>(it.name) }
}
}
/** Long content on a tall frame (411 × 1600 dp) so the whole screen, including a long timeline, is in the golden. */
@RunWith(ParameterizedRobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [36], qualifiers = "w411dp-h1600dp-xxhdpi")
class ExpenseTallScreenshotTest(private val name: String) {
@get:Rule
val compose = createComposeRule()
@Test
fun screen() {
val case = tallCases.first { it.name == name }
ScreenSnapshots.snap(compose, case.name, case.largeFont, case.content())
}
companion object {
@JvmStatic
@ParameterizedRobolectricTestRunner.Parameters(name = "{0}")
fun cases(): List<Array<Any>> = tallCases.map { arrayOf<Any>(it.name) }
}
}
@@ -0,0 +1,44 @@
package se.hagstedt.kvitt.feature.expense.screenshot
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.BottomSheetDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/**
* A sheet or dialog drawn over the screen in the same window, the way `:core:design` screenshots `ConfirmDialogContent`
* and the sheet contents: Robolectric captures of separate windows lose the configuration's locale (the window's own
* `LocalConfiguration`), so the goldens draw the real content in the Material sheet or dialog frame instead.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun OverlayFrame(sheet: Boolean, base: @Composable () -> Unit, overlay: @Composable () -> Unit) {
Box(Modifier.fillMaxSize()) {
base()
Box(Modifier.matchParentSize().background(MaterialTheme.colorScheme.scrim.copy(alpha = if (sheet) SHEET_SCRIM else DIALOG_SCRIM)))
if (sheet) {
Surface(Modifier.align(Alignment.BottomCenter).fillMaxWidth(), shape = BottomSheetDefaults.ExpandedShape, color = BottomSheetDefaults.ContainerColor) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
BottomSheetDefaults.DragHandle()
Box(Modifier.fillMaxWidth()) { overlay() }
}
}
} else {
Box(Modifier.align(Alignment.Center).padding(horizontal = DIALOG_SIDE)) { overlay() }
}
}
}
private const val SHEET_SCRIM = 0.32f
private const val DIALOG_SCRIM = 0.38f
private val DIALOG_SIDE = 24.dp
@@ -0,0 +1,103 @@
package se.hagstedt.kvitt.feature.expense.screenshot
import android.content.res.Configuration
import android.os.LocaleList
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.test.junit4.ComposeContentTestRule
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.unit.Density
import com.github.takahirom.roborazzi.captureRoboImage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import kotlinx.datetime.TimeZone
import se.hagstedt.kvitt.design.KvittTheme
import se.hagstedt.kvitt.design.component.KvittClock
import se.hagstedt.kvitt.design.component.LocalKvittClock
import java.util.Locale
import kotlin.time.Instant
/**
* Captures one golden per language and theme of a whole screen: `src/test/screenshots/<name>_<sv|en>_<light|dark>.png`,
* plus `<name>_sv_light_200.png` when `largeFont` is set (200 % font scale, Swedish and light, as the spec's 200 %
* frames). The pattern of `:core:design` (M6): content set once, language, theme and font scale switched through a
* configuration context between captures.
* The clock is fixed at 17 October 2026 14:02 in Stockholm, the fake scenarios' "now".
*/
object ScreenSnapshots {
val clock = KvittClock.fixed(Instant.parse("2026-10-17T12:02:00Z"), TimeZone.of("Europe/Stockholm"))
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 ""
}
fun snap(compose: ComposeContentTestRule, name: String, largeFont: Boolean = false, content: @Composable () -> Unit) {
val variants = listOf("sv", "en").flatMap { l -> listOf(false, true).map { Variant(l, it, 1f) } } +
if (largeFont) listOf(Variant("sv", dark = false, fontScale = 2f)) else emptyList()
var current by mutableStateOf(variants.first())
compose.setContent {
val variant = current
val base = LocalContext.current
val baseConfiguration = LocalConfiguration.current
val context = remember(variant) {
val config = Configuration(baseConfiguration).apply {
setLocales(LocaleList(Locale.forLanguageTag(if (variant.language == "sv") "sv-SE" else "en-GB")))
fontScale = variant.fontScale
uiMode = (uiMode and Configuration.UI_MODE_NIGHT_MASK.inv()) or if (variant.dark) Configuration.UI_MODE_NIGHT_YES else Configuration.UI_MODE_NIGHT_NO
}
base.createConfigurationContext(config)
}
CompositionLocalProvider(
LocalContext provides context,
LocalConfiguration provides context.resources.configuration,
LocalResources provides context.resources,
LocalDensity provides Density(context),
LocalKvittClock provides clock,
) {
KvittTheme(darkTheme = variant.dark) { content() }
}
}
// Material's date picker formats months and weekdays with the JVM default locale (the app's locale on a phone).
val hostLocale = Locale.getDefault()
try {
variants.forEach { variant ->
Locale.setDefault(Locale.forLanguageTag(if (variant.language == "sv") "sv-SE" else "en-GB"))
current = variant
compose.waitForIdle()
compose.onRoot().captureRoboImage("src/test/screenshots/${name}_${variant.suffix}.png")
}
} finally {
Locale.setDefault(hostLocale)
}
}
/**
* Runs [setup] with the view models' main dispatcher set to a test dispatcher and returns its result; [setup] calls
* the given `idle` after actions that wait (the 150 ms suggestion debounce).
*/
@OptIn(ExperimentalCoroutinesApi::class)
fun <T> build(setup: (idle: () -> Unit) -> T): T {
val dispatcher = UnconfinedTestDispatcher()
Dispatchers.setMain(dispatcher)
val out = mutableListOf<T>()
try {
runTest(dispatcher) { out += setup { advanceUntilIdle() } }
} finally {
Dispatchers.resetMain()
}
return out.single()
}
}