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
@@ -22,7 +22,13 @@ import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.text.style.TextOverflow
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.datetime.toLocalDateTime
import se.hagstedt.kvitt.design.KvittTheme
import se.hagstedt.kvitt.design.component.LocalKvittClock
import se.hagstedt.kvitt.design.component.kvittLanguage
import se.hagstedt.kvitt.design.format.DateFormatter
import se.hagstedt.kvitt.model.CardConnectionStatus
import kotlin.time.Instant
import se.hagstedt.kvitt.design.component.AvatarPlaceholder
import se.hagstedt.kvitt.design.component.AvatarShape
import se.hagstedt.kvitt.design.component.KvittCard
@@ -56,10 +62,11 @@ fun SettingsHubScreen(
onOpenAbout: () -> Unit,
onNavigate: (KvittDestination) -> Unit,
modifier: Modifier = Modifier,
onOpenCards: () -> Unit = {},
viewModel: SettingsHubViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
SettingsHubContent(state, SettingsHubActions(onOpenProfile, onOpenCategories, onOpenRecurring, onOpenDevices, onOpenInvites, onOpenSyncStatus, onOpenAbout, onNavigate), modifier)
SettingsHubContent(state, SettingsHubActions(onOpenProfile, onOpenCategories, onOpenRecurring, onOpenDevices, onOpenInvites, onOpenSyncStatus, onOpenAbout, onNavigate, onOpenCards), modifier)
}
/** The navigation of S-13. */
@@ -72,6 +79,7 @@ class SettingsHubActions(
val onOpenSyncStatus: () -> Unit = {},
val onOpenAbout: () -> Unit = {},
val onNavigate: (KvittDestination) -> Unit = {},
val onOpenCards: () -> Unit = {},
)
/** Stateless S-13; [state] null while the session loads (nothing but the bars). */
@@ -93,14 +101,16 @@ fun SettingsHubContent(state: SettingsHubState?, actions: SettingsHubActions, mo
verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.sectionGap),
) {
HouseholdCard(state)
SegmentedGroup(3) { item ->
SegmentedGroup(4) { item ->
when (item.index) {
0 -> SettingsRow(item, KvittIcon.Person, ColorToken.ORANGE, stringResource(R.string.settings_profile), actions.onOpenProfile, subtitle = stringResource(R.string.settings_profile_summary))
1 -> SettingsRow(
item, KvittIcon.Category, ColorToken.GREEN, stringResource(R.string.settings_categories), actions.onOpenCategories,
subtitle = pluralStringResource(R.plurals.settings_categories_summary, state.activeCategories, state.activeCategories),
)
else -> SettingsRow(item, KvittIcon.EventRepeat, ColorToken.BROWN, stringResource(R.string.settings_recurring), actions.onOpenRecurring, subtitle = recurringSummary(state))
2 -> SettingsRow(item, KvittIcon.EventRepeat, ColorToken.BROWN, stringResource(R.string.settings_recurring), actions.onOpenRecurring, subtitle = recurringSummary(state))
// D-172: "Anslut kort" opens S-24.
else -> SettingsRow(item, KvittIcon.CreditCard, ColorToken.INDIGO, stringResource(R.string.settings_cards), actions.onOpenCards, subtitle = cardsSummary(state))
}
}
val rows = buildList {
@@ -128,6 +138,17 @@ fun SettingsHubContent(state: SettingsHubState?, actions: SettingsHubActions, mo
private enum class HubRow { Devices, InvitePartner, PairDevice, Sync, About }
/** "Ansluten · giltig till <date>" (list form), "Har upphört" for an ended consent, else "Läs in dina kortköp". */
@Composable
private fun cardsSummary(state: SettingsHubState): String {
val card = state.card ?: return stringResource(R.string.settings_cards_none)
if (card.status == CardConnectionStatus.EXPIRED) return stringResource(R.string.settings_cards_expired)
val until = card.validUntil ?: return stringResource(R.string.settings_cards_none)
val clock = LocalKvittClock.current
val date = DateFormatter.listDate(Instant.parse(until).toLocalDateTime(clock.zone).date, clock.today, kvittLanguage())
return stringResource(R.string.settings_cards_connected, date)
}
@Composable
private fun recurringSummary(state: SettingsHubState): String {
if (state.activeTemplates == 0 && state.pausedTemplates == 0) return stringResource(R.string.settings_recurring_summary_none)
@@ -8,6 +8,9 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import se.hagstedt.kvitt.model.AboutRepository
import se.hagstedt.kvitt.model.CardConnection
import se.hagstedt.kvitt.model.CardFilter
import se.hagstedt.kvitt.model.CardRepository
import se.hagstedt.kvitt.model.Category
import se.hagstedt.kvitt.model.CategoryRepository
import se.hagstedt.kvitt.model.ConnectivityRepository
@@ -26,7 +29,8 @@ import javax.inject.Inject
/**
* S-13 (design-spec §4 S-13): the household card, row subtitles and which rows the viewer gets. [partner] null: the
* partner has not joined (AC-5.1). The invite row is the admin's only (AC-2.1, AC-2.2, §8.4).
* partner has not joined (AC-5.1). The invite row is the admin's only (AC-2.1, AC-2.2, §8.4). [card]: the viewer's card
* connection (ACTIVE or EXPIRED) for the "Anslut kort" subtitle (D-172), null without one.
*/
data class SettingsHubState(
val me: User,
@@ -38,6 +42,7 @@ data class SettingsHubState(
val online: Boolean,
val versionName: String,
val updateAvailable: Boolean,
val card: CardConnection? = null,
) {
val isAdmin: Boolean get() = me.role == Role.ADMIN
@@ -61,6 +66,7 @@ fun settingsHubState(
online: Boolean,
versionName: String,
update: UpdateState,
card: CardConnection? = null,
): SettingsHubState? {
val paired = session as? SessionState.Paired ?: return null
val me = household.firstOrNull { it.id == paired.me.id } ?: paired.me
@@ -75,6 +81,7 @@ fun settingsHubState(
online = online,
versionName = versionName,
updateAvailable = update !is UpdateState.None,
card = card,
)
}
@@ -88,13 +95,14 @@ class SettingsHubViewModel @Inject constructor(
about: AboutRepository,
update: UpdateRepository,
connectivity: ConnectivityRepository,
cards: CardRepository,
) : ViewModel() {
private val people = combine(session.session, profile.household()) { s, h -> s to h }
private val lists = combine(categories.all(), recurring.all()) { c, t -> c to t }
private val status = combine(sync.status(), connectivity.online, about.about(), update.state) { s, o, a, u -> Status(s.summary, o, a.versionName, u) }
val state: StateFlow<SettingsHubState?> = combine(people, lists, status) { (s, h), (c, t), st ->
settingsHubState(s, h, c, t, st.sync, st.online, st.versionName, st.update)
val state: StateFlow<SettingsHubState?> = combine(people, lists, status, cards.cards(CardFilter.ALL)) { (s, h), (c, t), st, card ->
settingsHubState(s, h, c, t, st.sync, st.online, st.versionName, st.update, card.connection)
}.stateIn(viewModelScope, SharingStarted.Eagerly, null)
private data class Status(val sync: SyncSummary, val online: Boolean, val versionName: String, val update: UpdateState)
@@ -180,4 +180,12 @@
<string name="settings_expanded">utfälld</string>
<string name="settings_collapsed">hopfälld</string>
<string name="settings_partner_missing">Partner saknas</string>
<!-- S-13 row (D-172), opens S-24 -->
<string name="settings_cards">Anslut kort</string>
<!-- S-13 row subtitle; %1$s the consent\'s end date (§9.8 list form) -->
<string name="settings_cards_connected">Ansluten · giltig till %1$s</string>
<!-- S-13 row subtitle without a connection -->
<string name="settings_cards_none">Läs in dina kortköp</string>
<!-- S-13 row subtitle of an expired connection (CP-6); K2 addition -->
<string name="settings_cards_expired">Har upphört</string>
</resources>
@@ -180,4 +180,12 @@
<string name="settings_expanded">expanded</string>
<string name="settings_collapsed">collapsed</string>
<string name="settings_partner_missing">Partner has not joined</string>
<!-- S-13 row (D-172), opens S-24 -->
<string name="settings_cards">Connect card</string>
<!-- S-13 row subtitle; %1$s the consent\'s end date (§9.8 list form) -->
<string name="settings_cards_connected">Connected · valid until %1$s</string>
<!-- S-13 row subtitle without a connection -->
<string name="settings_cards_none">Read your card purchases</string>
<!-- S-13 row subtitle of an expired connection (CP-6); K2 addition -->
<string name="settings_cards_expired">Ended</string>
</resources>
@@ -37,7 +37,7 @@ class StateHolderTest {
val main = MainDispatcherRule()
private fun hub(r: ScriptedSettingsRepositories) =
SettingsHubViewModel(r.fakes.session, r.profile, r.categories, r.recurring, r.fakes.sync, r.fakes.about, r.update, r.fakes.connectivity)
SettingsHubViewModel(r.fakes.session, r.profile, r.categories, r.recurring, r.fakes.sync, r.fakes.about, r.update, r.fakes.connectivity, r.fakes.cards)
@Test
fun hubCountsAndRows() {
@@ -9,7 +9,9 @@ import org.junit.runner.RunWith
import org.robolectric.ParameterizedRobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
import se.hagstedt.kvitt.fakes.FakeCards
import se.hagstedt.kvitt.fakes.FakeScenario
import se.hagstedt.kvitt.fakes.cards.CardScenarios
import se.hagstedt.kvitt.fakes.settings.ScriptedSettingsRepositories
import se.hagstedt.kvitt.fakes.settings.SettingsScenarios
import se.hagstedt.kvitt.feature.settings.hub.SettingsHubActions
@@ -29,7 +31,7 @@ class HubScreenshotTest(private val state: String) {
companion object {
fun viewModel(r: ScriptedSettingsRepositories) =
SettingsHubViewModel(r.fakes.session, r.profile, r.categories, r.recurring, r.fakes.sync, r.fakes.about, r.update, r.fakes.connectivity)
SettingsHubViewModel(r.fakes.session, r.profile, r.categories, r.recurring, r.fakes.sync, r.fakes.about, r.update, r.fakes.connectivity, r.fakes.cards)
private fun shot(name: String, scenario: FakeScenario) = Shot(name) { compose ->
val vm = viewModel(ScriptedSettingsRepositories(scenario))
@@ -46,6 +48,8 @@ class HubScreenshotTest(private val state: String) {
shot("offline", SettingsScenarios.offline),
shot("update_available", SettingsScenarios.updateAvailable),
shot("long", SettingsScenarios.long),
// D-172: "Anslut kort" with a connection: "Ansluten · giltig till 22 apr. 2027".
shot("card_connected", SettingsScenarios.loaded.copy(cards = FakeCards(connection = CardScenarios.connection))),
)
@JvmStatic
Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 37 KiB