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:
+266
@@ -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(),
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+274
@@ -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
|
||||
+107
@@ -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"
|
||||
}
|
||||
}
|
||||
+491
@@ -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
|
||||
+561
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
-14
@@ -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>
|
||||
+87
@@ -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)
|
||||
}
|
||||
}
|
||||
+295
@@ -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)
|
||||
}
|
||||
}
|
||||
+170
@@ -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),
|
||||
)
|
||||
+55
@@ -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) }
|
||||
}
|
||||
}
|
||||
+44
@@ -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
|
||||
+103
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user