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
@@ -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. */
|
||||
|
||||
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 26 KiB |