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
+1
View File
@@ -144,6 +144,7 @@ dependencies {
implementation(project(":feature:settle"))
implementation(project(":feature:analytics"))
implementation(project(":feature:settings"))
implementation(project(":feature:cards"))
testImplementation(project(":core:fakes"))
testImplementation(testFixtures(project(":core:design")))
+8 -1
View File
@@ -18,7 +18,7 @@
android:roundIcon="@mipmap/ic_launcher"
android:supportsRtl="true"
android:theme="@android:style/Theme.Material.NoActionBar">
<!-- The only exported activity (§8.8): launcher, the invite links of AC-2.6 and notification taps. -->
<!-- The only exported activity (§8.8): launcher, the invite links of AC-2.6, the card return link and notification taps. -->
<activity
android:name=".MainActivity"
android:exported="true"
@@ -34,6 +34,13 @@
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="kvitt" android:host="invite" />
</intent-filter>
<!-- CP-2 (D-173): the card callback page links back with kvitt://cards/connected?attempt=<id>. -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="kvitt" android:host="cards" android:path="/connected" />
</intent-filter>
<!-- Unverified link: the user may get a chooser (AC-2.6). The host is the Gradle property kvitt.publicHost
(BuildConfig.PUBLIC_HOST, D-121); no port, since Kvitt is served on 443 (D-124). A filter without a
port matches every port of the host. -->
@@ -14,6 +14,7 @@ import se.hagstedt.kvitt.link.AppLink
import se.hagstedt.kvitt.link.OpenTarget
import se.hagstedt.kvitt.model.AccountId
import se.hagstedt.kvitt.model.AccountRepository
import se.hagstedt.kvitt.model.CardRepository
import se.hagstedt.kvitt.model.SessionRepository
import se.hagstedt.kvitt.model.SessionState
import se.hagstedt.kvitt.model.SyncRepository
@@ -25,6 +26,7 @@ class AppViewModel @Inject constructor(
session: SessionRepository,
private val sync: SyncRepository,
private val accounts: AccountRepository,
private val cards: CardRepository,
) : ViewModel() {
/** Null until the first emission (the shell shows the empty themed screen meanwhile). */
val session: StateFlow<SessionState?> = session.session.stateIn(viewModelScope, SharingStarted.Eagerly, null)
@@ -39,6 +41,11 @@ class AppViewModel @Inject constructor(
/** A notification tap waiting for the paired graph (AC-13.6). */
val open: StateFlow<OpenTarget?> = openState.asStateFlow()
private val cardReturnState = MutableStateFlow<String?>(null)
/** The attempt of the last card return link (CP-2), until S-25 or S-23 has taken it. */
val cardReturn: StateFlow<String?> = cardReturnState.asStateFlow()
private val askNotificationsState = MutableStateFlow(false)
/** AC-2.9: true once the full sync after a pairing has finished; the activity then asks for POST_NOTIFICATIONS. */
@@ -52,6 +59,7 @@ class AppViewModel @Inject constructor(
when (link) {
is AppLink.Invite -> inviteState.value = if (session.value is SessionState.Paired) null else link
is AppLink.Open -> openState.value = link.target
is AppLink.CardConnected -> cardReturnState.value = link.attemptId
null -> Unit
}
}
@@ -60,6 +68,19 @@ class AppViewModel @Inject constructor(
openState.value = null
}
fun cardReturnHandled() {
cardReturnState.value = null
}
/**
* A card return link while S-25 is not open (CP-2): ask the attempt once, which writes the connection and follows the
* first fetch; `:app` then shows S-23.
*/
fun checkCardAttempt(attemptId: String) {
cardReturnState.value = null
viewModelScope.launch { cards.attempt(attemptId) }
}
/** S-1 succeeded: wait for the first successful sync (AC-2.9), then ask for the notification permission. */
fun onPaired() {
inviteState.value = null
@@ -2,6 +2,7 @@ package se.hagstedt.kvitt.link
import android.content.Context
import android.content.Intent
import android.net.Uri
import se.hagstedt.kvitt.MainActivity
import se.hagstedt.kvitt.feature.onboarding.InviteLink
@@ -22,10 +23,17 @@ sealed interface AppLink {
/** A notification tap. */
data class Open(val target: OpenTarget) : AppLink
/**
* The card callback page's link back to the app, `kvitt://cards/connected?attempt=<id>` (CP-2, D-173): S-25 polls
* that attempt at once, or S-23 opens and asks it when S-25 is not open.
*/
data class CardConnected(val attemptId: String) : AppLink
}
/**
* Parses the intents `MainActivity` receives: `kvitt://invite?server=…&code=…[&from=…]` and
* Parses the intents `MainActivity` receives: the card return link `kvitt://cards/connected?attempt=<id>` (CP-2),
* `kvitt://invite?server=…&code=…[&from=…]` and
* `https://<BuildConfig.PUBLIC_HOST>/i/<CODE>[?from=…]` (AC-2.6, D-124; the manifest's intent filter takes its host from
* the `publicHost` placeholder, D-121), both through `:feature:onboarding`'s [InviteLink], the reader S-1 uses for pasted
* links, which takes the server from the link itself, and the notification taps this app builds ([openIntent]).
@@ -37,11 +45,14 @@ object AppLinks {
private const val KIND_HOME = "home"
private const val KIND_EXPENSE = "expense"
private const val KIND_SETTLEMENT = "settlement"
private const val CARD_RETURN = "kvitt://cards/connected"
private const val ATTEMPT_MAX = 64
private const val ASCII = 128
fun parse(intent: Intent?): AppLink? {
if (intent == null) return null
return when (intent.action) {
Intent.ACTION_VIEW -> parseUri(intent.dataString)
Intent.ACTION_VIEW -> parseCardReturn(intent.dataString) ?: parseUri(intent.dataString)
ACTION_OPEN -> parseOpen(intent.getStringExtra(EXTRA_KIND), intent.getStringExtra(EXTRA_ID))
else -> null
}
@@ -53,6 +64,14 @@ object AppLinks {
return text?.let(InviteLink::parse)?.let { AppLink.Invite(it.serverUrl, it.code, it.adminName) }
}
/** `kvitt://cards/connected?attempt=<id>`; null for anything else or an attempt id that is not an id (at most 64 of `[0-9A-Za-z-]`). */
fun parseCardReturn(uri: String?): AppLink.CardConnected? {
val parsed = uri?.trim()?.takeIf { it.startsWith(CARD_RETURN, ignoreCase = true) }?.let(Uri::parse) ?: return null
if (!parsed.scheme.equals("kvitt", ignoreCase = true) || !parsed.host.equals("cards", ignoreCase = true) || parsed.path != "/connected") return null
val attempt = parsed.getQueryParameter("attempt")?.takeIf { it.length in 1..ATTEMPT_MAX && it.all { c -> (c.isLetterOrDigit() && c.code < ASCII) || c == '-' } } ?: return null
return AppLink.CardConnected(attempt)
}
fun parseOpen(kind: String?, id: String?): AppLink.Open? = when (kind) {
KIND_HOME -> AppLink.Open(OpenTarget.Home)
KIND_EXPENSE -> id?.takeIf { it.isNotEmpty() }?.let { AppLink.Open(OpenTarget.Expense(it)) }
@@ -44,6 +44,11 @@ import se.hagstedt.kvitt.feature.account.AccountDetailViewModel
import se.hagstedt.kvitt.feature.account.AccountEditorScreen
import se.hagstedt.kvitt.feature.analytics.AnalyticsScreen
import se.hagstedt.kvitt.feature.analytics.DrillDownScreen
import se.hagstedt.kvitt.feature.cards.CardConnectScreen
import se.hagstedt.kvitt.feature.cards.CardConnectionScreen
import se.hagstedt.kvitt.feature.cards.CardPickScreen
import se.hagstedt.kvitt.feature.cards.CardsScreen
import se.hagstedt.kvitt.feature.cards.ConnectCardScreen
import se.hagstedt.kvitt.feature.expense.ExpenseDetailScreen
import se.hagstedt.kvitt.feature.expense.ExpenseEditorScreen
import se.hagstedt.kvitt.feature.home.HomeScreen
@@ -147,6 +152,16 @@ private fun AppGraph(viewModel: AppViewModel, session: SessionState, updateCheck
}
}
// CP-2: the card callback page's return link. S-25 takes it itself (it polls that attempt at once); otherwise the
// attempt is asked once here and S-23 opens.
val cardReturn by viewModel.cardReturn.collectAsStateWithLifecycle()
LaunchedEffect(cardReturn, session is SessionState.Paired, route) {
val attempt = cardReturn ?: return@LaunchedEffect
if (session !is SessionState.Paired || route == null || route in Routes.ONBOARDING || route == Routes.CARD_CONNECT) return@LaunchedEffect
viewModel.checkCardAttempt(attempt)
shell.top(KvittDestination.Cards)
}
// Result snackbars are shown in the scaffold of the screen returned to (Home, S-5, S-8, S-10, S-12, S-16), which
// each host [snackbar] (D-071).
// D-108: every destination change is instant (NavTransitions).
@@ -163,6 +178,7 @@ private fun AppGraph(viewModel: AppViewModel, session: SessionState, updateCheck
topLevel(session, shell)
accountsAndExpenses(viewModel, shell)
settings(shell, updateCheckAvailable)
cards(viewModel, shell)
}
}
@@ -183,6 +199,7 @@ private class Shell(val nav: NavHostController, val snackbar: SnackbarHostState,
fun top(destination: KvittDestination) {
val route = when (destination) {
KvittDestination.Home -> Routes.HOME
KvittDestination.Cards -> Routes.CARDS
KvittDestination.Analytics -> Routes.ANALYTICS
KvittDestination.Settings -> Routes.SETTINGS
}
@@ -249,6 +266,17 @@ private fun NavGraphBuilder.topLevel(session: SessionState, shell: Shell) {
)
}
}
composable(Routes.CARDS) {
CardsScreen(
onNavigate = shell::top,
onConnect = { shell.nav.navigate(Routes.CONNECT_CARD) },
onOpenConnection = { shell.nav.navigate(Routes.cardConnection(it)) },
onRenew = { shell.nav.navigate(Routes.cardConnect(it, renew = true)) },
onCreateExpense = { shell.nav.navigate(Routes.expenseEditor(cardPurchase = it)) },
onOpenExpense = { shell.nav.navigate(Routes.expense(it)) },
snackbarHostState = shell.snackbar,
)
}
composable(Routes.ANALYTICS) {
AnalyticsScreen(onOpenDrillDown = { shell.nav.navigate(Routes.drillDown(it)) }, onNavigate = shell::top)
}
@@ -265,6 +293,7 @@ private fun NavGraphBuilder.topLevel(session: SessionState, shell: Shell) {
onOpenSyncStatus = { shell.nav.navigate(Routes.SYNC_STATUS) },
onOpenAbout = { shell.nav.navigate(Routes.ABOUT) },
onNavigate = shell::top,
onOpenCards = { shell.nav.navigate(Routes.CONNECT_CARD) },
)
}
composable(Routes.SYNC_STATUS) {
@@ -293,6 +322,7 @@ private fun NavGraphBuilder.accountsAndExpenses(viewModel: AppViewModel, shell:
},
onDeleted = { shell.nav.popBackStack(Routes.HOME, inclusive = false) },
snackbarHostState = shell.snackbar,
onFromCardPurchases = { shell.nav.navigate(Routes.cardPick(it)) },
)
}
composable(Routes.ACCOUNT_EDITOR, arguments = Routes.accountEditorArgs) { entry ->
@@ -393,5 +423,39 @@ private fun NavGraphBuilder.settings(shell: Shell, updateCheckAvailable: Boolean
}
}
/** Card purchases (D-173): S-24 from S-13, S-25 from S-24 or "Förnya med BankID", S-26, S-27 from S-5. */
private fun NavGraphBuilder.cards(viewModel: AppViewModel, shell: Shell) {
composable(Routes.CONNECT_CARD) {
ConnectCardScreen(
onBack = shell::back,
onOpenProvider = { shell.nav.navigate(Routes.cardConnect(it)) },
onOpenConnection = { shell.nav.navigate(Routes.cardConnection(it)) },
)
}
composable(Routes.CARD_CONNECT, arguments = Routes.cardConnectArgs) {
val returned by viewModel.cardReturn.collectAsStateWithLifecycle()
CardConnectScreen(
onBack = shell::back,
onConnected = { shell.top(KvittDestination.Cards) },
returnedAttempt = returned,
)
LaunchedEffect(returned) { if (returned != null) viewModel.cardReturnHandled() }
}
composable(Routes.CARD_CONNECTION, arguments = Routes.cardConnectionArgs) {
CardConnectionScreen(
onBack = shell::back,
onRenew = { shell.nav.navigate(Routes.cardConnect(it, renew = true)) },
onDisconnected = shell::back,
)
}
composable(Routes.CARD_PICK, arguments = Routes.cardPickArgs) {
CardPickScreen(
onBack = shell::back,
onPick = { purchase, account -> shell.nav.navigate(Routes.expenseEditor(account = account, cardPurchase = purchase)) },
snackbarHostState = shell.snackbar,
)
}
}
private const val ARCHIVE_NAME = "kvitt.categoryArchive.name"
private const val ARCHIVE_ARCHIVED = "kvitt.categoryArchive.archived"
@@ -6,12 +6,17 @@ import androidx.navigation.NavType
import androidx.navigation.navArgument
import se.hagstedt.kvitt.feature.account.AccountDetailViewModel
import se.hagstedt.kvitt.feature.analytics.DrillDownArgs
import se.hagstedt.kvitt.feature.cards.CardConnectViewModel
import se.hagstedt.kvitt.feature.cards.CardConnectionViewModel
import se.hagstedt.kvitt.feature.cards.CardPickViewModel
import se.hagstedt.kvitt.feature.expense.ExpenseDetailViewModel
import se.hagstedt.kvitt.feature.expense.ExpenseEditorViewModel
import se.hagstedt.kvitt.feature.settings.categories.CategoryEditorViewModel
import se.hagstedt.kvitt.feature.settings.recurring.RecurringEditorViewModel
import se.hagstedt.kvitt.feature.settle.SettleArgs
import se.hagstedt.kvitt.model.AccountId
import se.hagstedt.kvitt.model.CardConnectionId
import se.hagstedt.kvitt.model.CardPurchaseId
import se.hagstedt.kvitt.model.CategoryId
import se.hagstedt.kvitt.model.ExpenseId
import se.hagstedt.kvitt.model.SettlementId
@@ -27,16 +32,18 @@ object Routes {
const val REMOVED = "removed"
const val HOME = "home"
const val CARDS = "cards"
const val ANALYTICS = "analytics"
const val SETTINGS = "settings"
private val ACCOUNT_ID = AccountDetailViewModel.ARG_ACCOUNT_ID
private val EXPENSE_ID = ExpenseEditorViewModel.ARG_EXPENSE_ID
private val EXPENSE_ACCOUNT_ID = ExpenseEditorViewModel.ARG_ACCOUNT_ID
private val EXPENSE_CARD_PURCHASE = ExpenseEditorViewModel.ARG_CARD_PURCHASE_ID
val ACCOUNT = "account/{$ACCOUNT_ID}"
val ACCOUNT_EDITOR = "account_editor?$ACCOUNT_ID={$ACCOUNT_ID}"
val EXPENSE_EDITOR = "expense_editor?$EXPENSE_ID={$EXPENSE_ID}&$EXPENSE_ACCOUNT_ID={$EXPENSE_ACCOUNT_ID}"
val EXPENSE_EDITOR = "expense_editor?$EXPENSE_ID={$EXPENSE_ID}&$EXPENSE_ACCOUNT_ID={$EXPENSE_ACCOUNT_ID}&$EXPENSE_CARD_PURCHASE={$EXPENSE_CARD_PURCHASE}"
val EXPENSE = "expense/{${ExpenseDetailViewModel.ARG_EXPENSE_ID}}"
val SETTLE = "settle?${SettleArgs.ACCOUNT_ID}={${SettleArgs.ACCOUNT_ID}}&${SettleArgs.SETTLEMENT_ID}={${SettleArgs.SETTLEMENT_ID}}"
val SETTLEMENT = "settlement/{${SettleArgs.SETTLEMENT_ID}}"
@@ -52,13 +59,27 @@ object Routes {
const val INVITES = "settings/invites"
const val ABOUT = "settings/about"
// Card purchases (S-24 to S-27, D-173).
const val CONNECT_CARD = "cards/connect"
val CARD_CONNECT = "cards/provider/{${CardConnectViewModel.ARG_PROVIDER_ID}}?${CardConnectViewModel.ARG_RENEW}={${CardConnectViewModel.ARG_RENEW}}" +
"&${CardConnectViewModel.ARG_ATTEMPT}={${CardConnectViewModel.ARG_ATTEMPT}}"
val CARD_CONNECTION = "cards/connection/{${CardConnectionViewModel.ARG_CONNECTION_ID}}"
val CARD_PICK = "cards/pick/{${CardPickViewModel.ARG_ACCOUNT_ID}}"
/** Routes outside the paired app: the session gate (`KvittApp`) moves between them. */
val ONBOARDING = setOf(PAIRING, PROFILE_STEP, REMOVED)
fun account(id: AccountId) = "account/${encode(id.value)}"
fun accountEditor(id: AccountId?) = query("account_editor", ACCOUNT_ID to id?.value)
fun expenseEditor(id: ExpenseId? = null, account: AccountId? = null) =
query("expense_editor", EXPENSE_ID to id?.value, EXPENSE_ACCOUNT_ID to account?.value)
fun expenseEditor(id: ExpenseId? = null, account: AccountId? = null, cardPurchase: CardPurchaseId? = null) =
query("expense_editor", EXPENSE_ID to id?.value, EXPENSE_ACCOUNT_ID to account?.value, EXPENSE_CARD_PURCHASE to cardPurchase?.value)
fun cardConnect(providerId: String, renew: Boolean = false, attempt: String? = null) = query(
"cards/provider/${encode(providerId)}",
CardConnectViewModel.ARG_RENEW to if (renew) "true" else null,
CardConnectViewModel.ARG_ATTEMPT to attempt,
)
fun cardConnection(id: CardConnectionId) = "cards/connection/${encode(id.value)}"
fun cardPick(account: AccountId) = "cards/pick/${encode(account.value)}"
fun expense(id: ExpenseId) = "expense/${encode(id.value)}"
fun settle(account: AccountId? = null, editing: SettlementId? = null) =
query("settle", SettleArgs.ACCOUNT_ID to account?.value, SettleArgs.SETTLEMENT_ID to editing?.value)
@@ -80,7 +101,10 @@ object Routes {
val accountArgs get() = required(ACCOUNT_ID)
val accountEditorArgs get() = optional(ACCOUNT_ID)
val expenseEditorArgs get() = optional(EXPENSE_ID, EXPENSE_ACCOUNT_ID)
val expenseEditorArgs get() = optional(EXPENSE_ID, EXPENSE_ACCOUNT_ID, EXPENSE_CARD_PURCHASE)
val cardConnectArgs get() = required(CardConnectViewModel.ARG_PROVIDER_ID) + optional(CardConnectViewModel.ARG_RENEW, CardConnectViewModel.ARG_ATTEMPT)
val cardConnectionArgs get() = required(CardConnectionViewModel.ARG_CONNECTION_ID)
val cardPickArgs get() = required(CardPickViewModel.ARG_ACCOUNT_ID)
val expenseArgs get() = required(ExpenseDetailViewModel.ARG_EXPENSE_ID)
val settleArgs get() = optional(SettleArgs.ACCOUNT_ID, SettleArgs.SETTLEMENT_ID)
val settlementArgs get() = required(SettleArgs.SETTLEMENT_ID)
@@ -21,7 +21,7 @@ class AppViewModelTest {
@get:Rule
val main = MainDispatcherRule()
private fun vm(fakes: FakeRepositories) = AppViewModel(fakes.session, fakes.sync, fakes.accounts)
private fun vm(fakes: FakeRepositories) = AppViewModel(fakes.session, fakes.sync, fakes.accounts, fakes.cards)
private val link = AppLink.Invite(RELEASE_SERVER_URL, "ABCD2345EFGH", "Jacob")
@@ -117,4 +117,20 @@ class AppLinksTest {
assertNull(AppLinks.parseOpen("settlement", ""))
assertNull(AppLinks.parseOpen("unknown", "x"))
}
/** CP-2 (D-173): the card callback page's return link; only an id-shaped attempt is accepted. */
@Test
fun theCardReturnLinkNamesItsAttempt_andTheManifestOpensIt() {
assertEquals(AppLink.CardConnected("0192c300-0000-7000-8000-000000000001"), AppLinks.parseCardReturn("kvitt://cards/connected?attempt=0192c300-0000-7000-8000-000000000001"))
assertNull(AppLinks.parseCardReturn("kvitt://cards/connected"))
assertNull(AppLinks.parseCardReturn("kvitt://cards/other?attempt=abc"))
assertNull(AppLinks.parseCardReturn("kvitt://cards/connected?attempt=a%20b"))
assertNull(AppLinks.parseCardReturn("https://kvitt.example.org/cards/connected?attempt=abc"))
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("kvitt://cards/connected?attempt=abc-123"))
assertEquals(AppLink.CardConnected("abc-123"), AppLinks.parse(intent))
val context = RuntimeEnvironment.getApplication()
val opens = context.packageManager.queryIntentActivities(Intent(intent).addCategory(Intent.CATEGORY_BROWSABLE).setPackage(context.packageName), 0)
.any { it.activityInfo.name == "se.hagstedt.kvitt.MainActivity" }
assertTrue(opens)
}
}