K2: card purchases on Android (Room 2, card cycle, :feature:cards S-23 to S-27)

- :core:model model/Cards.kt per SPEC §14.2.4 "Changes in v0.35"; ContractTest covers the card wire values.
- :domain matchCardPurchase (§14.5.8), passing match_card_purchase.json (17 cases) in VectorTest.
- :data Room version 2 (card_connections, card_purchases, card_sync_state, MIGRATION_1_2 with its test,
  2.json exported); DTOs for E-36 to E-44 round-tripping K1's examples; the card cycle (E-44 push of
  pending link changes, E-40 pull) after each household cycle and after refresh; CardRepositoryImpl with
  CP-9 prefill, CP-10 matches, offline-first link/unlink/ignore; a local expense delete unlinks.
- :core:fakes FakeCardRepository and fakes/cards scenarios for every state of S-23 to S-27.
- :feature:cards S-23 (sheet and "Välj en annan utgift"), S-24, S-25 (Custom Tab, E-38 every 2 s while
  resumed, return link), S-26 (disconnect dialog), S-27, CardArt with the SJ Prio picture and the drawn
  fallback; 135 goldens and state-holder tests.
- :feature:expense S-7 cardPurchaseId prefill and link on save, S-8 "Från kortköp" line;
  :feature:account S-5 "Från kortköp"; :feature:settings S-13 "Anslut kort".
- :core:design KvittDestination.Cards with credit_card and nav_cards (four destinations, D-172); the
  bar's labels shrink to fit at large font scales. :app routes, kvitt://cards/connected, bindings.
- docs/ux/strings.csv: the card keys of §13.3 plus K2's additions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
2026-10-24 16:49:47 +02:00
parent 07df30d04d
commit a8f91ee6ea
391 changed files with 6889 additions and 54 deletions
@@ -4,6 +4,7 @@ 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.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -40,6 +41,8 @@ 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.KvittButton
import se.hagstedt.kvitt.design.component.KvittCard
import se.hagstedt.kvitt.design.component.KvittIconImage
import se.hagstedt.kvitt.design.component.KvittScaffold
import se.hagstedt.kvitt.design.component.KvittTopAppBar
@@ -56,6 +59,7 @@ import se.hagstedt.kvitt.design.component.TopBarNavigation
import se.hagstedt.kvitt.design.component.categoryName
import se.hagstedt.kvitt.design.component.detailDate
import se.hagstedt.kvitt.design.component.kvittLanguage
import se.hagstedt.kvitt.design.component.listDate
import se.hagstedt.kvitt.design.component.personName
import se.hagstedt.kvitt.design.component.savedSplitLabel
import se.hagstedt.kvitt.design.component.shareText
@@ -64,6 +68,7 @@ import se.hagstedt.kvitt.design.format.DateFormatter
import se.hagstedt.kvitt.design.icon.KvittIcon
import se.hagstedt.kvitt.design.theme.KvittDimens
import se.hagstedt.kvitt.model.AuditAction
import se.hagstedt.kvitt.model.CardPurchase
import se.hagstedt.kvitt.model.ExpenseDetailState
import se.hagstedt.kvitt.model.ExpenseId
import se.hagstedt.kvitt.model.User
@@ -76,6 +81,7 @@ interface ExpenseDetailActions {
fun dismissDelete()
fun confirmDelete()
fun syncNow()
fun unlinkCard()
}
/**
@@ -137,7 +143,7 @@ private fun DetailSurface(state: ExpenseDetailUiState, actions: ExpenseDetailAct
.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 (detail == null) Missing(state, actions) else Loaded(detail, state, actions)
}
}
if (state.confirmDelete) DeleteExpenseDialog(actions::confirmDelete, actions::dismissDelete)
@@ -160,12 +166,13 @@ private fun ColumnScope.Missing(state: ExpenseDetailUiState, actions: ExpenseDet
}
@Composable
private fun Loaded(detail: ExpenseDetailState, state: ExpenseDetailUiState) {
private fun Loaded(detail: ExpenseDetailState, state: ExpenseDetailUiState, actions: ExpenseDetailActions) {
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)
state.cardPurchase?.takeIf { !detail.deleted }?.let { CardLine(it, actions) }
val showNotes = detail.notes.isNotEmpty()
SegmentedGroup(if (showNotes) FACTS_WITH_NOTES else FACTS, faded) { item ->
when (item.index) {
@@ -238,6 +245,22 @@ private fun Hero(detail: ExpenseDetailState, modifier: Modifier) {
}
}
/** AC-22.10: "Från kortköp: <merchant>, <date>" with "Ta bort koppling", for the card holder only (CP-9, D-169). */
@Composable
private fun CardLine(purchase: CardPurchase, actions: ExpenseDetailActions) {
KvittCard(Modifier.fillMaxWidth(), contentPadding = PaddingValues(start = KvittTheme.spacing.s4, end = KvittTheme.spacing.s2, top = KvittTheme.spacing.s2, bottom = KvittTheme.spacing.s2)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3)) {
KvittIconImage(KvittIcon.CreditCard, null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
Text(
stringResource(R.string.expense_detail_from_card, purchase.merchant, listDate(purchase.date)),
Modifier.weight(1f).padding(vertical = KvittTheme.spacing.s2),
style = MaterialTheme.typography.bodyMedium,
)
KvittButton(stringResource(R.string.expense_detail_unlink_card), actions::unlinkCard, kind = ButtonKind.Text)
}
}
}
/** "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) {
@@ -15,6 +15,8 @@ import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import se.hagstedt.kvitt.model.CardPurchase
import se.hagstedt.kvitt.model.CardRepository
import se.hagstedt.kvitt.model.ConnectivityRepository
import se.hagstedt.kvitt.model.ExpenseDetailState
import se.hagstedt.kvitt.model.ExpenseId
@@ -26,7 +28,8 @@ 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).
* shows the deleted state (AC-8.11, design-spec S-8). [cardPurchase]: the viewer's card purchase linked to it, shown
* to the card holder only, since only the holder's card rows are on the phone (AC-22.10, D-161).
*/
data class ExpenseDetailUiState(
val loading: Boolean = true,
@@ -36,6 +39,7 @@ data class ExpenseDetailUiState(
val online: Boolean = true,
val confirmDelete: Boolean = false,
val syncing: Boolean = false,
val cardPurchase: CardPurchase? = null,
)
/** One-off results of S-8. */
@@ -51,6 +55,7 @@ class ExpenseDetailViewModel @Inject constructor(
home: HomeRepository,
connectivity: ConnectivityRepository,
private val sync: SyncRepository,
private val cards: CardRepository,
) : ViewModel(), ExpenseDetailActions {
private val id: ExpenseId? = savedStateHandle.get<String>(ARG_EXPENSE_ID)?.let(::ExpenseId)
private val local = MutableStateFlow(Local())
@@ -65,7 +70,8 @@ class ExpenseDetailViewModel @Inject constructor(
home.home(),
connectivity.online,
local,
) { detail, h, online, l ->
id?.let { cards.purchaseForExpense(it) } ?: flowOf(null),
) { detail, h, online, l, purchase ->
ExpenseDetailUiState(
loading = false,
detail = detail,
@@ -74,6 +80,7 @@ class ExpenseDetailViewModel @Inject constructor(
online = online,
confirmDelete = l.confirmDelete,
syncing = l.syncing,
cardPurchase = purchase,
)
}.stateIn(viewModelScope, SharingStarted.Eagerly, ExpenseDetailUiState())
@@ -91,6 +98,12 @@ class ExpenseDetailViewModel @Inject constructor(
}
}
/** "Ta bort koppling" on the card line (CP-9): local at once, the expense stays. */
override fun unlinkCard() {
val purchase = state.value.cardPurchase ?: return
viewModelScope.launch { cards.unlink(purchase.id) }
}
/** Missing state: "Synka nu"; the screen fills in when the row arrives. */
override fun syncNow() {
local.update { it.copy(syncing = true) }
@@ -27,6 +27,8 @@ 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.CardPurchaseId
import se.hagstedt.kvitt.model.CardRepository
import se.hagstedt.kvitt.model.CategoryId
import se.hagstedt.kvitt.model.ConnectivityRepository
import se.hagstedt.kvitt.model.ExpenseDraft
@@ -42,7 +44,8 @@ import javax.inject.Inject
/**
* 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).
* edits that expense; otherwise a new expense, in [ARG_ACCOUNT_ID] when opened from an account (S-5), prefilled from
* [ARG_CARD_PURCHASE_ID] when made from a card purchase (CP-9).
*/
@HiltViewModel
class ExpenseEditorViewModel @Inject constructor(
@@ -51,9 +54,11 @@ class ExpenseEditorViewModel @Inject constructor(
home: HomeRepository,
accounts: AccountRepository,
connectivity: ConnectivityRepository,
private val cards: CardRepository,
) : 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 val cardPurchase: CardPurchaseId? = savedStateHandle.get<String>(ARG_CARD_PURCHASE_ID)?.let(::CardPurchaseId)
private data class Environment(val options: ExpenseEditorOptions, val summaries: List<AccountSummary>, val all: List<Account>, val online: Boolean)
@@ -137,6 +142,8 @@ class ExpenseEditorViewModel @Inject constructor(
notesExpanded = draft.notes.isNotEmpty(),
)
}
} else if (cardPurchase != null && openFromCard(options, cardPurchase)) {
return
} else {
val activeIds = options.accounts.map { it.id }
val accountId = openerAccount?.takeIf { it in activeIds } ?: options.defaultAccountId
@@ -162,6 +169,43 @@ class ExpenseEditorViewModel @Inject constructor(
}
}
/**
* CP-9 (D-169): a new expense prefilled from the card purchase (amount, merchant text, date, the viewer as payer,
* the account it was opened from or the AC-8.6 default, split `default`); a foreign-currency purchase leaves the
* amount empty. The §9.11 suggestion then picks the category, as for any new expense (CS-1). False when the
* purchase is unknown: the editor opens empty.
*/
private suspend fun openFromCard(options: ExpenseEditorOptions, purchase: CardPurchaseId): Boolean {
val draft = cards.prefill(purchase, openerAccount) ?: return false
val accountId = draft.accountId?.takeIf { id -> options.accounts.any { it.id == id } } ?: options.defaultAccountId
val form = EditorForm(
id = null,
accountId = accountId,
categoryId = draft.categoryId ?: accountId?.let { options.defaultCategoryFor[it] },
amount = draft.amountOre?.let(AmountInput::of) ?: AmountInput(),
description = draft.description,
notes = draft.notes,
date = draft.date,
payer = draft.payer,
mode = SplitMode.DEFAULT,
ratioViewer = AmountInput(),
ratioPartner = AmountInput(),
exactTyped = options.me.id,
exactInput = AmountInput(),
fullOn = if (draft.payer == options.me.id) options.partner.id else options.me.id,
)
ui.update {
it.copy(
form = form,
initial = form,
frequent = accountId?.let { a -> options.frequentCategoryIds[a] }.orEmpty(),
keypad = if (draft.amountOre == null) KeypadTarget.Amount else null,
)
}
suggest()
return true
}
private fun derive(env: Environment?, u: Ui): ExpenseEditorState {
if (env == null) return ExpenseEditorState()
val options = env.options
@@ -371,6 +415,8 @@ class ExpenseEditorViewModel @Inject constructor(
viewModelScope.launch {
when (val result = expenses.save(draftOf(form, me.id, partner.id))) {
is SaveResult.Saved -> {
// CP-9: "Spara" also links the purchase the expense was made from (local at once, sent after the push).
if (form.id == null && cardPurchase != null) cards.link(cardPurchase, result.id)
ui.update { it.copy(saving = false, initial = it.form) }
events.send(EditorEvent.Saved(result.id, isNew = form.id == null, offline = !current.online))
}
@@ -427,6 +473,9 @@ class ExpenseEditorViewModel @Inject constructor(
/** Navigation argument: the account a new expense starts in when opened from S-5. */
const val ARG_ACCOUNT_ID = "accountId"
/** Navigation argument: the card purchase a new expense is made from (S-23's sheet or S-27, CP-9). */
const val ARG_CARD_PURCHASE_ID = "cardPurchaseId"
const val FIELD_AMOUNT = "amountOre"
const val FIELD_DESCRIPTION = "description"
const val FIELD_NOTES = "notes"
@@ -18,4 +18,8 @@
<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>
<string name="expense_detail_sync_now">Synka nu</string>
<!-- S-8, the card holder only (AC-22.10); %1$s merchant, %2$s purchase date (list form) -->
<string name="expense_detail_from_card">Från kortköp: %1$s, %2$s</string>
<!-- S-8 action on the card line (CP-9) -->
<string name="expense_detail_unlink_card">Ta bort koppling</string>
</resources>
@@ -18,4 +18,8 @@
<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>
<string name="expense_detail_sync_now">Sync now</string>
<!-- S-8, the card holder only (AC-22.10); %1$s merchant, %2$s purchase date (list form) -->
<string name="expense_detail_from_card">From card purchase: %1$s, %2$s</string>
<!-- S-8 action on the card line (CP-9) -->
<string name="expense_detail_unlink_card">Remove link</string>
</resources>
@@ -51,7 +51,7 @@ class EditorOptionRowsLayoutTest {
private val short: ExpenseEditorState = buildState { idle ->
val fakes = FakeRepositories(ExpenseScenarios.household)
val vm = ExpenseEditorViewModel(SavedStateHandle(), fakes.expenses, fakes.home, fakes.accounts, fakes.connectivity)
val vm = ExpenseEditorViewModel(SavedStateHandle(), fakes.expenses, fakes.home, fakes.accounts, fakes.connectivity, fakes.cards)
idle()
"412".forEach { vm.onKey(AmountKey.Digit(it.digitToInt())) }
vm.onDescription("Systembolaget")
@@ -14,7 +14,9 @@ import org.junit.Rule
import org.junit.Test
import se.hagstedt.kvitt.fakes.FakeRepositories
import se.hagstedt.kvitt.fakes.FakeScenario
import se.hagstedt.kvitt.fakes.cards.CardScenarios
import se.hagstedt.kvitt.fakes.expense.ExpenseScenarios
import se.hagstedt.kvitt.fakes.home.HomeScenarios
import se.hagstedt.kvitt.model.ExpenseId
import se.hagstedt.kvitt.design.testing.MainDispatcherRule
@@ -27,7 +29,7 @@ class ExpenseDetailViewModelTest {
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)
val vm = ExpenseDetailViewModel(SavedStateHandle(mapOf(ExpenseDetailViewModel.ARG_EXPENSE_ID to id.value)), fakes.expenses, fakes.home, fakes.connectivity, fakes.sync, fakes.cards)
advanceUntilIdle()
return vm to fakes
}
@@ -76,4 +78,20 @@ class ExpenseDetailViewModelTest {
assertTrue(fakes.store.current.expenses.single { it.id == ExpenseScenarios.trananId }.deleted)
assertTrue(vm.state.value.detail?.deleted == true)
}
@Test
fun aLinkedCardPurchaseShowsItsLine_andUnlinkRemovesIt() = runTest(dispatcher) {
val (vm, fakes) = detail(HomeScenarios.willys.id, CardScenarios.loaded)
assertEquals("WILLYS HORNSTULL", vm.state.value.cardPurchase?.merchant)
vm.unlinkCard()
advanceUntilIdle()
assertNull(vm.state.value.cardPurchase)
assertNull(fakes.store.current.cards.purchases.first { it.id == CardScenarios.willys.id }.expenseId)
}
@Test
fun anExpenseWithoutACardPurchaseHasNoLine() = runTest(dispatcher) {
val (vm, _) = detail(HomeScenarios.icaMaxi.id, CardScenarios.loaded)
assertNull(vm.state.value.cardPurchase)
}
}
@@ -17,7 +17,9 @@ 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.cards.CardScenarios
import se.hagstedt.kvitt.fakes.expense.ExpenseScenarios
import se.hagstedt.kvitt.fakes.home.HomeScenarios
import se.hagstedt.kvitt.model.FieldError
import se.hagstedt.kvitt.model.Share
import se.hagstedt.kvitt.model.SplitMode
@@ -34,7 +36,7 @@ class ExpenseEditorViewModelTest {
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)
val vm = ExpenseEditorViewModel(SavedStateHandle(args), fakes.expenses, fakes.home, fakes.accounts, fakes.connectivity, fakes.cards)
advanceUntilIdle()
return vm to fakes
}
@@ -286,4 +288,41 @@ class ExpenseEditorViewModelTest {
val (vm, _) = editor(ExpenseScenarios.deleted, mapOf(ExpenseEditorViewModel.ARG_EXPENSE_ID to ExpenseScenarios.icaId.value))
assertTrue(vm.state.value.missing)
}
// CP-9 (D-169): from a card purchase.
@Test
fun fromACardPurchase_isANewExpensePrefilled_andSaveLinksThePurchase() = runTest(dispatcher) {
val purchase = CardScenarios.wolt
val (vm, fakes) = editor(CardScenarios.loaded, mapOf(ExpenseEditorViewModel.ARG_CARD_PURCHASE_ID to purchase.id.value))
val s = vm.state.value
assertFalse(s.isEdit)
assertEquals(28_900L, s.form?.amount?.ore)
assertEquals("WOLT", s.form?.description)
assertEquals("2026-10-13", s.form?.date)
assertEquals(jacob, s.form?.payer)
assertEquals(SplitMode.DEFAULT, s.form?.mode)
// The card scenarios are Home's household: its Vardag is the account last used on the phone (AC-8.6).
assertEquals(HomeScenarios.vardag.id, s.form?.accountId)
assertNull(s.keypad)
vm.save()
advanceUntilIdle()
val linked = fakes.store.current.cards.purchases.first { it.id == purchase.id }.expenseId
assertTrue(linked != null && fakes.store.current.expenses.any { it.id == linked && it.description == "WOLT" })
}
@Test
fun fromAForeignCurrencyPurchase_theAmountIsEmpty_withTheKeypadOnIt() = runTest(dispatcher) {
val (vm, _) = editor(CardScenarios.loaded, mapOf(ExpenseEditorViewModel.ARG_CARD_PURCHASE_ID to CardScenarios.amazon.id.value))
assertNull(vm.state.value.form?.amount?.ore)
assertEquals("AMAZON.DE", vm.state.value.form?.description)
assertEquals(KeypadTarget.Amount, vm.state.value.keypad)
}
@Test
fun fromS27_theAccountItWasOpenedFromIsKept() = runTest(dispatcher) {
val args = mapOf(ExpenseEditorViewModel.ARG_CARD_PURCHASE_ID to CardScenarios.wolt.id.value, ExpenseEditorViewModel.ARG_ACCOUNT_ID to HomeScenarios.huset.id.value)
val (vm, _) = editor(CardScenarios.loaded, args)
assertEquals(HomeScenarios.huset.id, vm.state.value.form?.accountId)
}
}
@@ -6,7 +6,10 @@ 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.cards.CardScenarios
import se.hagstedt.kvitt.fakes.expense.ExpenseScenarios
import se.hagstedt.kvitt.fakes.home.HomeScenarios
import se.hagstedt.kvitt.model.CardPurchaseId
import se.hagstedt.kvitt.design.component.ProvideHousehold
import se.hagstedt.kvitt.feature.expense.DeleteExpenseDialog
import se.hagstedt.kvitt.feature.expense.EditorDialog
@@ -54,6 +57,7 @@ object NoActions : ExpenseEditorActions, ExpenseDetailActions {
override fun askDelete() = Unit
override fun dismissDelete() = Unit
override fun syncNow() = Unit
override fun unlinkCard() = Unit
}
private fun ExpenseEditorViewModel.type(text: String) = text.forEach { c ->
@@ -66,12 +70,17 @@ private fun editor(
scenario: FakeScenario = ExpenseScenarios.household,
edit: ExpenseId? = null,
largeFont: Boolean = false,
card: CardPurchaseId? = null,
steps: ExpenseEditorViewModel.(idle: () -> Unit) -> Unit = {},
) = Case(name, largeFont) {
val state = buildState { 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)
val args = when {
edit != null -> mapOf(ExpenseEditorViewModel.ARG_EXPENSE_ID to edit.value)
card != null -> mapOf(ExpenseEditorViewModel.ARG_CARD_PURCHASE_ID to card.value)
else -> emptyMap()
}
val vm = ExpenseEditorViewModel(SavedStateHandle(args), fakes.expenses, fakes.home, fakes.accounts, fakes.connectivity, fakes.cards)
idle()
vm.steps(idle)
idle()
@@ -100,7 +109,7 @@ private fun detail(
) = Case(name, largeFont) {
val state = buildState { 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)
val vm = ExpenseDetailViewModel(SavedStateHandle(mapOf(ExpenseDetailViewModel.ARG_EXPENSE_ID to id.value)), fakes.expenses, fakes.home, fakes.connectivity, fakes.sync, fakes.cards)
idle()
vm.steps()
vm.state.value
@@ -138,6 +147,9 @@ val editorCases = listOf(
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) },
// CP-9 (D-169): opened from a card purchase, prefilled; a foreign-currency purchase leaves the amount empty.
editor("s7_from_card", CardScenarios.loaded, card = CardScenarios.wolt.id),
editor("s7_from_card_foreign", CardScenarios.loaded, card = CardScenarios.amazon.id),
// 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) },
@@ -162,6 +174,8 @@ val detailCases = listOf(
detail("s8_missing_offline", ExpenseScenarios.missingId, ExpenseScenarios.offline),
detail("s8_offline", ExpenseScenarios.icaId, ExpenseScenarios.offline),
detail("s8_delete_confirm", ExpenseScenarios.trananId) { askDelete() },
// AC-22.10: the card holder sees "Från kortköp: <merchant>, <date>" with "Ta bort koppling".
detail("s8_from_card", HomeScenarios.willys.id, CardScenarios.loaded),
)
/** Long content on a tall frame so the whole screen shows. */
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB