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
@@ -71,6 +71,7 @@ data class AccountDetailActions(
val onRetry: (AccountAction) -> Unit = {},
val onMessageShown: () -> Unit = {},
val onRefresh: () -> Unit = {},
val onFromCardPurchases: (AccountId) -> Unit = {},
)
/**
@@ -90,6 +91,7 @@ fun AccountDetailScreen(
onArchived: (AccountId, String) -> Unit,
onDeleted: () -> Unit,
modifier: Modifier = Modifier,
onFromCardPurchases: (AccountId) -> Unit = {},
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
viewModel: AccountDetailViewModel = hiltViewModel(),
) {
@@ -123,6 +125,7 @@ fun AccountDetailScreen(
onRetry = viewModel::retry,
onMessageShown = viewModel::messageShown,
onRefresh = viewModel::refresh,
onFromCardPurchases = onFromCardPurchases,
)
AccountDetailContent(ui, actions, modifier, snackbarHostState = snackbarHostState)
}
@@ -242,7 +245,16 @@ private fun OverflowMenu(ui: AccountDetailUi.Ready, actions: AccountDetailAction
properties = PopupProperties(focusable = true),
) {
KvittMenuContent(
listOf(
listOfNotNull(
// AC-22.9: "Från kortköp" while the viewer has a card connection; local, so offline too (CP-9).
if (ui.hasCardConnection && !archived) {
KvittMenuItem(KvittIcon.CreditCard, stringResource(R.string.account_from_card_purchases)) {
actions.onMenu(false)
actions.onFromCardPurchases(ui.detail.account.id)
}
} else {
null
},
KvittMenuItem(KvittIcon.Edit, stringResource(R.string.account_edit), ui.canEdit, offlineReason) {
actions.onMenu(false)
actions.onEdit(ui.detail.account.id)
@@ -16,6 +16,7 @@ import se.hagstedt.kvitt.design.format.FAILURE_NETWORK
import se.hagstedt.kvitt.model.AccountDetailState
import se.hagstedt.kvitt.model.AccountId
import se.hagstedt.kvitt.model.AccountRepository
import se.hagstedt.kvitt.model.CardRepository
import se.hagstedt.kvitt.model.ConnectivityRepository
import se.hagstedt.kvitt.model.OnlineResult
import se.hagstedt.kvitt.model.SyncRepository
@@ -59,7 +60,10 @@ sealed interface AccountEvent {
sealed interface AccountDetailUi {
data object Starting : AccountDetailUi
/** [refreshing]: a pull-to-refresh sync is running; the indicator stays until it ends (D-127). */
/**
* [refreshing]: a pull-to-refresh sync is running; the indicator stays until it ends (D-127). [hasCardConnection]:
* the viewer has a card connection, so the menu offers "Från kortköp" (S-27, AC-22.9).
*/
data class Ready(
val detail: AccountDetailState,
val online: Boolean,
@@ -70,6 +74,7 @@ sealed interface AccountDetailUi {
val message: AccountMessage? = null,
val event: AccountEvent? = null,
val refreshing: Boolean = false,
val hasCardConnection: Boolean = false,
) : AccountDetailUi {
val partner: User get() = detail.balance.partner
val me: User get() = detail.users.firstOrNull { it.id != partner.id } ?: partner
@@ -107,11 +112,12 @@ class AccountDetailViewModel @Inject constructor(
private val accounts: AccountRepository,
private val connectivity: ConnectivityRepository,
private val sync: SyncRepository,
cards: CardRepository,
) : ViewModel() {
val id = AccountId(checkNotNull(savedState.get<String>(ARG_ACCOUNT_ID)) { "missing $ARG_ACCOUNT_ID" })
private val local = MutableStateFlow(AccountLocal())
val ui: StateFlow<AccountDetailUi> = combine(accounts.detail(id), accounts.all(), connectivity.online, local) { detail, all, online, l ->
val ui: StateFlow<AccountDetailUi> = combine(accounts.detail(id), accounts.all(), connectivity.online, local, cards.hasConnection) { detail, all, online, l, card ->
if (detail == null) {
AccountDetailUi.Gone(l.event ?: AccountEvent.Gone)
} else {
@@ -125,6 +131,7 @@ class AccountDetailViewModel @Inject constructor(
message = l.message,
event = l.event,
refreshing = l.refreshing,
hasCardConnection = card,
)
}
}.stateIn(viewModelScope, SharingStarted.Eagerly, AccountDetailUi.Starting)
@@ -31,4 +31,6 @@
<string name="account_editor_offline_body_edit">Konton sparas direkt på servern. Anslut telefonen till internet för att spara ändringen.</string>
<!-- name_taken -->
<string name="account_editor_name_taken">Det finns redan ett konto som heter %1$s</string>
<!-- S-5 overflow item while the viewer has a connection (AC-22.9), opens S-27 -->
<string name="account_from_card_purchases">Från kortköp</string>
</resources>
@@ -31,4 +31,6 @@
<string name="account_editor_offline_body_edit">Accounts are saved straight to the server. Connect the phone to the internet to save the change.</string>
<!-- name_taken -->
<string name="account_editor_name_taken">There is already an account called %1$s</string>
<!-- S-5 overflow item while the viewer has a connection (AC-22.9), opens S-27 -->
<string name="account_from_card_purchases">From card purchases</string>
</resources>
@@ -9,7 +9,9 @@ import org.robolectric.ParameterizedRobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
import se.hagstedt.kvitt.design.format.FAILURE_NETWORK
import se.hagstedt.kvitt.fakes.FakeCards
import se.hagstedt.kvitt.fakes.FakeScenario
import se.hagstedt.kvitt.fakes.cards.CardScenarios
import se.hagstedt.kvitt.fakes.account.AccountScenarios
import se.hagstedt.kvitt.model.AccountId
import se.hagstedt.kvitt.design.testing.ScreenSnapshots
@@ -25,6 +27,8 @@ val accountStates: List<ScreenState> = listOf(
detail("S-5-account-negative", AccountScenarios.loaded, AccountScenarios.huset),
detail("S-5-account-archived", AccountScenarios.loaded, AccountScenarios.gotland),
detail("S-5-account-menu", AccountScenarios.loaded, AccountScenarios.vardag) { it.setMenuOpen(true) },
// AC-22.9: with a card connection the menu starts with "Från kortköp".
detail("S-5-account-menu-card", AccountScenarios.loaded.copy(cards = FakeCards(connection = CardScenarios.connection)), AccountScenarios.vardag) { it.setMenuOpen(true) },
detail("S-5-account-menu-deletable", AccountScenarios.empty, AccountScenarios.sommarstugan.id) { it.setMenuOpen(true) },
detail("S-5-account-delete-confirm", AccountScenarios.empty, AccountScenarios.sommarstugan.id) { it.askDelete() },
detail("S-5-account-empty", AccountScenarios.empty, AccountScenarios.sommarstugan.id),
@@ -85,7 +89,7 @@ class AccountScreenshotTest(private val state: String) {
@JvmStatic
@ParameterizedRobolectricTestRunner.Parameters(name = "{0}")
fun states(): List<Array<Any>> = listOf(
"S-5-account", "S-5-account-negative", "S-5-account-archived", "S-5-account-menu", "S-5-account-menu-deletable",
"S-5-account", "S-5-account-negative", "S-5-account-archived", "S-5-account-menu", "S-5-account-menu-card", "S-5-account-menu-deletable",
"S-5-account-delete-confirm", "S-5-account-empty", "S-5-account-offline", "S-5-account-error", "S-5-account-long",
"S-5-account-refreshing",
"S-6-account-editor-create", "S-6-account-editor", "S-6-account-editor-edit", "S-6-account-editor-decimals",
@@ -23,7 +23,7 @@ fun args(id: AccountId?): SavedStateHandle = SavedStateHandle(if (id == null) em
fun detailState(scenario: FakeScenario, id: AccountId, failWith: String? = null, act: (AccountDetailViewModel) -> Unit = {}): AccountDetailUi = withMain {
val fakes = FakeRepositories(scenario)
val accounts = if (failWith == null) fakes.accounts else FailingAccounts(fakes.accounts, failWith)
val vm = AccountDetailViewModel(args(id), accounts, fakes.connectivity, fakes.sync)
val vm = AccountDetailViewModel(args(id), accounts, fakes.connectivity, fakes.sync, fakes.cards)
act(vm)
vm.ui.value
}
@@ -6,7 +6,9 @@ import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import se.hagstedt.kvitt.design.format.FAILURE_NETWORK
import se.hagstedt.kvitt.fakes.FakeCards
import se.hagstedt.kvitt.fakes.FakeRepositories
import se.hagstedt.kvitt.fakes.cards.CardScenarios
import se.hagstedt.kvitt.fakes.FakeScenarios
import se.hagstedt.kvitt.fakes.account.AccountScenarios
import se.hagstedt.kvitt.model.AccountId
@@ -105,11 +107,18 @@ class AccountDetailViewModelTest {
assertEquals(AccountMessage.Failed("forbidden"), ui.message)
}
@Test
fun fromCardPurchasesIsOfferedOnlyWithACardConnection() {
assertEquals(false, ready(detailState(AccountScenarios.loaded, AccountScenarios.vardag)).hasCardConnection)
val connected = AccountScenarios.loaded.copy(cards = FakeCards(connection = CardScenarios.connection))
assertEquals(true, ready(detailState(connected, AccountScenarios.vardag)).hasCardConnection)
}
/** S-5 for Vardag in [scenario] whose sync waits for the test (pull-to-refresh, D-127); runs [block] with Main set. */
private fun withGatedSync(scenario: FakeScenario, block: (AccountDetailViewModel, GatedSyncRepository) -> Unit) = withMain {
val fakes = FakeRepositories(scenario)
val sync = GatedSyncRepository(fakes.sync)
block(AccountDetailViewModel(args(AccountScenarios.vardag), fakes.accounts, fakes.connectivity, sync), sync)
block(AccountDetailViewModel(args(AccountScenarios.vardag), fakes.accounts, fakes.connectivity, sync, fakes.cards), sync)
}
private fun AccountDetailUi.Ready.pendingRows(): Int = detail.months.flatMap { it.rows }.count {
Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB