diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 8a2211c..9ad822e 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -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")))
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 2433753..0f3df4d 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -18,7 +18,7 @@
android:roundIcon="@mipmap/ic_launcher"
android:supportsRtl="true"
android:theme="@android:style/Theme.Material.NoActionBar">
-
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/kotlin/se/hagstedt/kvitt/AppViewModel.kt b/android/app/src/main/kotlin/se/hagstedt/kvitt/AppViewModel.kt
index efc68bc..d4b379c 100644
--- a/android/app/src/main/kotlin/se/hagstedt/kvitt/AppViewModel.kt
+++ b/android/app/src/main/kotlin/se/hagstedt/kvitt/AppViewModel.kt
@@ -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 = 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 = openState.asStateFlow()
+ private val cardReturnState = MutableStateFlow(null)
+
+ /** The attempt of the last card return link (CP-2), until S-25 or S-23 has taken it. */
+ val cardReturn: StateFlow = 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
diff --git a/android/app/src/main/kotlin/se/hagstedt/kvitt/link/AppLinks.kt b/android/app/src/main/kotlin/se/hagstedt/kvitt/link/AppLinks.kt
index 3c09630..4251b79 100644
--- a/android/app/src/main/kotlin/se/hagstedt/kvitt/link/AppLinks.kt
+++ b/android/app/src/main/kotlin/se/hagstedt/kvitt/link/AppLinks.kt
@@ -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=` (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=` (CP-2),
+ * `kvitt://invite?server=…&code=…[&from=…]` and
* `https:///i/[?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=`; 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)) }
diff --git a/android/app/src/main/kotlin/se/hagstedt/kvitt/nav/KvittApp.kt b/android/app/src/main/kotlin/se/hagstedt/kvitt/nav/KvittApp.kt
index 487f186..cc31c95 100644
--- a/android/app/src/main/kotlin/se/hagstedt/kvitt/nav/KvittApp.kt
+++ b/android/app/src/main/kotlin/se/hagstedt/kvitt/nav/KvittApp.kt
@@ -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"
diff --git a/android/app/src/main/kotlin/se/hagstedt/kvitt/nav/Routes.kt b/android/app/src/main/kotlin/se/hagstedt/kvitt/nav/Routes.kt
index eadb362..49bc4e7 100644
--- a/android/app/src/main/kotlin/se/hagstedt/kvitt/nav/Routes.kt
+++ b/android/app/src/main/kotlin/se/hagstedt/kvitt/nav/Routes.kt
@@ -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)
diff --git a/android/app/src/test/kotlin/se/hagstedt/kvitt/AppViewModelTest.kt b/android/app/src/test/kotlin/se/hagstedt/kvitt/AppViewModelTest.kt
index 47e3383..b064cd6 100644
--- a/android/app/src/test/kotlin/se/hagstedt/kvitt/AppViewModelTest.kt
+++ b/android/app/src/test/kotlin/se/hagstedt/kvitt/AppViewModelTest.kt
@@ -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")
diff --git a/android/app/src/test/kotlin/se/hagstedt/kvitt/link/AppLinksTest.kt b/android/app/src/test/kotlin/se/hagstedt/kvitt/link/AppLinksTest.kt
index 3bcf0c0..b13064e 100644
--- a/android/app/src/test/kotlin/se/hagstedt/kvitt/link/AppLinksTest.kt
+++ b/android/app/src/test/kotlin/se/hagstedt/kvitt/link/AppLinksTest.kt
@@ -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)
+ }
}
diff --git a/android/build-logic/src/main/kotlin/se/hagstedt/kvitt/buildlogic/ArchitectureRules.kt b/android/build-logic/src/main/kotlin/se/hagstedt/kvitt/buildlogic/ArchitectureRules.kt
index 226e180..5aaab3d 100644
--- a/android/build-logic/src/main/kotlin/se/hagstedt/kvitt/buildlogic/ArchitectureRules.kt
+++ b/android/build-logic/src/main/kotlin/se/hagstedt/kvitt/buildlogic/ArchitectureRules.kt
@@ -7,7 +7,7 @@ package se.hagstedt.kvitt.buildlogic
object ArchitectureRules {
const val FAKES = ":core:fakes"
- private val features = listOf("onboarding", "home", "account", "expense", "settle", "analytics", "settings")
+ private val features = listOf("onboarding", "home", "account", "expense", "settle", "analytics", "settings", "cards")
.map { ":feature:$it" }
/** Pure Kotlin/JVM modules: no Android, no java.time, no java.io; only kotlin.*, kotlinx.* and project imports. */
diff --git a/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Scaffold.kt b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Scaffold.kt
index 4843d58..39a4f6c 100644
--- a/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Scaffold.kt
+++ b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Scaffold.kt
@@ -21,6 +21,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.text.TextAutoSize
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FloatingActionButton
@@ -130,18 +131,20 @@ fun Wordmark(modifier: Modifier = Modifier) {
}
}
-/** The three destinations of the bottom navigation (SPEC §13.2). */
-enum class KvittDestination { Home, Analytics, Settings }
+/** The four destinations of the bottom navigation, in their order (SPEC §13.2, D-172: Kortköp between Översikt and Statistik). */
+enum class KvittDestination { Home, Cards, Analytics, Settings }
/**
* The bottom navigation (design-spec §3.11): Material 3 1.4.0's stable `ShortNavigationBar` (64 dp), `surface-container`,
- * indicator `primary-container`, filled icon when selected, labels label-medium capped at 1.6× (bold when selected).
+ * indicator `primary-container`, filled icon when selected, labels label-medium capped at 1.6× (bold when selected) and
+ * shrunk to fit their slot at large font scales (four destinations, D-172).
*/
@Composable
fun KvittNavigationBar(selected: KvittDestination, onSelect: (KvittDestination) -> Unit, modifier: Modifier = Modifier) {
val scheme = MaterialTheme.colorScheme
val items = listOf(
Triple(KvittDestination.Home, KvittIcon.Home, R.string.nav_home),
+ Triple(KvittDestination.Cards, KvittIcon.CreditCard, R.string.nav_cards),
Triple(KvittDestination.Analytics, KvittIcon.BarChart, R.string.nav_analytics),
Triple(KvittDestination.Settings, KvittIcon.Settings, R.string.nav_settings),
)
@@ -154,10 +157,15 @@ fun KvittNavigationBar(selected: KvittDestination, onSelect: (KvittDestination)
onClick = { onSelect(destination) },
icon = { KvittIconImage(icon, null, filled = isSelected) },
label = {
+ // Four destinations since D-172: at large font scales a label that does not fit its slot
+ // ("Inställningar") shrinks to fit, down to NAVIGATION_LABEL_MIN of its size, instead of being cut.
+ val style = MaterialTheme.typography.labelMedium.capped(KvittFontCaps.NAVIGATION_LABEL).let { if (isSelected) it.copy(fontWeight = FontWeight.Bold) else it }
Text(
stringResource(name),
- style = MaterialTheme.typography.labelMedium.capped(KvittFontCaps.NAVIGATION_LABEL).let { if (isSelected) it.copy(fontWeight = FontWeight.Bold) else it },
+ style = style,
maxLines = 1,
+ softWrap = false,
+ autoSize = TextAutoSize.StepBased(minFontSize = style.fontSize * KvittFontCaps.NAVIGATION_LABEL_MIN, maxFontSize = style.fontSize),
)
},
iconPosition = NavigationItemIconPosition.Top,
diff --git a/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/icon/KvittIcons.kt b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/icon/KvittIcons.kt
index f49186a..1194c7e 100644
--- a/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/icon/KvittIcons.kt
+++ b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/icon/KvittIcons.kt
@@ -31,6 +31,7 @@ enum class KvittIcon(@param:DrawableRes val outline: Int, @param:DrawableRes val
CloudUpload(R.drawable.ic_cloud_upload, R.drawable.ic_cloud_upload_filled),
ContentCopy(R.drawable.ic_content_copy, R.drawable.ic_content_copy_filled),
ContentPaste(R.drawable.ic_content_paste, R.drawable.ic_content_paste_filled),
+ CreditCard(R.drawable.ic_credit_card, R.drawable.ic_credit_card_filled),
DateRange(R.drawable.ic_date_range, R.drawable.ic_date_range_filled),
Delete(R.drawable.ic_delete, R.drawable.ic_delete_filled),
Devices(R.drawable.ic_devices, R.drawable.ic_devices_filled),
diff --git a/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/theme/KvittDimens.kt b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/theme/KvittDimens.kt
index 6c3f298..4b019b1 100644
--- a/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/theme/KvittDimens.kt
+++ b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/theme/KvittDimens.kt
@@ -276,4 +276,14 @@ object KvittDimens {
val settlementHeroVertical = 22.dp
val settlementHeroHorizontal = 20.dp
val settlementArrow = 28.dp
+
+ // Card artwork (S-23 to S-26, D-173, D-180): `CardArt` in :feature:cards. The bundled picture keeps its own aspect
+ // ratio (1313 × 829); the drawn fallback card is 1.586:1 with radius 16 and the name inset 16 from the lower left.
+ const val CARD_ART_ASPECT = 1.586f
+ val cardArtCorner = 16.dp
+ val cardArtNameInset = 16.dp
+ /** S-23 header card: the small artwork is 72 dp wide. */
+ val cardArtHeader = 72.dp
+ /** S-23 empty state and S-25, S-26: the large artwork is at most 280 dp wide. */
+ val cardArtLarge = 280.dp
}
diff --git a/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/theme/KvittTypography.kt b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/theme/KvittTypography.kt
index c4e2259..7bff8f0 100644
--- a/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/theme/KvittTypography.kt
+++ b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/theme/KvittTypography.kt
@@ -88,6 +88,9 @@ data class KvittTypography(
/** Size limits of design-spec §2.7: these labels grow with the font scale only up to the factor given. */
object KvittFontCaps {
const val NAVIGATION_LABEL = 1.6f
+
+ /** A navigation label that does not fit its slot shrinks to fit, to at least this share of its size (D-172, K2). */
+ const val NAVIGATION_LABEL_MIN = 0.7f
const val SEESAW_NAME = 1.15f
const val ACCOUNT_TILE_LETTER = 1.3f
const val AVATAR_INITIALS = 1.3f
diff --git a/android/core/design/src/main/res/drawable/ic_credit_card.xml b/android/core/design/src/main/res/drawable/ic_credit_card.xml
new file mode 100644
index 0000000..95f5a01
--- /dev/null
+++ b/android/core/design/src/main/res/drawable/ic_credit_card.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
diff --git a/android/core/design/src/main/res/drawable/ic_credit_card_filled.xml b/android/core/design/src/main/res/drawable/ic_credit_card_filled.xml
new file mode 100644
index 0000000..24e084c
--- /dev/null
+++ b/android/core/design/src/main/res/drawable/ic_credit_card_filled.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
diff --git a/android/core/design/src/main/res/values-sv/strings.xml b/android/core/design/src/main/res/values-sv/strings.xml
index 6385aac..8cd7c12 100644
--- a/android/core/design/src/main/res/values-sv/strings.xml
+++ b/android/core/design/src/main/res/values-sv/strings.xml
@@ -61,6 +61,7 @@
Datum: %1$s
Översikt
+ Kortköp
Statistik
Inställningar
diff --git a/android/core/design/src/main/res/values/strings.xml b/android/core/design/src/main/res/values/strings.xml
index dc933c9..a85c22b 100644
--- a/android/core/design/src/main/res/values/strings.xml
+++ b/android/core/design/src/main/res/values/strings.xml
@@ -61,6 +61,7 @@
Date: %1$s
Home
+ Card purchases
Analytics
Settings
diff --git a/android/core/design/src/test/kotlin/se/hagstedt/kvitt/design/StringsCatalogueTest.kt b/android/core/design/src/test/kotlin/se/hagstedt/kvitt/design/StringsCatalogueTest.kt
index 2177155..bd1a31e 100644
--- a/android/core/design/src/test/kotlin/se/hagstedt/kvitt/design/StringsCatalogueTest.kt
+++ b/android/core/design/src/test/kotlin/se/hagstedt/kvitt/design/StringsCatalogueTest.kt
@@ -12,7 +12,7 @@ import javax.xml.parsers.DocumentBuilderFactory
* (D-017, SPEC §14.7 Strings), with the CSV's English and Swedish text, plurals included.
*/
class StringsCatalogueTest {
- private val features = listOf("onboarding_", "home_", "account_", "expense_", "settle_", "analytics_", "settings_")
+ private val features = listOf("onboarding_", "home_", "account_", "expense_", "settle_", "analytics_", "settings_", "cards_")
private data class Row(val key: String, val en: String, val sv: String, val notes: String)
diff --git a/android/core/design/src/test/screenshots/navigation_bar_en_light.png b/android/core/design/src/test/screenshots/navigation_bar_en_light.png
index 6f6ba5d..70ca017 100644
Binary files a/android/core/design/src/test/screenshots/navigation_bar_en_light.png and b/android/core/design/src/test/screenshots/navigation_bar_en_light.png differ
diff --git a/android/core/design/src/test/screenshots/navigation_bar_sv_dark.png b/android/core/design/src/test/screenshots/navigation_bar_sv_dark.png
index fe279dc..407ed0c 100644
Binary files a/android/core/design/src/test/screenshots/navigation_bar_sv_dark.png and b/android/core/design/src/test/screenshots/navigation_bar_sv_dark.png differ
diff --git a/android/core/design/src/test/screenshots/navigation_bar_sv_light.png b/android/core/design/src/test/screenshots/navigation_bar_sv_light.png
index 73aa345..50c20de 100644
Binary files a/android/core/design/src/test/screenshots/navigation_bar_sv_light.png and b/android/core/design/src/test/screenshots/navigation_bar_sv_light.png differ
diff --git a/android/core/design/src/test/screenshots/navigation_bar_sv_light_200.png b/android/core/design/src/test/screenshots/navigation_bar_sv_light_200.png
index 3a097da..be76958 100644
Binary files a/android/core/design/src/test/screenshots/navigation_bar_sv_light_200.png and b/android/core/design/src/test/screenshots/navigation_bar_sv_light_200.png differ
diff --git a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeCardRepository.kt b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeCardRepository.kt
new file mode 100644
index 0000000..509d641
--- /dev/null
+++ b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeCardRepository.kt
@@ -0,0 +1,202 @@
+package se.hagstedt.kvitt.fakes
+
+import kotlinx.coroutines.awaitCancellation
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.datetime.LocalDate
+import se.hagstedt.kvitt.domain.CARD_MATCH_MAX
+import se.hagstedt.kvitt.domain.CARD_MATCH_WINDOW_DAYS
+import se.hagstedt.kvitt.domain.MatchableExpense
+import se.hagstedt.kvitt.domain.MatchablePurchase
+import se.hagstedt.kvitt.domain.matchCardPurchase
+import se.hagstedt.kvitt.model.AccountId
+import se.hagstedt.kvitt.model.CardAttemptState
+import se.hagstedt.kvitt.model.CardConnectStart
+import se.hagstedt.kvitt.model.CardConnection
+import se.hagstedt.kvitt.model.CardConnectionId
+import se.hagstedt.kvitt.model.CardConnectionStatus
+import se.hagstedt.kvitt.model.CardDay
+import se.hagstedt.kvitt.model.CardDirection
+import se.hagstedt.kvitt.model.CardFilter
+import se.hagstedt.kvitt.model.CardProvider
+import se.hagstedt.kvitt.model.CardPurchase
+import se.hagstedt.kvitt.model.CardPurchaseDetail
+import se.hagstedt.kvitt.model.CardPurchaseId
+import se.hagstedt.kvitt.model.CardPurchaseRow
+import se.hagstedt.kvitt.model.CardPurchaseStatus
+import se.hagstedt.kvitt.model.CardRepository
+import se.hagstedt.kvitt.model.CardsState
+import se.hagstedt.kvitt.model.DefaultCategoryKey
+import se.hagstedt.kvitt.model.ExpenseDraft
+import se.hagstedt.kvitt.model.ExpenseId
+import se.hagstedt.kvitt.model.ExpenseMatch
+import se.hagstedt.kvitt.model.LinkedExpense
+import se.hagstedt.kvitt.model.OnlineResult
+import se.hagstedt.kvitt.model.SplitMode
+import kotlin.math.abs
+import kotlin.time.Duration.Companion.days
+import kotlin.time.Instant
+
+/**
+ * The fake of [CardRepository] (§14.2.4) over [FakeScenario.cards]: the same filters, order and CP-10 matches (through
+ * `:domain`'s [matchCardPurchase]) as `CardRepositoryImpl`, the CP-9 prefill, local link changes, and the online calls
+ * answering from the scenario ([FakeStore.online]).
+ */
+class FakeCardRepository(private val store: FakeStore) : CardRepository {
+ override val hasConnection: Flow = store.derive { it.activeConnection() != null }.distinctUntilChanged()
+
+ override fun cards(filter: CardFilter): Flow = store.derive { s -> s.cardsState(filter) }.distinctUntilChanged()
+
+ override fun purchase(id: CardPurchaseId): Flow = store.derive { s ->
+ val p = s.cards.purchases.firstOrNull { it.id == id } ?: return@derive null
+ val toHandle = p.toHandle()
+ CardPurchaseDetail(CardPurchaseRow(s.model(p), if (toHandle) s.matches(p) else emptyList()), if (toHandle) s.others(p) else emptyList())
+ }.distinctUntilChanged()
+
+ override fun toHandle(): Flow> = store.derive { s -> s.newestFirst().filter { it.toHandle() }.map { s.model(it) } }.distinctUntilChanged()
+
+ override fun purchaseForExpense(id: ExpenseId): Flow =
+ store.derive { s -> s.cards.purchases.firstOrNull { it.expenseId == id }?.let { s.model(it) } }.distinctUntilChanged()
+
+ override suspend fun providers(): OnlineResult> {
+ if (store.current.online && store.current.cards.providersLoading) awaitCancellation()
+ return store.online { s -> s.cards.providersFailure?.let { OnlineResult.Failed(it, null) } ?: OnlineResult.Ok(s.cards.providers) }
+ }
+
+ override suspend fun startConnect(providerId: String, historyDays: Int): OnlineResult =
+ store.online { OnlineResult.Ok(CardConnectStart(ATTEMPT_ID, AUTH_URL, "2026-10-17T12:17:00.000Z")) }
+
+ override suspend fun attempt(attemptId: String): OnlineResult = store.online { s ->
+ val attempt = s.cards.attempt
+ if (attempt is CardAttemptState.Connected) store.edit { it.copy(cards = it.cards.copy(connection = attempt.connection)) }
+ OnlineResult.Ok(attempt)
+ }
+
+ override suspend fun refresh(): OnlineResult = store.online { s ->
+ if (s.activeConnection()?.status != CardConnectionStatus.ACTIVE) OnlineResult.Failed("card_consent_expired", null) else OnlineResult.Ok(Unit)
+ }
+
+ override suspend fun setHistoryDays(id: CardConnectionId, days: Int): OnlineResult = store.online { s ->
+ val c = s.cards.connection?.takeIf { it.id == id } ?: return@online OnlineResult.Failed("not_found", null)
+ if (days > c.historyDays) return@online OnlineResult.Failed("validation_failed", "history_days")
+ store.edit { it.copy(cards = it.cards.copy(connection = c.copy(historyDays = days))) }
+ OnlineResult.Ok(Unit)
+ }
+
+ /** CP-12: the purchases neither linked nor ignored go, the handled ones too with [eraseHandled]. */
+ override suspend fun disconnect(id: CardConnectionId, eraseHandled: Boolean): OnlineResult = store.online { s ->
+ val c = s.cards.connection?.takeIf { it.id == id } ?: return@online OnlineResult.Failed("not_found", null)
+ store.edit {
+ it.copy(
+ cards = it.cards.copy(
+ connection = c.copy(status = CardConnectionStatus.DISCONNECTED),
+ purchases = it.cards.purchases.filter { p -> !eraseHandled && (p.expenseId != null || p.ignored) },
+ ),
+ )
+ }
+ OnlineResult.Ok(Unit)
+ }
+
+ /** CP-9 with the AC-8.6 default account and its category, as `CardRepositoryImpl.prefill`. */
+ override suspend fun prefill(id: CardPurchaseId, accountId: AccountId?): ExpenseDraft? {
+ val s = store.current
+ val p = s.cards.purchases.firstOrNull { it.id == id }?.takeIf { it.direction == CardDirection.DEBIT } ?: return null
+ val active = s.accountsInOrder().filter { !it.archived }
+ val account = accountId?.takeIf { a -> active.any { it.id == a } } ?: s.defaultAccountId?.takeIf { a -> active.any { it.id == a } } ?: active.firstOrNull()?.id
+ val categories = s.categories.filter { !it.archived }
+ val category = account?.let { s.lastCategoryFor[it] }?.takeIf { c -> categories.any { it.id == c } } ?: categories.firstOrNull { it.defaultKey == DefaultCategoryKey.OTHER }?.id
+ val description = p.merchant.trim().let { if (it.codePointCount(0, it.length) <= DESCRIPTION_MAX) it else it.substring(0, it.offsetByCodePoints(0, DESCRIPTION_MAX)) }
+ return ExpenseDraft(null, account, category, p.amountOre, description, "", p.date, s.me.id, SplitMode.DEFAULT, null, null, null)
+ }
+
+ override suspend fun link(id: CardPurchaseId, expenseId: ExpenseId) = change(id) { it.copy(expenseId = expenseId, ignored = false) }
+
+ override suspend fun unlink(id: CardPurchaseId) = change(id) { it.copy(expenseId = null) }
+
+ override suspend fun setIgnored(id: CardPurchaseId, ignored: Boolean) = change(id) { it.copy(ignored = ignored, expenseId = if (ignored) null else it.expenseId) }
+
+ private fun change(id: CardPurchaseId, edit: (FakeCardPurchase) -> FakeCardPurchase) = store.edit { s ->
+ s.copy(cards = s.cards.copy(purchases = s.cards.purchases.map { if (it.id == id && it.direction == CardDirection.DEBIT) edit(it).copy(pendingLocal = true) else it }))
+ }
+
+ companion object {
+ const val ATTEMPT_ID = "01920000-0000-7000-8000-0000000000aa"
+ const val AUTH_URL = "https://bank.example.org/authorize"
+ private const val DESCRIPTION_MAX = 100
+ private const val OTHERS_WINDOW_DAYS = 14
+ }
+
+ private fun FakeScenario.activeConnection(): CardConnection? = cards.connection?.takeIf { it.status != CardConnectionStatus.DISCONNECTED }
+
+ private fun FakeScenario.cardsState(filter: CardFilter): CardsState {
+ val connection = activeConnection()
+ val provider = connection?.let { c -> cards.providers.firstOrNull { it.id == c.providerId } ?: CardProvider(c.providerId, "", "", c.providerId, true) } ?: cards.providers.firstOrNull()
+ if (connection == null) return CardsState(provider, null, filter, CardFilter.entries.associateWith { 0 }, emptyList(), cards.fetching)
+ val rows = newestFirst().filter { it.inFilter(filter) }.map { CardPurchaseRow(model(it), if (filter == CardFilter.TO_HANDLE) matches(it) else emptyList()) }
+ return CardsState(
+ provider = provider,
+ connection = connection.copy(expiresSoon = expiresSoon(connection)),
+ filter = filter,
+ counts = CardFilter.entries.associateWith { f -> cards.purchases.count { it.inFilter(f) } },
+ days = rows.groupBy { it.purchase.date }.map { (date, inDay) -> CardDay(date, inDay) },
+ fetching = cards.fetching,
+ )
+ }
+
+ /** CP-6: ACTIVE and `valid_until` within 14 days of the scenario's clock. */
+ private fun FakeScenario.expiresSoon(c: CardConnection): Boolean {
+ val until = c.validUntil?.let { Instant.parse(it) } ?: return false
+ return c.status == CardConnectionStatus.ACTIVE && until - Instant.parse(now) <= EXPIRY_NOTICE.days
+ }
+
+ private fun FakeScenario.newestFirst() =
+ cards.purchases.sortedWith(compareByDescending { it.date }.thenByDescending { it.createdAt }.thenByDescending { it.id.value })
+
+ private fun FakeScenario.model(p: FakeCardPurchase) = CardPurchase(
+ id = p.id,
+ connectionId = cards.connection?.id ?: CardConnectionId(""),
+ status = p.status,
+ direction = p.direction,
+ date = p.date,
+ bookingDate = if (p.status == CardPurchaseStatus.BOOKED) p.date else null,
+ amountOre = p.amountOre,
+ originalAmount = p.originalAmount,
+ originalCurrency = p.originalCurrency,
+ merchant = p.merchant,
+ linked = p.expenseId?.let { id -> expenses.firstOrNull { it.id == id && !it.deleted }?.let { LinkedExpense(it.id, it.description, account(it.accountId)?.name.orEmpty()) } },
+ ignored = p.ignored,
+ pendingLocal = p.pendingLocal,
+ )
+
+ private fun FakeScenario.linkedByOther(expense: ExpenseId, purchase: CardPurchaseId) = cards.purchases.any { it.id != purchase && it.expenseId == expense }
+
+ private fun FakeScenario.matches(p: FakeCardPurchase): List {
+ val live = expenses.filter { !it.deleted }
+ val candidates = live.map { MatchableExpense(it.id.value, it.amountOre, LocalDate.parse(it.date), it.createdAt, linkedByOther(it.id, p.id)) }
+ val ids = matchCardPurchase(MatchablePurchase(p.amountOre, LocalDate.parse(p.date), p.direction.name.lowercase()), candidates, CARD_MATCH_WINDOW_DAYS, CARD_MATCH_MAX)
+ return ids.mapNotNull { id -> live.firstOrNull { it.id.value == id }?.let { match(it) } }
+ }
+
+ private fun FakeScenario.others(p: FakeCardPurchase): List {
+ val day = LocalDate.parse(p.date).toEpochDays()
+ return expenses.filter { !it.deleted && !linkedByOther(it.id, p.id) && abs(LocalDate.parse(it.date).toEpochDays() - day) <= OTHERS_WINDOW_DAYS }
+ .sortedWith(compareByDescending { it.date }.thenByDescending { it.createdAt }.thenBy { it.id.value })
+ .mapNotNull { match(it) }
+ }
+
+ private fun FakeScenario.match(e: FakeExpense): ExpenseMatch? {
+ val payer = user(e.payer) ?: return null
+ return ExpenseMatch(e.id, e.description, account(e.accountId)?.name.orEmpty(), payer, e.date, e.amountOre)
+ }
+}
+
+private const val EXPIRY_NOTICE = 14
+
+private fun FakeCardPurchase.toHandle() = direction == CardDirection.DEBIT && expenseId == null && !ignored
+
+private fun FakeCardPurchase.inFilter(filter: CardFilter) = when (filter) {
+ CardFilter.TO_HANDLE -> toHandle()
+ CardFilter.LINKED -> expenseId != null
+ CardFilter.IGNORED -> ignored
+ CardFilter.ALL -> true
+}
diff --git a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeExpenseRepository.kt b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeExpenseRepository.kt
index b7c7b9a..f77bd29 100644
--- a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeExpenseRepository.kt
+++ b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeExpenseRepository.kt
@@ -120,8 +120,12 @@ class FakeExpenseRepository(private val store: FakeStore) : ExpenseRepository {
override suspend fun suggestCategory(accountId: AccountId?, description: String): CategoryId? =
store.current.suggestCategory(accountId, description)
+ /** A soft delete; a card purchase linked to it is "Att hantera" again (CP-9). */
override suspend fun delete(id: ExpenseId) = store.edit { s ->
- s.copy(expenses = s.expenses.map { if (it.id == id) it.copy(deleted = true, pending = true) else it })
+ s.copy(
+ expenses = s.expenses.map { if (it.id == id) it.copy(deleted = true, pending = true) else it },
+ cards = s.cards.copy(purchases = s.cards.purchases.map { if (it.expenseId == id) it.copy(expenseId = null) else it }),
+ )
}
override fun detail(id: ExpenseId): Flow = store.derive { s ->
diff --git a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeRepositories.kt b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeRepositories.kt
index 6f1436e..798ef34 100644
--- a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeRepositories.kt
+++ b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeRepositories.kt
@@ -19,4 +19,5 @@ class FakeRepositories(scenario: FakeScenario = FakeScenarios.empty) {
val update = FakeUpdateRepository(store)
val about = FakeAboutRepository(store)
val connectivity = FakeConnectivityRepository(store)
+ val cards = FakeCardRepository(store)
}
diff --git a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeScenario.kt b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeScenario.kt
index 73fbd35..453f784 100644
--- a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeScenario.kt
+++ b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeScenario.kt
@@ -3,6 +3,12 @@ package se.hagstedt.kvitt.fakes
import se.hagstedt.kvitt.model.AboutState
import se.hagstedt.kvitt.model.Account
import se.hagstedt.kvitt.model.AccountId
+import se.hagstedt.kvitt.model.CardAttemptState
+import se.hagstedt.kvitt.model.CardConnection
+import se.hagstedt.kvitt.model.CardDirection
+import se.hagstedt.kvitt.model.CardProvider
+import se.hagstedt.kvitt.model.CardPurchaseId
+import se.hagstedt.kvitt.model.CardPurchaseStatus
import se.hagstedt.kvitt.model.Category
import se.hagstedt.kvitt.model.CategoryId
import se.hagstedt.kvitt.model.Device
@@ -68,6 +74,7 @@ data class FakeSettlement(
* @property pairingInviteKind the invite kind `pair` reports; null: `partner` for a member, `admin_setup` for the admin.
* @property insightsFailure the code the Insights reads fail with while online (`network`: the server could not be
* reached); null: they succeed (D-141).
+ * @property cards the viewer's card catalogue, connection and purchases (S-23 to S-27, D-173); none by default.
*/
data class FakeScenario(
val me: User,
@@ -95,6 +102,44 @@ data class FakeScenario(
val lastAttemptFailed: Boolean = false,
val pairingInviteKind: InviteKind? = null,
val insightsFailure: String? = null,
+ val cards: FakeCards = FakeCards(),
) {
val users: List get() = listOfNotNull(me, partner)
}
+
+/** A card purchase as the fakes hold it: the stored row of §14.2.1 in model types; [expenseId] the link (CP-9). */
+data class FakeCardPurchase(
+ val id: CardPurchaseId,
+ val date: String,
+ val amountOre: Long?,
+ val merchant: String,
+ val status: CardPurchaseStatus = CardPurchaseStatus.BOOKED,
+ val direction: CardDirection = CardDirection.DEBIT,
+ val originalAmount: String? = null,
+ val originalCurrency: String? = null,
+ val expenseId: ExpenseId? = null,
+ val ignored: Boolean = false,
+ val pendingLocal: Boolean = false,
+ val createdAt: String = date + "T12:00:00.000Z",
+)
+
+/**
+ * The viewer's cards (D-161: only the holder's rows exist on the phone). [connection] null: no connection (the empty
+ * state); its `expiresSoon` is derived from the scenario's clock (CP-6). [providersFailure]: the code E-36 fails with
+ * while online (`network`: "Kunde inte nå servern"); [providersLoading]: E-36 never answers (S-24's skeleton).
+ * [attempt]: what E-38 answers; [fetching]: an E-42 call is running.
+ */
+data class FakeCards(
+ val providers: List = listOf(SJ_PRIO),
+ val providersFailure: String? = null,
+ val providersLoading: Boolean = false,
+ val connection: CardConnection? = null,
+ val purchases: List = emptyList(),
+ val attempt: CardAttemptState = CardAttemptState.Pending,
+ val fetching: Boolean = false,
+) {
+ companion object {
+ /** The registry's only entry (CP-1). */
+ val SJ_PRIO = CardProvider("sj_prio_mastercard", "SJ Prio Mastercard", "SEB Kort", "sj_prio_mastercard", available = true)
+ }
+}
diff --git a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/cards/CardScenarios.kt b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/cards/CardScenarios.kt
new file mode 100644
index 0000000..8441e5d
--- /dev/null
+++ b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/cards/CardScenarios.kt
@@ -0,0 +1,132 @@
+package se.hagstedt.kvitt.fakes.cards
+
+import se.hagstedt.kvitt.fakes.FakeCardPurchase
+import se.hagstedt.kvitt.fakes.FakeCards
+import se.hagstedt.kvitt.fakes.FakeScenario
+import se.hagstedt.kvitt.fakes.home.HomeScenarios
+import se.hagstedt.kvitt.model.CardAttemptState
+import se.hagstedt.kvitt.model.CardConnection
+import se.hagstedt.kvitt.model.CardConnectionId
+import se.hagstedt.kvitt.model.CardConnectionStatus
+import se.hagstedt.kvitt.model.CardDirection
+import se.hagstedt.kvitt.model.CardFetchError
+import se.hagstedt.kvitt.model.CardProvider
+import se.hagstedt.kvitt.model.CardPurchaseId
+import se.hagstedt.kvitt.model.CardPurchaseStatus
+import se.hagstedt.kvitt.model.DefaultCategoryKey
+
+/**
+ * Card purchases (S-23 to S-27) on [HomeScenarios.household]: Jacob's SJ Prio Mastercard, connected for 30 days of
+ * history, valid until 22 April 2027, fetched 15 minutes before the clock (17 October 2026 14:02). The purchases cover
+ * every row of S-23: one match (Anna's ICA Maxi entry, the Systembolaget expense), two matches, none, pending, a foreign
+ * currency, linked, ignored and a credit. Matches come from `:domain` (§6.2).
+ */
+object CardScenarios {
+ private val jacob = HomeScenarios.household.me.id
+ private val anna = HomeScenarios.household.partner?.id ?: jacob
+
+ private fun id(n: Int) = CardPurchaseId("01920000-0000-7000-8000-0000000c" + n.toString(16).padStart(4, '0'))
+
+ val connectionId = CardConnectionId("01920000-0000-7000-8000-0000000cc001")
+
+ val connection = CardConnection(
+ id = connectionId,
+ providerId = FakeCards.SJ_PRIO.id,
+ status = CardConnectionStatus.ACTIVE,
+ historyDays = 30,
+ validUntil = "2027-04-22T10:00:00.000Z",
+ lastFetchAt = "2026-10-17T11:47:00.000Z",
+ lastFetchError = null,
+ expiresSoon = false,
+ )
+
+ /** A second household expense of 384,40 kr next to Hemköp's, so one purchase has two possible expenses. */
+ private val apotek = HomeScenarios.expense(20, HomeScenarios.vardag, DefaultCategoryKey.HEALTH, 38_440, "Apotek Hjärtat", "2026-10-12", anna)
+
+ val ica = FakeCardPurchase(id(1), "2026-10-16", 124_760, "ICA MAXI LINDHAGEN")
+ val systembolaget = FakeCardPurchase(id(2), "2026-10-16", 41_200, "SYSTEMBOLAGET 0152 STHLM", status = CardPurchaseStatus.PENDING)
+ val pressbyran = FakeCardPurchase(id(3), "2026-10-17", 5_400, "PRESSBYRAN ODENPLAN", status = CardPurchaseStatus.PENDING)
+ val twoMatches = FakeCardPurchase(id(4), "2026-10-11", 38_440, "KLARNA*HEMKOP")
+ val wolt = FakeCardPurchase(id(5), "2026-10-13", 28_900, "WOLT")
+ val amazon = FakeCardPurchase(id(6), "2026-10-12", null, "AMAZON.DE", originalAmount = "24.99", originalCurrency = "EUR")
+ val willys = FakeCardPurchase(id(7), "2026-09-28", 52_300, "WILLYS HORNSTULL", expenseId = HomeScenarios.willys.id)
+ val bauhaus = FakeCardPurchase(id(8), "2026-09-20", 282_320, "BAUHAUS BARKARBY", expenseId = HomeScenarios.bauhaus.id)
+ val spotify = FakeCardPurchase(id(9), "2026-10-10", 11_900, "SPOTIFY P2F3A1", ignored = true)
+ val payment = FakeCardPurchase(id(10), "2026-10-02", 500_000, "BETALNING TACK", direction = CardDirection.CREDIT)
+
+ private val all = listOf(ica, systembolaget, pressbyran, twoMatches, wolt, amazon, willys, bauhaus, spotify, payment)
+
+ private val base: FakeScenario = HomeScenarios.household.copy(expenses = HomeScenarios.household.expenses + apotek)
+
+ /** No connection: S-23's empty state "Anslut ditt kort"; S-24 lists the catalogue. */
+ val none: FakeScenario = base
+
+ /** Connected, the first fetch after BankID still running: "Hämtar dina köp…". */
+ val firstFetch: FakeScenario = base.copy(cards = FakeCards(connection = connection.copy(lastFetchAt = null)))
+
+ /** Connected with every kind of row. */
+ val loaded: FakeScenario = base.copy(cards = FakeCards(connection = connection, purchases = all))
+
+ /** Connected, nothing to handle: "Inget att hantera". */
+ val nothingToHandle: FakeScenario = base.copy(cards = FakeCards(connection = connection, purchases = listOf(willys, bauhaus, spotify)))
+
+ /** Connected, no purchases at all: every filter is empty. */
+ val noPurchases: FakeScenario = base.copy(cards = FakeCards(connection = connection))
+
+ /** [loaded] offline: "Uppdatera" disabled; linking and ignoring still work (CP-9). */
+ val offline: FakeScenario = loaded.copy(online = false)
+
+ /** An E-42 call of this phone running: "Uppdaterar…". */
+ val refreshing: FakeScenario = loaded.copy(cards = loaded.cards.copy(fetching = true))
+
+ /** The consent ends in ten days (CP-6): "Kortanslutningen upphör …" with "Förnya med BankID". */
+ val expiring: FakeScenario = loaded.copy(cards = loaded.cards.copy(connection = connection.copy(validUntil = "2026-10-27T10:00:00.000Z")))
+
+ /** The consent has ended: "Kortanslutningen har upphört"; the list is kept and still works. */
+ val expired: FakeScenario = loaded.copy(
+ cards = loaded.cards.copy(connection = connection.copy(status = CardConnectionStatus.EXPIRED, validUntil = "2026-10-15T10:00:00.000Z", lastFetchError = CardFetchError.CONSENT_EXPIRED)),
+ )
+
+ /** The last fetch failed (CP-14): "Kunde inte hämta köp. Senast uppdaterad …" with "Försök igen". */
+ val fetchFailed: FakeScenario = loaded.copy(
+ cards = loaded.cards.copy(connection = connection.copy(lastFetchAt = "2026-10-16T19:45:00.000Z", lastFetchError = CardFetchError.BANK_UNAVAILABLE)),
+ )
+
+ /** Long merchant texts and 7-digit amounts. */
+ val long: FakeScenario = base.copy(
+ cards = FakeCards(
+ connection = connection,
+ purchases = listOf(
+ FakeCardPurchase(id(21), "2026-10-17", 1_234_567_89, "BYGGMAX STORA INKÖPSCENTRET HÄGERSTEN ÅRSTABERG STOCKHOLM", status = CardPurchaseStatus.PENDING),
+ FakeCardPurchase(id(22), "2026-10-16", 124_760, "ICA MAXI LINDHAGEN STOCKHOLM KUNGSHOLMEN SANKT ERIKSGATAN"),
+ FakeCardPurchase(id(23), "2026-10-15", 9_876_543_21, "RESEBYRÅN SKANDINAVISKA FJÄLLRESOR OCH ÄVENTYR AKTIEBOLAG"),
+ FakeCardPurchase(id(24), "2026-10-12", null, "AMAZON MARKETPLACE EU SARL LUXEMBOURG", originalAmount = "1234.56", originalCurrency = "EUR"),
+ ),
+ ),
+ )
+
+ // S-24 and S-25
+
+ /** [none] with the provider not configured for the viewer on the server (CP-13). */
+ val unavailable: FakeScenario = none.copy(cards = FakeCards(providers = listOf(FakeCards.SJ_PRIO.copy(available = false))))
+
+ /** A catalogue entry with an image key the app has no artwork for: the drawn fallback card (D-180). */
+ val unknownArtwork: FakeScenario = none.copy(
+ cards = FakeCards(providers = listOf(FakeCards.SJ_PRIO, CardProvider("nordea_visa", "Nordea Visa", "Nordea", "nordea_visa", available = true))),
+ )
+
+ /** E-36 fails: "Kunde inte nå servern" with "Försök igen". */
+ val providersFailed: FakeScenario = none.copy(cards = FakeCards(providersFailure = "network"))
+
+ /** E-36 has not answered: the skeleton tile. */
+ val providersLoading: FakeScenario = none.copy(cards = FakeCards(providersLoading = true))
+
+ /** [none] offline: S-24's tiles and S-25's button disabled with "Kräver anslutning". */
+ val connectOffline: FakeScenario = none.copy(online = false)
+
+ /** E-38 answers per state (S-25). */
+ val attemptPending: FakeScenario = none.copy(cards = FakeCards(attempt = CardAttemptState.Pending))
+ val attemptConnected: FakeScenario = none.copy(cards = FakeCards(attempt = CardAttemptState.Connected(connection.copy(lastFetchAt = null))))
+ val attemptFailed: FakeScenario = none.copy(cards = FakeCards(attempt = CardAttemptState.Failed("cancelled")))
+ val attemptExpired: FakeScenario = none.copy(cards = FakeCards(attempt = CardAttemptState.Expired))
+}
diff --git a/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Cards.kt b/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Cards.kt
new file mode 100644
index 0000000..22f2b59
--- /dev/null
+++ b/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Cards.kt
@@ -0,0 +1,133 @@
+package se.hagstedt.kvitt.model
+
+import kotlinx.coroutines.flow.Flow
+
+// Card purchases (S-23 to S-27, FR-22, §9.12), SPEC §14.2.4 "Changes in v0.35". Only the signed-in user's own card rows
+// exist on the phone (D-161). Wire values of the enums are their lowercase names.
+
+@JvmInline value class CardConnectionId(val value: String)
+
+@JvmInline value class CardPurchaseId(val value: String)
+
+enum class CardConnectionStatus { ACTIVE, EXPIRED, DISCONNECTED }
+
+enum class CardPurchaseStatus { PENDING, BOOKED }
+
+enum class CardDirection { DEBIT, CREDIT }
+
+enum class CardFilter { TO_HANDLE, LINKED, IGNORED, ALL }
+
+/** Wire: lowercase; an unknown value reads as [FAILED] (CP-14). */
+enum class CardFetchError { BANK_UNAVAILABLE, RATE_LIMITED, CONSENT_EXPIRED, FAILED }
+
+/** An entry of the card catalogue (E-36, CP-1); [id] and [imageKey] are open strings. */
+data class CardProvider(val id: String, val name: String, val issuer: String, val imageKey: String, val available: Boolean)
+
+/** [expiresSoon]: [status] ACTIVE and [validUntil] within 14 days (CP-6), computed with the phone's clock. */
+data class CardConnection(
+ val id: CardConnectionId,
+ val providerId: String,
+ val status: CardConnectionStatus,
+ val historyDays: Int,
+ val validUntil: String?,
+ val lastFetchAt: String?,
+ val lastFetchError: CardFetchError?,
+ val expiresSoon: Boolean,
+)
+
+data class LinkedExpense(val id: ExpenseId, val description: String, val accountName: String)
+
+/** [amountOre] null: foreign currency (CP-7); [linked] null also while the linked expense is not on the phone. */
+data class CardPurchase(
+ val id: CardPurchaseId,
+ val connectionId: CardConnectionId,
+ val status: CardPurchaseStatus,
+ val direction: CardDirection,
+ val date: String,
+ val bookingDate: String?,
+ val amountOre: Long?,
+ val originalAmount: String?,
+ val originalCurrency: String?,
+ val merchant: String,
+ val linked: LinkedExpense?,
+ val ignored: Boolean,
+ val pendingLocal: Boolean,
+)
+
+data class ExpenseMatch(val id: ExpenseId, val description: String, val accountName: String, val payer: User, val date: String, val amountOre: Long)
+
+/** [matches] (CP-10) only under [CardFilter.TO_HANDLE], else empty. */
+data class CardPurchaseRow(val purchase: CardPurchase, val matches: List)
+
+data class CardDay(val date: String, val rows: List)
+
+/** [connection] null: the empty state; [fetching]: an E-42 call of this phone is running. */
+data class CardsState(
+ val provider: CardProvider?,
+ val connection: CardConnection?,
+ val filter: CardFilter,
+ val counts: Map,
+ val days: List,
+ val fetching: Boolean,
+)
+
+/** [others]: "Välj en annan utgift" (CP-10). */
+data class CardPurchaseDetail(val row: CardPurchaseRow, val others: List)
+
+data class CardConnectStart(val attemptId: String, val authUrl: String, val expiresAt: String)
+
+sealed interface CardAttemptState {
+ data object Pending : CardAttemptState
+
+ data class Connected(val connection: CardConnection) : CardAttemptState
+
+ /** [reason]: the E-38 failure token, for example "no_account", "bank_error", "cancelled". */
+ data class Failed(val reason: String) : CardAttemptState
+
+ data object Expired : CardAttemptState
+}
+
+interface CardRepository {
+ /** A connection with status ACTIVE or EXPIRED exists (S-5's "Från kortköp"). */
+ val hasConnection: Flow
+
+ /** S-23, from Room; works offline. */
+ fun cards(filter: CardFilter): Flow
+
+ /** The purchase sheet. */
+ fun purchase(id: CardPurchaseId): Flow
+
+ /** S-27: debit, neither linked nor ignored, newest first. */
+ fun toHandle(): Flow>
+
+ /** S-8's line. */
+ fun purchaseForExpense(id: ExpenseId): Flow
+
+ /** E-36, online only (S-24). */
+ suspend fun providers(): OnlineResult>
+
+ /** E-37, connect and renew. */
+ suspend fun startConnect(providerId: String, historyDays: Int): OnlineResult
+
+ /** E-38. */
+ suspend fun attempt(attemptId: String): OnlineResult
+
+ /** E-42, then a card pull. */
+ suspend fun refresh(): OnlineResult
+
+ /** E-41, shorter only. */
+ suspend fun setHistoryDays(id: CardConnectionId, days: Int): OnlineResult
+
+ /** E-43. */
+ suspend fun disconnect(id: CardConnectionId, eraseHandled: Boolean): OnlineResult
+
+ /** CP-9; [accountId] null: the AC-8.6 default; null when the purchase is unknown. */
+ suspend fun prefill(id: CardPurchaseId, accountId: AccountId?): ExpenseDraft?
+
+ /** Local at once, sent later (CP-9); never fails. */
+ suspend fun link(id: CardPurchaseId, expenseId: ExpenseId)
+
+ suspend fun unlink(id: CardPurchaseId)
+
+ suspend fun setIgnored(id: CardPurchaseId, ignored: Boolean)
+}
diff --git a/android/core/model/src/test/kotlin/se/hagstedt/kvitt/model/ContractTest.kt b/android/core/model/src/test/kotlin/se/hagstedt/kvitt/model/ContractTest.kt
index e1928a4..ca9ebb4 100644
--- a/android/core/model/src/test/kotlin/se/hagstedt/kvitt/model/ContractTest.kt
+++ b/android/core/model/src/test/kotlin/se/hagstedt/kvitt/model/ContractTest.kt
@@ -42,6 +42,17 @@ class ContractTest {
)
}
+ /** §14.3.3 card wire values (v0.35, D-167): `CardConnection.status`, `CardPurchase.status` and `direction`, `last_fetch_error`. */
+ @Test
+ fun cardEnumWireValuesMatchTheSpec() {
+ assertEquals(listOf("active", "expired", "disconnected"), wire(CardConnectionStatus.entries.toTypedArray()))
+ assertEquals(listOf("pending", "booked"), wire(CardPurchaseStatus.entries.toTypedArray()))
+ assertEquals(listOf("debit", "credit"), wire(CardDirection.entries.toTypedArray()))
+ assertEquals(listOf("bank_unavailable", "rate_limited", "consent_expired", "failed"), wire(CardFetchError.entries.toTypedArray()))
+ // S-23's filters in the order of the chips: "Att hantera" (default), "Kopplade", "Ignorerade", "Alla".
+ assertEquals(listOf("to_handle", "linked", "ignored", "all"), wire(CardFilter.entries.toTypedArray()))
+ }
+
@Test
fun domainErrorTokensMatchSpec14_5_3() {
assertEquals(
diff --git a/android/data/schemas/se.hagstedt.kvitt.data.db.KvittDatabase/2.json b/android/data/schemas/se.hagstedt.kvitt.data.db.KvittDatabase/2.json
new file mode 100644
index 0000000..7aa13f9
--- /dev/null
+++ b/android/data/schemas/se.hagstedt.kvitt.data.db.KvittDatabase/2.json
@@ -0,0 +1,1233 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 2,
+ "identityHash": "b6530112027f347b0de64eab87fdb779",
+ "entities": [
+ {
+ "tableName": "users",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `role` TEXT NOT NULL, `display_name` TEXT NOT NULL, `picture_version` INTEGER, `created_at` TEXT NOT NULL, `updated_at` TEXT NOT NULL, `server_version` INTEGER NOT NULL, `local_updated_at` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "role",
+ "columnName": "role",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "displayName",
+ "columnName": "display_name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "pictureVersion",
+ "columnName": "picture_version",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "created_at",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updated_at",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "serverVersion",
+ "columnName": "server_version",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "localUpdatedAt",
+ "columnName": "local_updated_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "accounts",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `created_by_user_id` TEXT NOT NULL, `created_at` TEXT NOT NULL, `updated_at` TEXT NOT NULL, `archived_at` TEXT, `deleted_at` TEXT, `server_version` INTEGER NOT NULL, `local_updated_at` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdByUserId",
+ "columnName": "created_by_user_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "created_at",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updated_at",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "archivedAt",
+ "columnName": "archived_at",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "deletedAt",
+ "columnName": "deleted_at",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "serverVersion",
+ "columnName": "server_version",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "localUpdatedAt",
+ "columnName": "local_updated_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_accounts_created_at",
+ "unique": false,
+ "columnNames": [
+ "created_at"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_accounts_created_at` ON `${TABLE_NAME}` (`created_at`)"
+ }
+ ]
+ },
+ {
+ "tableName": "account_default_shares",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`account_id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `share_bp` INTEGER NOT NULL, PRIMARY KEY(`account_id`, `user_id`))",
+ "fields": [
+ {
+ "fieldPath": "accountId",
+ "columnName": "account_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "userId",
+ "columnName": "user_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "shareBp",
+ "columnName": "share_bp",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "account_id",
+ "user_id"
+ ]
+ }
+ },
+ {
+ "tableName": "categories",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `default_key` TEXT, `name` TEXT, `icon` TEXT NOT NULL, `color` TEXT NOT NULL, `sort_order` INTEGER NOT NULL, `created_at` TEXT NOT NULL, `updated_at` TEXT NOT NULL, `archived_at` TEXT, `deleted_at` TEXT, `server_version` INTEGER NOT NULL, `local_updated_at` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "defaultKey",
+ "columnName": "default_key",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "icon",
+ "columnName": "icon",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "color",
+ "columnName": "color",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sortOrder",
+ "columnName": "sort_order",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "created_at",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updated_at",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "archivedAt",
+ "columnName": "archived_at",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "deletedAt",
+ "columnName": "deleted_at",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "serverVersion",
+ "columnName": "server_version",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "localUpdatedAt",
+ "columnName": "local_updated_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "recurring_templates",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `account_id` TEXT NOT NULL, `category_id` TEXT NOT NULL, `amount_ore` INTEGER NOT NULL, `description` TEXT NOT NULL, `notes` TEXT NOT NULL, `payer_user_id` TEXT NOT NULL, `split_mode` TEXT NOT NULL, `day_of_month` INTEGER NOT NULL, `start_period` TEXT NOT NULL, `end_period` TEXT, `paused` INTEGER NOT NULL, `last_generated_period` TEXT, `created_by_user_id` TEXT NOT NULL, `updated_by_user_id` TEXT NOT NULL, `created_at` TEXT NOT NULL, `updated_at` TEXT NOT NULL, `deleted_at` TEXT, `server_version` INTEGER NOT NULL, `next_occurrence_date` TEXT, `local_updated_at` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "accountId",
+ "columnName": "account_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "categoryId",
+ "columnName": "category_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "amountOre",
+ "columnName": "amount_ore",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "description",
+ "columnName": "description",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "notes",
+ "columnName": "notes",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "payerUserId",
+ "columnName": "payer_user_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "splitMode",
+ "columnName": "split_mode",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "dayOfMonth",
+ "columnName": "day_of_month",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "startPeriod",
+ "columnName": "start_period",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "endPeriod",
+ "columnName": "end_period",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "paused",
+ "columnName": "paused",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastGeneratedPeriod",
+ "columnName": "last_generated_period",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "createdByUserId",
+ "columnName": "created_by_user_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedByUserId",
+ "columnName": "updated_by_user_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "created_at",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updated_at",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "deletedAt",
+ "columnName": "deleted_at",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "serverVersion",
+ "columnName": "server_version",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "nextOccurrenceDate",
+ "columnName": "next_occurrence_date",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "localUpdatedAt",
+ "columnName": "local_updated_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_recurring_templates_account_id",
+ "unique": false,
+ "columnNames": [
+ "account_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_recurring_templates_account_id` ON `${TABLE_NAME}` (`account_id`)"
+ }
+ ]
+ },
+ {
+ "tableName": "recurring_template_splits",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`template_id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `share_bp` INTEGER, `share_ore` INTEGER, PRIMARY KEY(`template_id`, `user_id`))",
+ "fields": [
+ {
+ "fieldPath": "templateId",
+ "columnName": "template_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "userId",
+ "columnName": "user_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "shareBp",
+ "columnName": "share_bp",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "shareOre",
+ "columnName": "share_ore",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "template_id",
+ "user_id"
+ ]
+ }
+ },
+ {
+ "tableName": "expenses",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `account_id` TEXT NOT NULL, `category_id` TEXT NOT NULL, `amount_ore` INTEGER NOT NULL, `description` TEXT NOT NULL, `notes` TEXT NOT NULL, `expense_date` TEXT NOT NULL, `payer_user_id` TEXT NOT NULL, `split_mode` TEXT NOT NULL, `recurring_template_id` TEXT, `recurring_period` TEXT, `created_by_user_id` TEXT, `updated_by_user_id` TEXT, `created_at` TEXT NOT NULL, `updated_at` TEXT NOT NULL, `deleted_at` TEXT, `server_version` INTEGER, `pending` INTEGER NOT NULL, `pending_mutation_id` TEXT, `local_updated_at` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "accountId",
+ "columnName": "account_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "categoryId",
+ "columnName": "category_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "amountOre",
+ "columnName": "amount_ore",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "description",
+ "columnName": "description",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "notes",
+ "columnName": "notes",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "expenseDate",
+ "columnName": "expense_date",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "payerUserId",
+ "columnName": "payer_user_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "splitMode",
+ "columnName": "split_mode",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "recurringTemplateId",
+ "columnName": "recurring_template_id",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "recurringPeriod",
+ "columnName": "recurring_period",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "createdByUserId",
+ "columnName": "created_by_user_id",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "updatedByUserId",
+ "columnName": "updated_by_user_id",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "created_at",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updated_at",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "deletedAt",
+ "columnName": "deleted_at",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "serverVersion",
+ "columnName": "server_version",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "pending",
+ "columnName": "pending",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "pendingMutationId",
+ "columnName": "pending_mutation_id",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "localUpdatedAt",
+ "columnName": "local_updated_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_expenses_account_id",
+ "unique": false,
+ "columnNames": [
+ "account_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_expenses_account_id` ON `${TABLE_NAME}` (`account_id`)"
+ },
+ {
+ "name": "index_expenses_expense_date",
+ "unique": false,
+ "columnNames": [
+ "expense_date"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_expenses_expense_date` ON `${TABLE_NAME}` (`expense_date`)"
+ },
+ {
+ "name": "index_expenses_pending",
+ "unique": false,
+ "columnNames": [
+ "pending"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_expenses_pending` ON `${TABLE_NAME}` (`pending`)"
+ }
+ ]
+ },
+ {
+ "tableName": "expense_splits",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`expense_id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `share_ore` INTEGER NOT NULL, `share_bp` INTEGER, PRIMARY KEY(`expense_id`, `user_id`))",
+ "fields": [
+ {
+ "fieldPath": "expenseId",
+ "columnName": "expense_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "userId",
+ "columnName": "user_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "shareOre",
+ "columnName": "share_ore",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "shareBp",
+ "columnName": "share_bp",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "expense_id",
+ "user_id"
+ ]
+ }
+ },
+ {
+ "tableName": "settlements",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `from_user_id` TEXT NOT NULL, `to_user_id` TEXT NOT NULL, `amount_ore` INTEGER NOT NULL, `settlement_date` TEXT NOT NULL, `note` TEXT NOT NULL, `created_by_user_id` TEXT, `updated_by_user_id` TEXT, `created_at` TEXT NOT NULL, `updated_at` TEXT NOT NULL, `deleted_at` TEXT, `server_version` INTEGER, `pending` INTEGER NOT NULL, `pending_mutation_id` TEXT, `local_updated_at` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "fromUserId",
+ "columnName": "from_user_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "toUserId",
+ "columnName": "to_user_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "amountOre",
+ "columnName": "amount_ore",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "settlementDate",
+ "columnName": "settlement_date",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "note",
+ "columnName": "note",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdByUserId",
+ "columnName": "created_by_user_id",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "updatedByUserId",
+ "columnName": "updated_by_user_id",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "created_at",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updated_at",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "deletedAt",
+ "columnName": "deleted_at",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "serverVersion",
+ "columnName": "server_version",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "pending",
+ "columnName": "pending",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "pendingMutationId",
+ "columnName": "pending_mutation_id",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "localUpdatedAt",
+ "columnName": "local_updated_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_settlements_settlement_date",
+ "unique": false,
+ "columnNames": [
+ "settlement_date"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_settlements_settlement_date` ON `${TABLE_NAME}` (`settlement_date`)"
+ },
+ {
+ "name": "index_settlements_pending",
+ "unique": false,
+ "columnNames": [
+ "pending"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_settlements_pending` ON `${TABLE_NAME}` (`pending`)"
+ }
+ ]
+ },
+ {
+ "tableName": "settlement_allocations",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`settlement_id` TEXT NOT NULL, `account_id` TEXT NOT NULL, `amount_ore` INTEGER NOT NULL, PRIMARY KEY(`settlement_id`, `account_id`))",
+ "fields": [
+ {
+ "fieldPath": "settlementId",
+ "columnName": "settlement_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "accountId",
+ "columnName": "account_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "amountOre",
+ "columnName": "amount_ore",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "settlement_id",
+ "account_id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_settlement_allocations_account_id",
+ "unique": false,
+ "columnNames": [
+ "account_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_settlement_allocations_account_id` ON `${TABLE_NAME}` (`account_id`)"
+ }
+ ]
+ },
+ {
+ "tableName": "card_connections",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `provider_id` TEXT NOT NULL, `status` TEXT NOT NULL, `history_days` INTEGER NOT NULL, `valid_until` TEXT, `last_fetch_at` TEXT, `last_fetch_error` TEXT, `created_at` TEXT NOT NULL, `updated_at` TEXT NOT NULL, `card_version` INTEGER NOT NULL, `local_updated_at` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "providerId",
+ "columnName": "provider_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "status",
+ "columnName": "status",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "historyDays",
+ "columnName": "history_days",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "validUntil",
+ "columnName": "valid_until",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "lastFetchAt",
+ "columnName": "last_fetch_at",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "lastFetchError",
+ "columnName": "last_fetch_error",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "created_at",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updated_at",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "cardVersion",
+ "columnName": "card_version",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "localUpdatedAt",
+ "columnName": "local_updated_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "card_purchases",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `connection_id` TEXT NOT NULL, `status` TEXT NOT NULL, `direction` TEXT NOT NULL, `purchase_date` TEXT NOT NULL, `booking_date` TEXT, `amount_ore` INTEGER, `original_amount` TEXT, `original_currency` TEXT, `merchant` TEXT NOT NULL, `expense_id` TEXT, `ignored` INTEGER NOT NULL, `created_at` TEXT NOT NULL, `updated_at` TEXT NOT NULL, `deleted_at` TEXT, `card_version` INTEGER NOT NULL, `pending` INTEGER NOT NULL, `local_updated_at` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "connectionId",
+ "columnName": "connection_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "status",
+ "columnName": "status",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "direction",
+ "columnName": "direction",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "purchaseDate",
+ "columnName": "purchase_date",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "bookingDate",
+ "columnName": "booking_date",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "amountOre",
+ "columnName": "amount_ore",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "originalAmount",
+ "columnName": "original_amount",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "originalCurrency",
+ "columnName": "original_currency",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "merchant",
+ "columnName": "merchant",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "expenseId",
+ "columnName": "expense_id",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "ignored",
+ "columnName": "ignored",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "created_at",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updated_at",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "deletedAt",
+ "columnName": "deleted_at",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "cardVersion",
+ "columnName": "card_version",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "pending",
+ "columnName": "pending",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "localUpdatedAt",
+ "columnName": "local_updated_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_card_purchases_purchase_date",
+ "unique": false,
+ "columnNames": [
+ "purchase_date"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_card_purchases_purchase_date` ON `${TABLE_NAME}` (`purchase_date`)"
+ },
+ {
+ "name": "index_card_purchases_expense_id",
+ "unique": false,
+ "columnNames": [
+ "expense_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_card_purchases_expense_id` ON `${TABLE_NAME}` (`expense_id`)"
+ },
+ {
+ "name": "index_card_purchases_pending",
+ "unique": false,
+ "columnNames": [
+ "pending"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_card_purchases_pending` ON `${TABLE_NAME}` (`pending`)"
+ }
+ ]
+ },
+ {
+ "tableName": "audit_log",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `entity_type` TEXT NOT NULL, `entity_id` TEXT NOT NULL, `action` TEXT NOT NULL, `source` TEXT NOT NULL, `actor_user_id` TEXT NOT NULL, `client_at` TEXT NOT NULL, `server_at` TEXT NOT NULL, `changes` TEXT NOT NULL, `server_version` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "entityType",
+ "columnName": "entity_type",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "entityId",
+ "columnName": "entity_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "action",
+ "columnName": "action",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "source",
+ "columnName": "source",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "actorUserId",
+ "columnName": "actor_user_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "clientAt",
+ "columnName": "client_at",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "serverAt",
+ "columnName": "server_at",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "changes",
+ "columnName": "changes",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "serverVersion",
+ "columnName": "server_version",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_audit_log_entity_type_entity_id",
+ "unique": false,
+ "columnNames": [
+ "entity_type",
+ "entity_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_audit_log_entity_type_entity_id` ON `${TABLE_NAME}` (`entity_type`, `entity_id`)"
+ }
+ ]
+ },
+ {
+ "tableName": "sync_state",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `cursor` INTEGER NOT NULL, `epoch` TEXT, `last_success_at` TEXT, `last_attempt_at` TEXT, `last_error_code` TEXT, `clock_skew` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "cursor",
+ "columnName": "cursor",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "epoch",
+ "columnName": "epoch",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "lastSuccessAt",
+ "columnName": "last_success_at",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "lastAttemptAt",
+ "columnName": "last_attempt_at",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "lastErrorCode",
+ "columnName": "last_error_code",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "clockSkew",
+ "columnName": "clock_skew",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "card_sync_state",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `cursor` INTEGER NOT NULL, `last_success_at` TEXT, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "cursor",
+ "columnName": "cursor",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastSuccessAt",
+ "columnName": "last_success_at",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "sync_rejections",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mutation_id` TEXT NOT NULL, `entity_type` TEXT NOT NULL, `entity_id` TEXT NOT NULL, `reason` TEXT NOT NULL, `message` TEXT NOT NULL, `rejected_payload` TEXT NOT NULL, `server_current` TEXT, `rejected_at` TEXT NOT NULL, `dismissed` INTEGER NOT NULL, PRIMARY KEY(`mutation_id`))",
+ "fields": [
+ {
+ "fieldPath": "mutationId",
+ "columnName": "mutation_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "entityType",
+ "columnName": "entity_type",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "entityId",
+ "columnName": "entity_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "reason",
+ "columnName": "reason",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "message",
+ "columnName": "message",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "rejectedPayload",
+ "columnName": "rejected_payload",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "serverCurrent",
+ "columnName": "server_current",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "rejectedAt",
+ "columnName": "rejected_at",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "dismissed",
+ "columnName": "dismissed",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "mutation_id"
+ ]
+ }
+ }
+ ],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b6530112027f347b0de64eab87fdb779')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/api/ApiResult.kt b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/api/ApiResult.kt
index e81ea08..ced2a5e 100644
--- a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/api/ApiResult.kt
+++ b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/api/ApiResult.kt
@@ -27,6 +27,12 @@ object ErrorCodes {
const val INTERNAL = "internal"
const val UNAVAILABLE = "unavailable"
+ // Card purchases (§14.3.2, v0.35).
+ const val CARDS_UNAVAILABLE = "cards_unavailable"
+ const val CARD_CONSENT_EXPIRED = "card_consent_expired"
+ const val CARD_BANK_UNAVAILABLE = "card_bank_unavailable"
+ const val EXPENSE_ALREADY_LINKED = "expense_already_linked"
+
/** Not a server code: the network failure marker of `sync_state.last_error_code` and of REST calls (§9.9). */
const val NETWORK = "network"
diff --git a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/api/CardDtos.kt b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/api/CardDtos.kt
new file mode 100644
index 0000000..9800dec
--- /dev/null
+++ b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/api/CardDtos.kt
@@ -0,0 +1,105 @@
+package se.hagstedt.kvitt.data.api
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+// Card purchases, SPEC §14.3.3 wire objects `CardProvider`, `CardConnection`, `CardPurchase`, `CardAttempt` and the
+// bodies of E-36 to E-44 (§14.3.4, v0.35). Enum values stay strings and are mapped at the edge (an unknown value from a
+// newer server is not a crash). Only ever sent to the holder's own devices (CP-11); never logged (§8.7).
+
+/** `CardProvider` (E-36, D-160). */
+@Serializable
+data class CardProviderDto(
+ val id: String,
+ val name: String,
+ val issuer: String,
+ @SerialName("image_key") val imageKey: String,
+ val available: Boolean,
+)
+
+/** E-36 response. */
+@Serializable
+data class CardProvidersResponse(val providers: List)
+
+/** `CardConnection` (E-38, E-40, E-41, E-42, E-43; D-167). No session id, account uid or provider reference. */
+@Serializable
+data class CardConnectionDto(
+ val id: String,
+ @SerialName("provider_id") val providerId: String,
+ val status: String,
+ @SerialName("history_days") val historyDays: Int,
+ @SerialName("valid_until") val validUntil: String?,
+ @SerialName("last_fetch_at") val lastFetchAt: String?,
+ @SerialName("last_fetch_error") val lastFetchError: String?,
+ val fetching: Boolean,
+ @SerialName("created_at") val createdAt: String,
+ @SerialName("updated_at") val updatedAt: String,
+ @SerialName("card_version") val cardVersion: Long,
+)
+
+/** `CardPurchase` (E-40, E-44; D-167, D-168). A tombstone has `deleted_at` set and its content erased. */
+@Serializable
+data class CardPurchaseDto(
+ val id: String,
+ @SerialName("connection_id") val connectionId: String,
+ val status: String,
+ val direction: String,
+ @SerialName("purchase_date") val purchaseDate: String,
+ @SerialName("booking_date") val bookingDate: String?,
+ @SerialName("amount_ore") val amountOre: Long?,
+ @SerialName("original_amount") val originalAmount: String?,
+ @SerialName("original_currency") val originalCurrency: String?,
+ val merchant: String,
+ @SerialName("expense_id") val expenseId: String?,
+ val ignored: Boolean,
+ @SerialName("created_at") val createdAt: String,
+ @SerialName("updated_at") val updatedAt: String,
+ @SerialName("deleted_at") val deletedAt: String?,
+ @SerialName("card_version") val cardVersion: Long,
+)
+
+/** `CardAttempt` (E-37, E-38; D-162): `auth_url` only in E-37's answer, `connection` once connected. */
+@Serializable
+data class CardAttemptDto(
+ val id: String,
+ @SerialName("provider_id") val providerId: String,
+ val status: String,
+ val failure: String?,
+ @SerialName("auth_url") val authUrl: String?,
+ @SerialName("expires_at") val expiresAt: String,
+ val connection: CardConnectionDto?,
+)
+
+/** E-37 request; `client` is `android` from the app (CP-2). */
+@Serializable
+data class CardAuthRequest(
+ @SerialName("provider_id") val providerId: String,
+ @SerialName("history_days") val historyDays: Int,
+ val client: String,
+)
+
+/** E-40 response: the caller's card rows with `card_version > since`, tombstones included. */
+@Serializable
+data class CardSyncPage(
+ val since: Long,
+ @SerialName("next_since") val nextSince: Long,
+ @SerialName("has_more") val hasMore: Boolean,
+ @SerialName("server_time") val serverTime: String,
+ val connections: List,
+ val purchases: List,
+)
+
+/** E-41 request: only a value at most the current one (CP-3). */
+@Serializable
+data class PatchCardConnectionRequest(@SerialName("history_days") val historyDays: Int)
+
+/** E-42 response; `fetched` false when the 60-second rule answered or the fetch still runs (`connection.fetching`). */
+@Serializable
+data class CardRefreshResponse(val connection: CardConnectionDto, val fetched: Boolean)
+
+/** E-44 request: both keys required, `expense_id` null clears the link (CP-9). */
+@Serializable
+data class CardPurchaseUpdateRequest(
+ @SerialName("expense_id") val expenseId: String?,
+ val ignored: Boolean,
+)
diff --git a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/api/KvittApi.kt b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/api/KvittApi.kt
index ca5f418..e706897 100644
--- a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/api/KvittApi.kt
+++ b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/api/KvittApi.kt
@@ -15,6 +15,7 @@ import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpMethod
import io.ktor.http.contentType
+import io.ktor.http.encodeURLPathPart
import io.ktor.http.isSuccess
import io.ktor.serialization.JsonConvertException
import io.ktor.serialization.kotlinx.json.json
@@ -144,6 +145,22 @@ class KvittApi(
parameter("measure", measure)
}
+ // E-36 to E-44 (card purchases, v0.35): the caller's own card rows only (CP-11). E-39 is the bank's browser callback.
+ suspend fun cardProviders(): ApiResult = authed(HttpMethod.Get, "/cards/providers")
+ suspend fun cardAuth(request: CardAuthRequest): ApiResult = authed(HttpMethod.Post, "/cards/auth") { jsonBody(request) }
+ suspend fun cardAttempt(attemptId: String): ApiResult = authed(HttpMethod.Get, "/cards/auth/${segment(attemptId)}")
+ suspend fun cardSync(since: Long, limit: Int): ApiResult = authed(HttpMethod.Get, "/cards/sync") {
+ parameter("since", since)
+ parameter("limit", limit)
+ }
+ suspend fun patchCardConnection(id: String, request: PatchCardConnectionRequest): ApiResult =
+ authed(HttpMethod.Patch, "/cards/connections/${segment(id)}") { jsonBody(request) }
+ suspend fun refreshCardConnection(id: String): ApiResult = authed(HttpMethod.Post, "/cards/connections/${segment(id)}/refresh")
+ suspend fun disconnectCard(id: String, eraseHandled: Boolean): ApiResult =
+ authed(HttpMethod.Delete, "/cards/connections/${segment(id)}") { parameter("erase_handled", eraseHandled) }
+ suspend fun putCardPurchase(id: String, request: CardPurchaseUpdateRequest): ApiResult =
+ authed(HttpMethod.Put, "/cards/purchases/${segment(id)}") { jsonBody(request) }
+
private suspend inline fun authed(
method: HttpMethod,
path: String,
@@ -241,6 +258,9 @@ class KvittApi(
private fun url(t: ApiTarget, path: String): String = t.baseUrl.trimEnd('/') + path
+ /** An id as one path segment: the server's ids are UUIDs, anything else is percent-encoded. */
+ private fun segment(id: String): String = id.encodeURLPathPart()
+
private inline fun HttpRequestBuilder.jsonBody(body: B) {
contentType(ContentType.Application.Json)
setBody(body, typeInfo())
diff --git a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/cards/CardMappers.kt b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/cards/CardMappers.kt
new file mode 100644
index 0000000..4e9b76f
--- /dev/null
+++ b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/cards/CardMappers.kt
@@ -0,0 +1,87 @@
+package se.hagstedt.kvitt.data.cards
+
+import se.hagstedt.kvitt.data.api.CardConnectionDto
+import se.hagstedt.kvitt.data.api.CardProviderDto
+import se.hagstedt.kvitt.data.api.CardPurchaseDto
+import se.hagstedt.kvitt.data.db.CardConnectionEntity
+import se.hagstedt.kvitt.data.db.CardPurchaseEntity
+import se.hagstedt.kvitt.model.CardConnection
+import se.hagstedt.kvitt.model.CardConnectionId
+import se.hagstedt.kvitt.model.CardConnectionStatus
+import se.hagstedt.kvitt.model.CardDirection
+import se.hagstedt.kvitt.model.CardFetchError
+import se.hagstedt.kvitt.model.CardProvider
+import se.hagstedt.kvitt.model.CardPurchaseStatus
+
+// Card wire <-> Room <-> model. Wire enums are lowercase names (§14.2.4); an unknown value degrades gracefully.
+
+fun CardConnectionDto.toEntity(localUpdatedAt: Long) = CardConnectionEntity(
+ id = id,
+ providerId = providerId,
+ status = status,
+ historyDays = historyDays,
+ validUntil = validUntil,
+ lastFetchAt = lastFetchAt,
+ lastFetchError = lastFetchError,
+ createdAt = createdAt,
+ updatedAt = updatedAt,
+ cardVersion = cardVersion,
+ localUpdatedAt = localUpdatedAt,
+)
+
+/** A pulled or E-44 row: not pending. Tombstones are never stored (the caller deletes the row). */
+fun CardPurchaseDto.toEntity(localUpdatedAt: Long) = CardPurchaseEntity(
+ id = id,
+ connectionId = connectionId,
+ status = status,
+ direction = direction,
+ purchaseDate = purchaseDate,
+ bookingDate = bookingDate,
+ amountOre = amountOre,
+ originalAmount = originalAmount,
+ originalCurrency = originalCurrency,
+ merchant = merchant,
+ expenseId = expenseId,
+ ignored = ignored,
+ createdAt = createdAt,
+ updatedAt = updatedAt,
+ deletedAt = deletedAt,
+ cardVersion = cardVersion,
+ pending = false,
+ localUpdatedAt = localUpdatedAt,
+)
+
+fun CardProviderDto.toModel() = CardProvider(id = id, name = name, issuer = issuer, imageKey = imageKey, available = available)
+
+fun connectionStatusOf(wire: String): CardConnectionStatus =
+ CardConnectionStatus.entries.firstOrNull { it.name.lowercase() == wire } ?: CardConnectionStatus.DISCONNECTED
+
+fun purchaseStatusOf(wire: String): CardPurchaseStatus = if (wire == "pending") CardPurchaseStatus.PENDING else CardPurchaseStatus.BOOKED
+
+fun directionOf(wire: String): CardDirection = if (wire == "credit") CardDirection.CREDIT else CardDirection.DEBIT
+
+/** CP-14: null clears; an unknown value reads as [CardFetchError.FAILED] (§14.2.4). */
+fun fetchErrorOf(wire: String?): CardFetchError? =
+ wire?.let { w -> CardFetchError.entries.firstOrNull { it.name.lowercase() == w } ?: CardFetchError.FAILED }
+
+/** [expiresSoon] per CP-6: ACTIVE and `valid_until` within 14 days of [nowMillis]. */
+fun CardConnectionEntity.toModel(nowMillis: Long, parse: (String) -> Long?): CardConnection {
+ val state = connectionStatusOf(status)
+ val until = validUntil?.let(parse)
+ return CardConnection(
+ id = CardConnectionId(id),
+ providerId = providerId,
+ status = state,
+ historyDays = historyDays,
+ validUntil = validUntil,
+ lastFetchAt = lastFetchAt,
+ lastFetchError = fetchErrorOf(lastFetchError),
+ expiresSoon = state == CardConnectionStatus.ACTIVE && until != null && until - nowMillis <= EXPIRY_NOTICE_MILLIS,
+ )
+}
+
+fun CardConnectionDto.toModel(nowMillis: Long, parse: (String) -> Long?): CardConnection = toEntity(nowMillis).toModel(nowMillis, parse)
+
+/** CP-6: "From 14 days before it, S-23 and S-26 show the expiry notice". */
+const val EXPIRY_NOTICE_DAYS = 14
+private const val EXPIRY_NOTICE_MILLIS = EXPIRY_NOTICE_DAYS * 24L * 60 * 60 * 1000
diff --git a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/cards/CardSync.kt b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/cards/CardSync.kt
new file mode 100644
index 0000000..d887469
--- /dev/null
+++ b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/cards/CardSync.kt
@@ -0,0 +1,173 @@
+package se.hagstedt.kvitt.data.cards
+
+import androidx.room3.withWriteTransaction
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import se.hagstedt.kvitt.data.api.ApiResult
+import se.hagstedt.kvitt.data.api.CardConnectionDto
+import se.hagstedt.kvitt.data.api.CardPurchaseDto
+import se.hagstedt.kvitt.data.api.CardPurchaseUpdateRequest
+import se.hagstedt.kvitt.data.api.CardSyncPage
+import se.hagstedt.kvitt.data.api.ErrorCodes
+import se.hagstedt.kvitt.data.api.KvittApi
+import se.hagstedt.kvitt.data.db.CardPurchaseEntity
+import se.hagstedt.kvitt.data.db.CardSyncStateEntity
+import se.hagstedt.kvitt.data.db.KvittDatabase
+import se.hagstedt.kvitt.data.util.Clock
+import se.hagstedt.kvitt.data.util.nowTimestamp
+
+/** The outcome of one card cycle. Card errors never change the household sync's outcome (§14.4.1). */
+sealed interface CardCycle {
+ data object Success : CardCycle
+
+ /** E-40 answered 404 `not_found`: a server before schema 4; the card tables stay as they are (empty). */
+ data object NoSupport : CardCycle
+
+ /** [code]: a §14.3.2 code or `network`. */
+ data class Failed(val code: String) : CardCycle
+}
+
+/**
+ * The phone's card cycle (SPEC §14.2.4 `:data`, §14.2.1, CP-9): push every pending link, unlink and ignore change with
+ * E-44, then pull E-40 pages until `has_more` is false. It runs after every household sync cycle (so an expense a link
+ * names is on the server first) and after `CardRepository.refresh`; one cycle at a time ([mutex]). Card content is never
+ * logged (§8.7, D-163).
+ */
+class CardSync(private val db: KvittDatabase, private val api: KvittApi, private val clock: Clock) {
+ private val mutex = Mutex()
+
+ suspend fun cycle(): CardCycle = mutex.withLock {
+ val pushError = push()
+ if (pushError != null) return@withLock CardCycle.Failed(pushError)
+ pull()
+ }
+
+ /**
+ * Drops the card cache (§14.2.4): every connection and every purchase that is not pending, cursor 0. Runs inside the
+ * caller's transaction for a household epoch change, and here for `cursor_ahead`.
+ */
+ suspend fun dropCache() {
+ db.cards().deleteSettledPurchases()
+ db.cards().deleteConnections()
+ db.cards().upsertSyncState(INITIAL_STATE)
+ }
+
+ // E-44 for each pending row (§14.2.1): 200 overwrites and clears `pending`; `not_found` with field `expense_id`
+ // (the expense is not on the server yet) stays pending; `not_found` for the purchase deletes it;
+ // `expense_already_linked` and `validation_failed` take the server's state at the next pull.
+
+ /** The error that ends the cycle (network, 5xx, rate limit, unauthorised), or null. */
+ private suspend fun push(): String? {
+ for (row in db.cards().pendingPurchases()) {
+ val expenseId = row.expenseId?.let { id -> id.takeIf { linkable(it) } ?: clearDeadLink(row) }
+ val sent = db.cards().purchase(row.id) ?: continue
+ val request = CardPurchaseUpdateRequest(expenseId, sent.ignored)
+ when (val result = api.putCardPurchase(row.id, request)) {
+ is ApiResult.Ok -> accept(sent, result.value)
+ is ApiResult.NetworkError -> return ErrorCodes.NETWORK
+ ApiResult.NotPaired -> return ErrorCodes.UNAUTHORIZED
+ is ApiResult.Failure -> when {
+ result.status == NOT_FOUND && result.field == FIELD_EXPENSE_ID -> Unit
+ result.status == NOT_FOUND -> db.cards().deletePurchase(row.id)
+ result.code == ErrorCodes.EXPENSE_ALREADY_LINKED || result.code == ErrorCodes.VALIDATION_FAILED -> takeServerState(sent)
+ result.status >= SERVER_ERROR || result.code == ErrorCodes.RATE_LIMITED || result.status == UNAUTHORIZED -> return result.code
+ else -> Unit
+ }
+ }
+ }
+ return null
+ }
+
+ /** A link may be sent while its expense is on the phone and not deleted; otherwise it is cleared first (CP-9). */
+ private suspend fun linkable(expenseId: String): Boolean {
+ val expense = db.expenses().get(expenseId) ?: return false
+ return expense.deletedAt == null
+ }
+
+ private suspend fun clearDeadLink(row: CardPurchaseEntity): String? {
+ db.cards().upsertPurchase(row.copy(expenseId = null, localUpdatedAt = clock.nowMillis()))
+ return null
+ }
+
+ /** 200: the answer replaces the row unless it was changed again during the request (then it stays pending). */
+ private suspend fun accept(sent: CardPurchaseEntity, dto: CardPurchaseDto) = db.withWriteTransaction {
+ val now = db.cards().purchase(sent.id) ?: return@withWriteTransaction
+ if (now.localUpdatedAt == sent.localUpdatedAt) {
+ db.cards().upsertPurchase(dto.toEntity(clock.nowMillis()))
+ } else {
+ db.cards().upsertPurchase(now.copy(cardVersion = dto.cardVersion))
+ }
+ }
+
+ /**
+ * The server refused the change for a reason that will not go away (`expense_already_linked`, `validation_failed`):
+ * the row stops being pending and is pulled again. Its version is reset and the cursor moved before it, because the
+ * server's row may not have changed and would otherwise never be sent again (proposed decision, K2 report).
+ */
+ private suspend fun takeServerState(row: CardPurchaseEntity) = db.withWriteTransaction {
+ db.cards().upsertPurchase(row.copy(pending = false, cardVersion = 0, localUpdatedAt = clock.nowMillis()))
+ val state = state()
+ val before = (row.cardVersion - 1).coerceAtLeast(0)
+ if (state.cursor > before) db.cards().upsertSyncState(state.copy(cursor = before))
+ }
+
+ // E-40 pull (§14.2.4): like §14.4.2, a pending local row is skipped and a tombstone deletes the row.
+
+ private suspend fun pull(): CardCycle {
+ var restarted = false
+ while (true) {
+ val state = state()
+ when (val result = api.cardSync(state.cursor, PULL_LIMIT)) {
+ is ApiResult.Ok -> {
+ val page = result.value
+ db.withWriteTransaction {
+ apply(page)
+ db.cards().upsertSyncState(state().copy(cursor = page.nextSince, lastSuccessAt = clock.nowTimestamp()))
+ }
+ if (!page.hasMore) return CardCycle.Success
+ }
+ is ApiResult.NetworkError -> return CardCycle.Failed(ErrorCodes.NETWORK)
+ ApiResult.NotPaired -> return CardCycle.Failed(ErrorCodes.UNAUTHORIZED)
+ is ApiResult.Failure -> when {
+ result.status == NOT_FOUND -> return CardCycle.NoSupport
+ result.code == ErrorCodes.CURSOR_AHEAD && !restarted -> {
+ restarted = true
+ db.withWriteTransaction { dropCache() }
+ }
+ else -> return CardCycle.Failed(result.code)
+ }
+ }
+ }
+ }
+
+ private suspend fun apply(page: CardSyncPage) {
+ val now = clock.nowMillis()
+ for (dto in page.connections) writeConnectionIfNewer(dto, now)
+ for (dto in page.purchases) {
+ val local = db.cards().purchase(dto.id)
+ when {
+ dto.deletedAt != null -> if (local != null) db.cards().deletePurchase(dto.id)
+ local != null && local.pending -> Unit
+ local == null || local.cardVersion < dto.cardVersion -> db.cards().upsertPurchase(dto.toEntity(now))
+ }
+ }
+ }
+
+ /** A connection from any card answer (E-38, E-40 to E-43), written when it is newer than the phone's. */
+ suspend fun writeConnectionIfNewer(dto: CardConnectionDto, now: Long = clock.nowMillis()) {
+ val local = db.cards().connection(dto.id)
+ if (local == null || local.cardVersion <= dto.cardVersion) db.cards().upsertConnection(dto.toEntity(now))
+ }
+
+ private suspend fun state(): CardSyncStateEntity = db.cards().syncState() ?: INITIAL_STATE
+
+ companion object {
+ /** Pages of 500, as the household pull (§14.4.9; E-40's default). */
+ const val PULL_LIMIT = 500
+ val INITIAL_STATE = CardSyncStateEntity(id = 1, cursor = 0, lastSuccessAt = null)
+ private const val NOT_FOUND = 404
+ private const val UNAUTHORIZED = 401
+ private const val SERVER_ERROR = 500
+ private const val FIELD_EXPENSE_ID = "expense_id"
+ }
+}
diff --git a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/cards/CardViews.kt b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/cards/CardViews.kt
new file mode 100644
index 0000000..f2fff6d
--- /dev/null
+++ b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/cards/CardViews.kt
@@ -0,0 +1,147 @@
+package se.hagstedt.kvitt.data.cards
+
+import kotlinx.datetime.LocalDate
+import se.hagstedt.kvitt.data.db.CardConnectionEntity
+import se.hagstedt.kvitt.data.db.CardPurchaseEntity
+import se.hagstedt.kvitt.data.repo.ExpenseRecord
+import se.hagstedt.kvitt.data.repo.Snapshot
+import se.hagstedt.kvitt.domain.CARD_MATCH_MAX
+import se.hagstedt.kvitt.domain.CARD_MATCH_WINDOW_DAYS
+import se.hagstedt.kvitt.domain.MatchableExpense
+import se.hagstedt.kvitt.domain.MatchablePurchase
+import se.hagstedt.kvitt.domain.matchCardPurchase
+import se.hagstedt.kvitt.model.CardConnectionId
+import se.hagstedt.kvitt.model.CardConnectionStatus
+import se.hagstedt.kvitt.model.CardDay
+import se.hagstedt.kvitt.model.CardDirection
+import se.hagstedt.kvitt.model.CardFilter
+import se.hagstedt.kvitt.model.CardProvider
+import se.hagstedt.kvitt.model.CardPurchase
+import se.hagstedt.kvitt.model.CardPurchaseDetail
+import se.hagstedt.kvitt.model.CardPurchaseId
+import se.hagstedt.kvitt.model.CardPurchaseRow
+import se.hagstedt.kvitt.model.CardsState
+import se.hagstedt.kvitt.model.ExpenseMatch
+import se.hagstedt.kvitt.model.LinkedExpense
+import kotlin.math.abs
+
+/**
+ * The card `*State`s of §14.2.4 from one consistent read of Room (the card tables and the household [snapshot]), with
+ * `:domain`'s [matchCardPurchase] for the suggested matches (CP-10). Pure; the repository supplies the clock.
+ */
+internal class CardView(
+ private val snapshot: Snapshot,
+ private val connections: List,
+ purchases: List,
+ private val catalogue: List,
+ private val nowMillis: Long,
+ private val parseTimestamp: (String) -> Long?,
+) {
+ private val live = purchases.filter { it.deletedAt == null }
+ private val newestFirst = live.sortedWith(compareByDescending { it.purchaseDate }.thenByDescending { it.createdAt }.thenByDescending { it.id })
+ private val linkedBy: Map> = live.filter { it.expenseId != null }.groupBy({ it.expenseId.orEmpty() }, { it.id })
+ private val expenses: List = snapshot.liveExpenses
+
+ /** The connection S-23 shows: ACTIVE or EXPIRED (one per provider, CP-1); null is the empty state. */
+ val connection: CardConnectionEntity? = connections.filter { connectionStatusOf(it.status) != CardConnectionStatus.DISCONNECTED }.maxByOrNull { it.updatedAt }
+
+ val hasConnection: Boolean get() = connection != null
+
+ /** The provider of [connection], else the catalogue's first entry; an unknown id gets a nameless entry with its id as image key. */
+ val provider: CardProvider? = connection?.let { c -> catalogue.firstOrNull { it.id == c.providerId } ?: CardProvider(c.providerId, "", "", c.providerId, true) }
+ ?: catalogue.firstOrNull()
+
+ fun state(filter: CardFilter, fetching: Boolean): CardsState {
+ val current = connection ?: return CardsState(provider, null, filter, CardFilter.entries.associateWith { 0 }, emptyList(), fetching)
+ val rows = newestFirst.filter { it.inFilter(filter) }.map { row(it, withMatches = filter == CardFilter.TO_HANDLE) }
+ val days = rows.groupBy { it.purchase.date }.map { (date, inDay) -> CardDay(date, inDay) }
+ return CardsState(
+ provider = provider,
+ connection = current.toModel(nowMillis, parseTimestamp),
+ filter = filter,
+ counts = CardFilter.entries.associateWith { f -> live.count { it.inFilter(f) } },
+ days = days,
+ fetching = fetching,
+ )
+ }
+
+ fun detail(id: CardPurchaseId): CardPurchaseDetail? {
+ val p = live.firstOrNull { it.id == id.value } ?: return null
+ return CardPurchaseDetail(row(p, withMatches = p.toHandle()), if (p.toHandle()) others(p) else emptyList())
+ }
+
+ /** S-27: debit, neither linked nor ignored, newest first (foreign currency included). */
+ fun toHandle(): List = newestFirst.filter { it.toHandle() }.map { model(it) }
+
+ fun forExpense(expenseId: String): CardPurchase? = live.firstOrNull { it.expenseId == expenseId }?.let(::model)
+
+ fun purchase(id: String): CardPurchaseEntity? = live.firstOrNull { it.id == id }
+
+ private fun row(p: CardPurchaseEntity, withMatches: Boolean) = CardPurchaseRow(model(p), if (withMatches) matches(p) else emptyList())
+
+ fun model(p: CardPurchaseEntity): CardPurchase = CardPurchase(
+ id = CardPurchaseId(p.id),
+ connectionId = CardConnectionId(p.connectionId),
+ status = purchaseStatusOf(p.status),
+ direction = directionOf(p.direction),
+ date = p.purchaseDate,
+ bookingDate = p.bookingDate,
+ amountOre = p.amountOre,
+ originalAmount = p.originalAmount,
+ originalCurrency = p.originalCurrency,
+ merchant = p.merchant,
+ linked = p.expenseId?.let { linkedExpense(it) },
+ ignored = p.ignored,
+ pendingLocal = p.pending,
+ )
+
+ private fun linkedExpense(id: String): LinkedExpense? {
+ val e = snapshot.expense(id)?.takeIf { !it.deleted } ?: return null
+ return LinkedExpense(e.id, e.row.description, snapshot.account(e.row.accountId)?.name.orEmpty())
+ }
+
+ /** CP-10 through `:domain`: same amount, ±3 days, not linked from another purchase of the viewer, at most 3. */
+ private fun matches(p: CardPurchaseEntity): List {
+ val date = parseDate(p.purchaseDate) ?: return emptyList()
+ val candidates = expenses.mapNotNull { e -> parseDate(e.row.expenseDate)?.let { d -> MatchableExpense(e.row.id, e.row.amountOre, d, e.row.createdAt, linkedByOther(e.row.id, p.id)) } }
+ val ids = matchCardPurchase(MatchablePurchase(p.amountOre, date, p.direction), candidates, CARD_MATCH_WINDOW_DAYS, CARD_MATCH_MAX)
+ return ids.mapNotNull { id -> snapshot.expense(id)?.let(::expenseMatch) }
+ }
+
+ /** "Välj en annan utgift" (CP-10): not linked from another purchase of the viewer, within 14 days, any amount, newest first. */
+ private fun others(p: CardPurchaseEntity): List {
+ val day = parseDate(p.purchaseDate)?.toEpochDays() ?: return emptyList()
+ return expenses
+ .filter { e -> !linkedByOther(e.row.id, p.id) && parseDate(e.row.expenseDate)?.let { abs(it.toEpochDays() - day) <= OTHERS_WINDOW_DAYS } == true }
+ .sortedWith(compareByDescending { it.row.expenseDate }.thenByDescending { it.row.createdAt }.thenBy { it.row.id })
+ .mapNotNull(::expenseMatch)
+ }
+
+ private fun expenseMatch(e: ExpenseRecord): ExpenseMatch? {
+ val payer = snapshot.user(e.row.payerUserId) ?: return null
+ return ExpenseMatch(e.id, e.row.description, snapshot.account(e.row.accountId)?.name.orEmpty(), payer, e.row.expenseDate, e.row.amountOre)
+ }
+
+ private fun linkedByOther(expenseId: String, purchaseId: String): Boolean = linkedBy[expenseId].orEmpty().any { it != purchaseId }
+
+ companion object {
+ /** CP-10: "Välj en annan utgift" lists expenses dated within 14 days of the purchase. */
+ const val OTHERS_WINDOW_DAYS = 14
+ }
+}
+
+/** CP-8 and §13.3 S-23: "Att hantera" is a debit purchase neither linked nor ignored; a credit shows only under "Alla". */
+internal fun CardPurchaseEntity.toHandle(): Boolean = directionOf(direction) == CardDirection.DEBIT && expenseId == null && !ignored
+
+internal fun CardPurchaseEntity.inFilter(filter: CardFilter): Boolean = when (filter) {
+ CardFilter.TO_HANDLE -> toHandle()
+ CardFilter.LINKED -> expenseId != null
+ CardFilter.IGNORED -> ignored
+ CardFilter.ALL -> true
+}
+
+private fun parseDate(iso: String): LocalDate? = try {
+ LocalDate.parse(iso)
+} catch (e: IllegalArgumentException) {
+ null
+}
diff --git a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/db/Daos.kt b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/db/Daos.kt
index 0b238d8..6d481b8 100644
--- a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/db/Daos.kt
+++ b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/db/Daos.kt
@@ -250,5 +250,60 @@ interface RejectionDao {
suspend fun dismiss(mutationId: String)
}
+/**
+ * The card tables (v2, D-173): the signed-in user's own connections and purchases and the card cursor (§14.2.1, §14.2.2).
+ * Tombstones are never stored (a pulled tombstone deletes the row), so every purchase row is live.
+ */
+@Dao
+interface CardDao {
+ @Query("SELECT * FROM card_connections ORDER BY created_at, id")
+ fun observeConnections(): Flow>
+
+ @Query("SELECT * FROM card_connections ORDER BY created_at, id")
+ suspend fun connections(): List
+
+ @Query("SELECT * FROM card_connections WHERE id = :id")
+ suspend fun connection(id: String): CardConnectionEntity?
+
+ @Upsert
+ suspend fun upsertConnection(connection: CardConnectionEntity)
+
+ @Query("SELECT * FROM card_purchases WHERE deleted_at IS NULL")
+ fun observePurchases(): Flow>
+
+ @Query("SELECT * FROM card_purchases WHERE deleted_at IS NULL")
+ suspend fun purchases(): List
+
+ @Query("SELECT * FROM card_purchases WHERE id = :id")
+ suspend fun purchase(id: String): CardPurchaseEntity?
+
+ @Upsert
+ suspend fun upsertPurchase(purchase: CardPurchaseEntity)
+
+ @Query("DELETE FROM card_purchases WHERE id = :id")
+ suspend fun deletePurchase(id: String)
+
+ /** The E-44 queue (CP-9): oldest local change first. */
+ @Query("SELECT * FROM card_purchases WHERE pending = 1 ORDER BY local_updated_at, id")
+ suspend fun pendingPurchases(): List
+
+ /** CP-9: a local expense delete clears the local link to it; a pending change stays pending. */
+ @Query("UPDATE card_purchases SET expense_id = NULL, local_updated_at = :localUpdatedAt WHERE expense_id = :expenseId")
+ suspend fun clearLinksTo(expenseId: String, localUpdatedAt: Long): Int
+
+ /** §14.2.4: `cursor_ahead` or a household epoch change drops the card cache; pending purchases are kept. */
+ @Query("DELETE FROM card_purchases WHERE pending = 0")
+ suspend fun deleteSettledPurchases()
+
+ @Query("DELETE FROM card_connections")
+ suspend fun deleteConnections()
+
+ @Query("SELECT * FROM card_sync_state WHERE id = 1")
+ suspend fun syncState(): CardSyncStateEntity?
+
+ @Upsert
+ suspend fun upsertSyncState(state: CardSyncStateEntity)
+}
+
/** One row of [ExpenseDao.observeCategoryUse]: how many non-deleted expenses of [accountId] use [categoryId]. */
data class CategoryUse(val accountId: String, val categoryId: String, val uses: Int)
diff --git a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/db/Entities.kt b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/db/Entities.kt
index 5c8a648..a96d379 100644
--- a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/db/Entities.kt
+++ b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/db/Entities.kt
@@ -150,6 +150,51 @@ data class SettlementAllocationEntity(
@ColumnInfo(name = "amount_ore") val amountOre: Long,
)
+/**
+ * The signed-in user's card connections (v2, D-173, §14.2.1): the server's columns without `user_id`, `session_id`,
+ * `account_uid`, `last_user_fetch_at` and `background_fetches`. `card_version` is the row's E-40 version.
+ */
+@Entity(tableName = "card_connections")
+data class CardConnectionEntity(
+ @PrimaryKey @ColumnInfo(name = "id") val id: String,
+ @ColumnInfo(name = "provider_id") val providerId: String,
+ @ColumnInfo(name = "status") val status: String,
+ @ColumnInfo(name = "history_days") val historyDays: Int,
+ @ColumnInfo(name = "valid_until") val validUntil: String?,
+ @ColumnInfo(name = "last_fetch_at") val lastFetchAt: String?,
+ @ColumnInfo(name = "last_fetch_error") val lastFetchError: String?,
+ @ColumnInfo(name = "created_at") val createdAt: String,
+ @ColumnInfo(name = "updated_at") val updatedAt: String,
+ @ColumnInfo(name = "card_version") val cardVersion: Long,
+ @ColumnInfo(name = "local_updated_at") val localUpdatedAt: Long,
+)
+
+/**
+ * The signed-in user's card purchases (v2, D-173, §14.2.1), without `provider_ref`. Local `pending`: a link, unlink or
+ * ignore change not yet accepted by E-44 (CP-9); a card pull skips a pending row.
+ */
+@Entity(tableName = "card_purchases", indices = [Index("purchase_date"), Index("expense_id"), Index("pending")])
+data class CardPurchaseEntity(
+ @PrimaryKey @ColumnInfo(name = "id") val id: String,
+ @ColumnInfo(name = "connection_id") val connectionId: String,
+ @ColumnInfo(name = "status") val status: String,
+ @ColumnInfo(name = "direction") val direction: String,
+ @ColumnInfo(name = "purchase_date") val purchaseDate: String,
+ @ColumnInfo(name = "booking_date") val bookingDate: String?,
+ @ColumnInfo(name = "amount_ore") val amountOre: Long?,
+ @ColumnInfo(name = "original_amount") val originalAmount: String?,
+ @ColumnInfo(name = "original_currency") val originalCurrency: String?,
+ @ColumnInfo(name = "merchant") val merchant: String,
+ @ColumnInfo(name = "expense_id") val expenseId: String?,
+ @ColumnInfo(name = "ignored") val ignored: Boolean,
+ @ColumnInfo(name = "created_at") val createdAt: String,
+ @ColumnInfo(name = "updated_at") val updatedAt: String,
+ @ColumnInfo(name = "deleted_at") val deletedAt: String?,
+ @ColumnInfo(name = "card_version") val cardVersion: Long,
+ @ColumnInfo(name = "pending") val pending: Boolean,
+ @ColumnInfo(name = "local_updated_at") val localUpdatedAt: Long,
+)
+
@Entity(tableName = "audit_log", indices = [Index("entity_type", "entity_id")])
data class AuditEntryEntity(
@PrimaryKey @ColumnInfo(name = "id") val id: String,
@@ -177,6 +222,14 @@ data class SyncStateEntity(
@ColumnInfo(name = "clock_skew") val clockSkew: Boolean,
)
+/** The card cycle's cursor (v2, D-173, §14.2.2): the last applied `next_since` of E-40, 0 initially; one row, `id` 1. */
+@Entity(tableName = "card_sync_state")
+data class CardSyncStateEntity(
+ @PrimaryKey @ColumnInfo(name = "id") val id: Int,
+ @ColumnInfo(name = "cursor") val cursor: Long,
+ @ColumnInfo(name = "last_success_at") val lastSuccessAt: String?,
+)
+
@Entity(tableName = "sync_rejections")
data class SyncRejectionEntity(
@PrimaryKey @ColumnInfo(name = "mutation_id") val mutationId: String,
diff --git a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/db/KvittDatabase.kt b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/db/KvittDatabase.kt
index 613a003..9900b9d 100644
--- a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/db/KvittDatabase.kt
+++ b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/db/KvittDatabase.kt
@@ -5,12 +5,15 @@ import androidx.room3.Database
import androidx.room3.Room
import androidx.room3.RoomDatabase
import androidx.room3.migration.Migration
+import androidx.sqlite.SQLiteConnection
import androidx.sqlite.SQLiteDriver
+import androidx.sqlite.execSQL
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import kotlinx.coroutines.Dispatchers
/**
- * The phone's database (SPEC §14.2): file `kvitt.db`, version 1, schema exported to `android/data/schemas/`.
+ * The phone's database (SPEC §14.2): file `kvitt.db`, version 2 (the card tables, D-173), schemas exported to
+ * `android/data/schemas/`.
* Every later version adds a migration to [KvittMigrations.ALL] and its test in `MigrationTest` (§14.2.3).
*/
@Database(
@@ -25,8 +28,11 @@ import kotlinx.coroutines.Dispatchers
ExpenseSplitEntity::class,
SettlementEntity::class,
SettlementAllocationEntity::class,
+ CardConnectionEntity::class,
+ CardPurchaseEntity::class,
AuditEntryEntity::class,
SyncStateEntity::class,
+ CardSyncStateEntity::class,
SyncRejectionEntity::class,
],
version = KvittDatabase.VERSION,
@@ -42,10 +48,11 @@ abstract class KvittDatabase : RoomDatabase() {
abstract fun audit(): AuditDao
abstract fun syncState(): SyncStateDao
abstract fun rejections(): RejectionDao
+ abstract fun cards(): CardDao
companion object {
const val FILE_NAME = "kvitt.db"
- const val VERSION = 1
+ const val VERSION = 2
/**
* The app's database. Production uses the bundled SQLite driver (Room 3 with sqlite-bundled);
@@ -60,7 +67,35 @@ abstract class KvittDatabase : RoomDatabase() {
}
}
-/** Every Room migration, in version order. Version 1 has none (§14.2.3). */
+/** Every Room migration, in version order (§14.2.3). */
object KvittMigrations {
- val ALL: List = emptyList()
+ /**
+ * Version 2 (D-173, K2): the card tables `card_connections`, `card_purchases` and `card_sync_state` (with its row
+ * `id` 1, `cursor` 0); no existing table changes. The statements are Room's own `createSql` of the exported `2.json`.
+ */
+ val MIGRATION_1_2: Migration = object : Migration(1, 2) {
+ override suspend fun migrate(connection: SQLiteConnection) {
+ connection.execSQL(
+ "CREATE TABLE IF NOT EXISTS `card_connections` (`id` TEXT NOT NULL, `provider_id` TEXT NOT NULL, `status` TEXT NOT NULL, " +
+ "`history_days` INTEGER NOT NULL, `valid_until` TEXT, `last_fetch_at` TEXT, `last_fetch_error` TEXT, `created_at` TEXT NOT NULL, " +
+ "`updated_at` TEXT NOT NULL, `card_version` INTEGER NOT NULL, `local_updated_at` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ )
+ connection.execSQL(
+ "CREATE TABLE IF NOT EXISTS `card_purchases` (`id` TEXT NOT NULL, `connection_id` TEXT NOT NULL, `status` TEXT NOT NULL, " +
+ "`direction` TEXT NOT NULL, `purchase_date` TEXT NOT NULL, `booking_date` TEXT, `amount_ore` INTEGER, `original_amount` TEXT, " +
+ "`original_currency` TEXT, `merchant` TEXT NOT NULL, `expense_id` TEXT, `ignored` INTEGER NOT NULL, `created_at` TEXT NOT NULL, " +
+ "`updated_at` TEXT NOT NULL, `deleted_at` TEXT, `card_version` INTEGER NOT NULL, `pending` INTEGER NOT NULL, " +
+ "`local_updated_at` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ )
+ connection.execSQL("CREATE INDEX IF NOT EXISTS `index_card_purchases_purchase_date` ON `card_purchases` (`purchase_date`)")
+ connection.execSQL("CREATE INDEX IF NOT EXISTS `index_card_purchases_expense_id` ON `card_purchases` (`expense_id`)")
+ connection.execSQL("CREATE INDEX IF NOT EXISTS `index_card_purchases_pending` ON `card_purchases` (`pending`)")
+ connection.execSQL(
+ "CREATE TABLE IF NOT EXISTS `card_sync_state` (`id` INTEGER NOT NULL, `cursor` INTEGER NOT NULL, `last_success_at` TEXT, PRIMARY KEY(`id`))",
+ )
+ connection.execSQL("INSERT OR IGNORE INTO `card_sync_state` (`id`, `cursor`, `last_success_at`) VALUES (1, 0, NULL)")
+ }
+ }
+
+ val ALL: List = listOf(MIGRATION_1_2)
}
diff --git a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/di/DataModule.kt b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/di/DataModule.kt
index 088f963..b1e7ea1 100644
--- a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/di/DataModule.kt
+++ b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/di/DataModule.kt
@@ -41,6 +41,7 @@ import se.hagstedt.kvitt.data.db.KvittDatabase
import se.hagstedt.kvitt.data.repo.AboutRepositoryImpl
import se.hagstedt.kvitt.data.repo.AccountRepositoryImpl
import se.hagstedt.kvitt.data.repo.AnalyticsRepositoryImpl
+import se.hagstedt.kvitt.data.repo.CardRepositoryImpl
import se.hagstedt.kvitt.data.repo.CategoryKeywords
import se.hagstedt.kvitt.data.repo.CategoryRepositoryImpl
import se.hagstedt.kvitt.data.repo.DeviceRepositoryImpl
@@ -71,6 +72,7 @@ import se.hagstedt.kvitt.data.util.UuidV7Generator
import se.hagstedt.kvitt.model.AboutRepository
import se.hagstedt.kvitt.model.AccountRepository
import se.hagstedt.kvitt.model.AnalyticsRepository
+import se.hagstedt.kvitt.model.CardRepository
import se.hagstedt.kvitt.model.CategoryRepository
import se.hagstedt.kvitt.model.ConnectivityRepository
import se.hagstedt.kvitt.model.DeviceRepository
@@ -293,6 +295,19 @@ object DataModule {
fun sync(local: LocalData, db: KvittDatabase, summaries: SyncSummaries, engine: SyncEngine, work: SyncWork): SyncRepositoryImpl =
SyncRepositoryImpl(local, db, summaries, engine, work)
+ @Provides @Singleton
+ fun cards(
+ db: KvittDatabase,
+ local: LocalData,
+ api: KvittApi,
+ gate: OnlineGate,
+ engine: SyncEngine,
+ prefs: PhonePrefs,
+ clock: Clock,
+ triggers: SyncTriggers,
+ @DataScope scope: CoroutineScope,
+ ): CardRepositoryImpl = CardRepositoryImpl(db, local, api, gate, engine.cards, prefs, clock, triggers, scope)
+
@Provides @Singleton
fun about(credentials: CredentialStore, session: SessionManager, api: KvittApi, gate: OnlineGate, build: AppBuildInfo): AboutRepositoryImpl =
AboutRepositoryImpl(credentials, session.serverVersion, api, gate, build.versionName, build.versionCode, build.releaseServerUrl)
@@ -318,6 +333,7 @@ abstract class DataBindingsModule {
@Binds abstract fun update(impl: UpdateRepositoryImpl): UpdateRepository
@Binds abstract fun about(impl: AboutRepositoryImpl): AboutRepository
@Binds abstract fun connectivity(impl: ConnectivityRepositoryImpl): ConnectivityRepository
+ @Binds abstract fun cards(impl: CardRepositoryImpl): CardRepository
}
private fun Flow.stateInBaseUrl(scope: CoroutineScope): StateFlow =
diff --git a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/CardRepositoryImpl.kt b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/CardRepositoryImpl.kt
new file mode 100644
index 0000000..2a0456e
--- /dev/null
+++ b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/CardRepositoryImpl.kt
@@ -0,0 +1,290 @@
+package se.hagstedt.kvitt.data.repo
+
+import androidx.room3.withWriteTransaction
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.launch
+import kotlinx.serialization.SerializationException
+import kotlinx.serialization.builtins.ListSerializer
+import se.hagstedt.kvitt.data.api.ApiResult
+import se.hagstedt.kvitt.data.api.CardAuthRequest
+import se.hagstedt.kvitt.data.api.CardAttemptDto
+import se.hagstedt.kvitt.data.api.CardProviderDto
+import se.hagstedt.kvitt.data.api.ErrorCodes
+import se.hagstedt.kvitt.data.api.KvittApi
+import se.hagstedt.kvitt.data.api.KvittJson
+import se.hagstedt.kvitt.data.api.PatchCardConnectionRequest
+import se.hagstedt.kvitt.data.cards.CardSync
+import se.hagstedt.kvitt.data.cards.CardView
+import se.hagstedt.kvitt.data.cards.directionOf
+import se.hagstedt.kvitt.data.cards.toModel
+import se.hagstedt.kvitt.data.connectivity.OnlineGate
+import se.hagstedt.kvitt.data.db.CardConnectionEntity
+import se.hagstedt.kvitt.data.db.CardPurchaseEntity
+import se.hagstedt.kvitt.data.db.KvittDatabase
+import se.hagstedt.kvitt.data.sync.LocalWriteListener
+import se.hagstedt.kvitt.data.util.Clock
+import se.hagstedt.kvitt.data.util.Timestamps
+import se.hagstedt.kvitt.model.AccountId
+import se.hagstedt.kvitt.model.CardAttemptState
+import se.hagstedt.kvitt.model.CardConnectStart
+import se.hagstedt.kvitt.model.CardConnectionId
+import se.hagstedt.kvitt.model.CardConnectionStatus
+import se.hagstedt.kvitt.model.CardDirection
+import se.hagstedt.kvitt.model.CardFilter
+import se.hagstedt.kvitt.model.CardProvider
+import se.hagstedt.kvitt.model.CardPurchase
+import se.hagstedt.kvitt.model.CardPurchaseDetail
+import se.hagstedt.kvitt.model.CardPurchaseId
+import se.hagstedt.kvitt.model.CardRepository
+import se.hagstedt.kvitt.model.CardsState
+import se.hagstedt.kvitt.model.DefaultCategoryKey
+import se.hagstedt.kvitt.model.ExpenseDraft
+import se.hagstedt.kvitt.model.ExpenseId
+import se.hagstedt.kvitt.model.OnlineResult
+import se.hagstedt.kvitt.model.SplitMode
+import java.time.format.DateTimeParseException
+
+/**
+ * Card purchases (S-23 to S-27, FR-22, §9.12) on the Room card tables (§14.2.1) and the household snapshot. Reads work
+ * offline; link, unlink and ignore are local first and go out with the next card cycle after the household push (CP-9,
+ * through [writes], which asks for a sync); connecting, renewing, refreshing, the history setting and disconnecting
+ * are online only (§9.9, through [gate]). Card content is never logged (§8.7).
+ */
+class CardRepositoryImpl(
+ private val db: KvittDatabase,
+ private val local: LocalData,
+ private val api: KvittApi,
+ private val gate: OnlineGate,
+ private val sync: CardSync,
+ private val prefs: PhonePrefs,
+ private val clock: Clock,
+ private val writes: LocalWriteListener,
+ private val scope: CoroutineScope,
+) : CardRepository {
+ private val fetching = MutableStateFlow(false)
+ private var follow: Job? = null
+
+ private val catalogue: Flow> = prefs.cardProviders.map { json -> json?.let(::decodeProviders).orEmpty() }
+
+ private val view: Flow = combine(local.snapshot, db.cards().observeConnections(), db.cards().observePurchases(), catalogue) { s, c, p, providers ->
+ CardView(s, c, p, providers, clock.nowMillis(), ::parseTimestamp)
+ }
+
+ override val hasConnection: Flow = db.cards().observeConnections()
+ .map { rows -> rows.any { it.status == ACTIVE || it.status == EXPIRED } }
+ .distinctUntilChanged()
+
+ override fun cards(filter: CardFilter): Flow = combine(view, fetching) { v, running -> v.state(filter, running) }.distinctUntilChanged()
+
+ override fun purchase(id: CardPurchaseId): Flow = view.map { it.detail(id) }.distinctUntilChanged()
+
+ override fun toHandle(): Flow> = view.map { it.toHandle() }.distinctUntilChanged()
+
+ override fun purchaseForExpense(id: ExpenseId): Flow = view.map { it.forExpense(id.value) }.distinctUntilChanged()
+
+ /** E-36; a successful answer is kept as the catalogue S-23 names its card from. */
+ override suspend fun providers(): OnlineResult> = gate.run({ api.cardProviders() }) { response ->
+ prefs.saveCardProviders(KvittJson.encodeToString(PROVIDERS, response.providers))
+ response.providers.map { it.toModel() }
+ }
+
+ /** E-37 for a new connection or a renewal (CP-2); `client` is `android`. */
+ override suspend fun startConnect(providerId: String, historyDays: Int): OnlineResult {
+ val result = gate.run({ api.cardAuth(CardAuthRequest(providerId, historyDays, CLIENT)) }) { it }
+ return when (result) {
+ is OnlineResult.Ok -> result.value.authUrl?.takeIf { it.isNotBlank() }?.let { OnlineResult.Ok(CardConnectStart(result.value.id, it, result.value.expiresAt)) }
+ ?: OnlineResult.Failed(ErrorCodes.INTERNAL, null)
+ OnlineResult.Offline -> OnlineResult.Offline
+ is OnlineResult.Failed -> result
+ }
+ }
+
+ /** E-38. Once connected, the connection is written at once and the first fetch is followed with card pulls (CP-2). */
+ override suspend fun attempt(attemptId: String): OnlineResult = gate.run({ api.cardAttempt(attemptId) }) { dto -> attemptState(dto) }
+
+ private suspend fun attemptState(dto: CardAttemptDto): CardAttemptState = when (dto.status) {
+ STATUS_PENDING -> CardAttemptState.Pending
+ STATUS_CONNECTED -> {
+ val connection = dto.connection
+ if (connection == null) {
+ CardAttemptState.Pending
+ } else {
+ sync.writeConnectionIfNewer(connection)
+ followFetch(connection.id)
+ ensureCatalogue()
+ CardAttemptState.Connected(connection.toModel(clock.nowMillis(), ::parseTimestamp))
+ }
+ }
+ STATUS_EXPIRED -> CardAttemptState.Expired
+ else -> CardAttemptState.Failed(dto.failure ?: STATUS_FAILED)
+ }
+
+ /**
+ * "Uppdatera" and pull to refresh (CP-5): E-42 for the connection, then a card pull. When the server is still
+ * fetching (`connection.fetching`), card pulls follow until the fetch has finished (at most [FOLLOW_ROUNDS]).
+ */
+ override suspend fun refresh(): OnlineResult {
+ val connection = current() ?: return OnlineResult.Failed(ErrorCodes.NOT_FOUND, null)
+ if (fetching.value) return OnlineResult.Ok(Unit)
+ fetching.value = true
+ try {
+ val result = gate.run({ api.refreshCardConnection(connection.id) }) { response ->
+ sync.writeConnectionIfNewer(response.connection)
+ response
+ }
+ if (result is OnlineResult.Ok) {
+ sync.cycle()
+ if (result.value.connection.fetching) followFetch(connection.id)
+ ensureCatalogue()
+ }
+ return result.unit()
+ } finally {
+ fetching.value = false
+ }
+ }
+
+ /** E-41: a shorter history only (CP-3; a longer one is a renewal). */
+ override suspend fun setHistoryDays(id: CardConnectionId, days: Int): OnlineResult =
+ gate.run({ api.patchCardConnection(id.value, PatchCardConnectionRequest(days)) }) { sync.writeConnectionIfNewer(it) }
+
+ /** E-43, then a card pull, which brings the deleted purchases (CP-12). */
+ override suspend fun disconnect(id: CardConnectionId, eraseHandled: Boolean): OnlineResult {
+ val result = gate.run({ api.disconnectCard(id.value, eraseHandled) }) { sync.writeConnectionIfNewer(it) }
+ if (result is OnlineResult.Ok) sync.cycle()
+ return result
+ }
+
+ /**
+ * CP-9: a new expense from the purchase: its amount (null for a foreign currency), the merchant text trimmed to 100
+ * code points, its date, the viewer as payer, [accountId] if still active else the AC-8.6 default, that account's
+ * AC-8.6 category (the editor then runs the §9.11 suggestion, CS-1), split `default`, no notes.
+ */
+ override suspend fun prefill(id: CardPurchaseId, accountId: AccountId?): ExpenseDraft? {
+ val purchase = db.cards().purchase(id.value)?.takeIf { it.deletedAt == null } ?: return null
+ if (directionOf(purchase.direction) == CardDirection.CREDIT) return null
+ val s = local.snapshot.first()
+ val me = s.meId ?: return null
+ val defaults = prefs.editorDefaults.first()
+ val active = s.accounts.filter { !it.archived }
+ val account = accountId?.takeIf { a -> active.any { it.id == a } }
+ ?: defaults.lastAccountId?.let { last -> active.firstOrNull { it.id.value == last }?.id }
+ ?: active.firstOrNull()?.id
+ val categories = s.categories.filter { !it.archived }
+ val category = account?.let { a -> defaults.lastCategoryFor[a.value]?.let { last -> categories.firstOrNull { it.id.value == last }?.id } }
+ ?: categories.firstOrNull { it.defaultKey == DefaultCategoryKey.OTHER }?.id
+ return ExpenseDraft(
+ id = null,
+ accountId = account,
+ categoryId = category,
+ amountOre = purchase.amountOre,
+ description = purchase.merchant.trim().takeCodePoints(DESCRIPTION_MAX),
+ notes = "",
+ date = purchase.purchaseDate,
+ payer = me,
+ mode = SplitMode.DEFAULT,
+ ratioBp = null,
+ exactOre = null,
+ fullOn = null,
+ )
+ }
+
+ override suspend fun link(id: CardPurchaseId, expenseId: ExpenseId) = change(id) { it.copy(expenseId = expenseId.value, ignored = false) }
+
+ override suspend fun unlink(id: CardPurchaseId) = change(id) { it.copy(expenseId = null) }
+
+ override suspend fun setIgnored(id: CardPurchaseId, ignored: Boolean) = change(id) { it.copy(ignored = ignored, expenseId = if (ignored) null else it.expenseId) }
+
+ /** CP-9: written to the local copy at once (offline too), pending until E-44 accepts it; a credit takes no change (CP-8). */
+ private suspend fun change(id: CardPurchaseId, edit: (CardPurchaseEntity) -> CardPurchaseEntity) {
+ val changed = db.withWriteTransaction {
+ val row = db.cards().purchase(id.value)?.takeIf { it.deletedAt == null && directionOf(it.direction) == CardDirection.DEBIT } ?: return@withWriteTransaction false
+ val next = edit(row)
+ if (next.expenseId == row.expenseId && next.ignored == row.ignored) return@withWriteTransaction false
+ db.cards().upsertPurchase(next.copy(pending = true, localUpdatedAt = clock.nowMillis()))
+ true
+ }
+ if (changed) writes.onLocalWrite()
+ }
+
+ /** The connection "Uppdatera" asks for: ACTIVE (an EXPIRED one is renewed instead, CP-6). */
+ private suspend fun current(): CardConnectionEntity? = db.cards().connections().filter { it.status == ACTIVE }.maxByOrNull { it.updatedAt }
+
+ /**
+ * Card pulls every [FOLLOW_DELAY_MILLIS] while the server fetches (the first fetch after BankID, or E-42 answering
+ * `fetching`), until the connection's `last_fetch_at` or `last_fetch_error` changes, at most [FOLLOW_ROUNDS] times.
+ * One follower at a time.
+ */
+ private fun followFetch(connectionId: String) {
+ follow?.cancel()
+ follow = scope.launch {
+ val before = db.cards().connection(connectionId)
+ repeat(FOLLOW_ROUNDS) {
+ delay(FOLLOW_DELAY_MILLIS)
+ sync.cycle()
+ val now = db.cards().connection(connectionId) ?: return@launch
+ if (now.lastFetchAt != before?.lastFetchAt || now.lastFetchError != before?.lastFetchError) return@launch
+ }
+ }
+ }
+
+ /** Fetches the catalogue once when the connected card is not in it yet (it is online only, S-24). */
+ private suspend fun ensureCatalogue() {
+ val known = catalogue.first()
+ val missing = db.cards().connections().any { c -> c.status != DISCONNECTED && known.none { it.id == c.providerId } }
+ if (missing || known.isEmpty()) {
+ val result = api.cardProviders()
+ if (result is ApiResult.Ok) prefs.saveCardProviders(KvittJson.encodeToString(PROVIDERS, result.value.providers))
+ }
+ }
+
+ private fun decodeProviders(json: String): List = try {
+ KvittJson.decodeFromString(PROVIDERS, json).map { it.toModel() }
+ } catch (e: SerializationException) {
+ emptyList()
+ } catch (e: IllegalArgumentException) {
+ emptyList()
+ }
+
+ private fun parseTimestamp(text: String): Long? = try {
+ Timestamps.parse(text)
+ } catch (e: DateTimeParseException) {
+ null
+ }
+
+ companion object {
+ private val PROVIDERS = ListSerializer(CardProviderDto.serializer())
+ private const val CLIENT = "android"
+ private val ACTIVE = CardConnectionStatus.ACTIVE.name.lowercase()
+ private val EXPIRED = CardConnectionStatus.EXPIRED.name.lowercase()
+ private val DISCONNECTED = CardConnectionStatus.DISCONNECTED.name.lowercase()
+ private const val STATUS_PENDING = "pending"
+ private const val STATUS_CONNECTED = "connected"
+ private const val STATUS_EXPIRED = "expired"
+ private const val STATUS_FAILED = "failed"
+
+ /** §9.6: a description is at most 100 code points. */
+ private const val DESCRIPTION_MAX = 100
+ const val FOLLOW_DELAY_MILLIS = 3_000L
+ const val FOLLOW_ROUNDS = 40
+ }
+}
+
+private fun OnlineResult.unit(): OnlineResult = when (this) {
+ is OnlineResult.Ok -> OnlineResult.Ok(Unit)
+ OnlineResult.Offline -> OnlineResult.Offline
+ is OnlineResult.Failed -> this
+}
+
+/** The first [max] code points (a surrogate pair is one), §9.6. */
+internal fun String.takeCodePoints(max: Int): String {
+ if (codePointCount(0, length) <= max) return this
+ return substring(0, offsetByCodePoints(0, max))
+}
diff --git a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/ExpenseRepositoryImpl.kt b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/ExpenseRepositoryImpl.kt
index 3cd45b7..022ce2f 100644
--- a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/ExpenseRepositoryImpl.kt
+++ b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/ExpenseRepositoryImpl.kt
@@ -152,12 +152,15 @@ class ExpenseRepositoryImpl(
return SaveResult.Saved(ExpenseId(id))
}
- /** A local delete keeps the row as a tombstone: `deleted_at` = `updated_at` = now, pending (§14.2.1). */
+ /** A local delete keeps the row as a tombstone: `deleted_at` = `updated_at` = now, pending (§14.2.1), and unlinks its card purchase. */
override suspend fun delete(id: ExpenseId) {
val now = clock.nowTimestamp()
val changed = db.withWriteTransaction {
val row = db.expenses().get(id.value)?.takeIf { it.deletedAt == null } ?: return@withWriteTransaction false
db.expenses().upsert(row.copy(deletedAt = now, updatedAt = now, pending = true, pendingMutationId = ids.newId(), localUpdatedAt = clock.nowMillis()))
+ // CP-9: the phone clears a link to the expense at its own delete; the server does the same when the delete
+ // arrives (§14.4.4 step 7), so the purchase is "Att hantera" again at once, offline too.
+ db.cards().clearLinksTo(id.value, clock.nowMillis())
true
}
if (changed) writes.onLocalWrite()
diff --git a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/PhonePrefs.kt b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/PhonePrefs.kt
index b63fee7..2f81c5f 100644
--- a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/PhonePrefs.kt
+++ b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/PhonePrefs.kt
@@ -15,7 +15,8 @@ data class EditorDefaults(val lastAccountId: String?, val lastCategoryFor: Map) {
val editorDefaults: Flow = store.data.map { prefs ->
@@ -47,6 +48,16 @@ class PhonePrefs(private val store: DataStore) {
store.edit { it[PROTOCOL_BLOCKED_AT] = versionCode }
}
+ /**
+ * The last card catalogue E-36 answered (CP-1), as its JSON; null until the phone has asked once. S-23 names the
+ * connected card and draws its artwork from it while offline (the catalogue itself is online only, S-24).
+ */
+ val cardProviders: Flow = store.data.map { it[CARD_PROVIDERS] }
+
+ suspend fun saveCardProviders(json: String) {
+ store.edit { it[CARD_PROVIDERS] = json }
+ }
+
suspend fun clear() {
store.edit { it.clear() }
}
@@ -58,5 +69,6 @@ class PhonePrefs(private val store: DataStore) {
private const val CATEGORY_PREFIX = "last_category_"
private val INSTALL_EXPLAINED = booleanPreferencesKey("install_unknown_apps_explained")
private val PROTOCOL_BLOCKED_AT = intPreferencesKey("protocol_blocked_version_code")
+ private val CARD_PROVIDERS = stringPreferencesKey("card_providers")
}
}
diff --git a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/sync/LocalRows.kt b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/sync/LocalRows.kt
index 2aa9752..03b5196 100644
--- a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/sync/LocalRows.kt
+++ b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/sync/LocalRows.kt
@@ -45,6 +45,8 @@ class LocalRows(private val db: KvittDatabase, private val clock: Clock) {
suspend fun deleteExpense(id: String) {
db.expenses().deleteSplits(id)
db.expenses().delete(id)
+ // CP-9: a card purchase linked to an expense that is gone from the phone is "Att hantera" again.
+ db.cards().clearLinksTo(id, clock.nowMillis())
}
suspend fun deleteSettlement(id: String) {
diff --git a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/sync/SyncEngine.kt b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/sync/SyncEngine.kt
index 0816702..75be6c0 100644
--- a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/sync/SyncEngine.kt
+++ b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/sync/SyncEngine.kt
@@ -20,6 +20,7 @@ import se.hagstedt.kvitt.data.api.PushRequest
import se.hagstedt.kvitt.data.api.PushResultDto
import se.hagstedt.kvitt.data.api.SettlementDto
import se.hagstedt.kvitt.data.api.SettlementPushEntity
+import se.hagstedt.kvitt.data.cards.CardSync
import se.hagstedt.kvitt.data.db.KvittDatabase
import se.hagstedt.kvitt.data.db.SyncRejectionEntity
import se.hagstedt.kvitt.data.db.SyncStateEntity
@@ -42,7 +43,8 @@ sealed interface SyncOutcome {
/**
* The phone side of sync protocol v1 (SPEC §14.4). One cycle at a time per process ([mutex]); a cycle pushes every
- * pending expense and settlement in batches of [PUSH_BATCH], then pulls pages of [PULL_LIMIT] until `has_more` is false.
+ * pending expense and settlement in batches of [PUSH_BATCH], then pulls pages of [PULL_LIMIT] until `has_more` is false,
+ * then runs the card cycle ([cards], §14.2.4).
* Epoch changes (§14.4.8) restart the cycle after marking everything pending.
*/
class SyncEngine(
@@ -54,6 +56,9 @@ class SyncEngine(
) {
private val mutex = Mutex()
private val rows = LocalRows(db, clock)
+
+ /** The card cycle (§14.2.4): run after each successful household cycle; `CardRepositoryImpl` also runs it. */
+ val cards = CardSync(db, api, clock)
private val runningState = MutableStateFlow(false)
private val completedState = MutableStateFlow(CompletedCycle(0, null))
@@ -96,6 +101,9 @@ class SyncEngine(
Step.Continue -> {
val pushError = (push as? Step.ContinueWithError)?.code
updateState { it.copy(lastSuccessAt = clock.nowTimestamp(), lastErrorCode = pushError) }
+ // CP-9 (D-173): card link changes go out after the household push, so their expenses exist on the
+ // server first. The card cycle's own result never changes the household outcome (§14.4.1).
+ cards.cycle()
return SyncOutcome.Success
}
is Step.ContinueWithError -> return finish(SyncOutcome.Stopped(pull.code))
@@ -377,6 +385,8 @@ class SyncEngine(
private suspend fun recoverEpoch(epoch: String?) {
val now = clock.nowMillis()
db.syncState().upsert(state().copy(cursor = 0, epoch = epoch))
+ // §14.2.4: a household epoch change also drops the card cache (pending rows kept) and pulls it from 0.
+ cards.dropCache()
for (e in db.expenses().all()) db.expenses().markPending(e.id, ids.newId(), now)
for (s in db.settlements().all()) db.settlements().markPending(s.id, ids.newId(), now)
}
diff --git a/android/data/src/test/kotlin/se/hagstedt/kvitt/data/FakeCardServer.kt b/android/data/src/test/kotlin/se/hagstedt/kvitt/data/FakeCardServer.kt
new file mode 100644
index 0000000..a09da90
--- /dev/null
+++ b/android/data/src/test/kotlin/se/hagstedt/kvitt/data/FakeCardServer.kt
@@ -0,0 +1,171 @@
+package se.hagstedt.kvitt.data
+
+import io.ktor.client.request.HttpRequestData
+import io.ktor.http.HttpMethod
+import io.ktor.http.HttpStatusCode
+import se.hagstedt.kvitt.data.api.CardAttemptDto
+import se.hagstedt.kvitt.data.api.CardAuthRequest
+import se.hagstedt.kvitt.data.api.CardConnectionDto
+import se.hagstedt.kvitt.data.api.CardProviderDto
+import se.hagstedt.kvitt.data.api.CardProvidersResponse
+import se.hagstedt.kvitt.data.api.CardPurchaseDto
+import se.hagstedt.kvitt.data.api.CardPurchaseUpdateRequest
+import se.hagstedt.kvitt.data.api.CardRefreshResponse
+import se.hagstedt.kvitt.data.api.CardSyncPage
+import se.hagstedt.kvitt.data.api.ErrorDto
+import se.hagstedt.kvitt.data.api.KvittJson
+import se.hagstedt.kvitt.data.api.PatchCardConnectionRequest
+import se.hagstedt.kvitt.data.util.Clock
+import se.hagstedt.kvitt.data.util.Timestamps
+
+/**
+ * The server side of E-36 to E-44 as far as the phone can observe it (§14.3.4, §9.12): one `card_sequence`, E-40 pages
+ * with `has_more` and tombstones, E-44's rules of §9.6 (`expense_id` must name a non-deleted expense of [household],
+ * one purchase per expense, credits take no change). [supported] false is a server before schema 4: every card path
+ * answers 404 `not_found`. Tests script deviations with [putOverride] and [attemptStatus].
+ */
+class FakeCardServer(private val clock: Clock, private val household: FakeServer) {
+ var supported = false
+ var seq = 0L
+ val connections = linkedMapOf()
+ val purchases = linkedMapOf()
+ val providers = mutableListOf(CardProviderDto("sj_prio_mastercard", "SJ Prio Mastercard", "SEB Kort", "sj_prio_mastercard", true))
+ var maxPage: Int? = null
+ val pulls = mutableListOf()
+ val puts = mutableListOf>()
+ val refreshes = mutableListOf()
+ var putOverride: ((String, CardPurchaseUpdateRequest) -> Pair?)? = null
+ var attemptStatus: String = "pending"
+ var attemptFailure: String? = null
+ var refreshFetching = false
+ var authRequests = mutableListOf()
+
+ fun next() = ++seq
+
+ fun addConnection(id: String = CONNECTION, status: String = "active", validUntil: String? = "2027-04-22T10:00:00.000Z", lastFetchAt: String? = T0): CardConnectionDto {
+ val dto = CardConnectionDto(id, "sj_prio_mastercard", status, 30, validUntil, lastFetchAt, null, false, T0, T0, next())
+ connections[id] = dto
+ return dto
+ }
+
+ fun addPurchase(
+ id: String,
+ amountOre: Long? = 41_200,
+ date: String = "2026-10-16",
+ merchant: String = "ICA MAXI LINDHAGEN",
+ direction: String = "debit",
+ status: String = "booked",
+ expenseId: String? = null,
+ ignored: Boolean = false,
+ currency: String? = null,
+ ): CardPurchaseDto {
+ val dto = CardPurchaseDto(
+ id, CONNECTION, status, direction, date, if (status == "booked") date else null, amountOre,
+ if (amountOre == null) "12.50" else null, currency, merchant, expenseId, ignored, T0, T0, null, next(),
+ )
+ purchases[id] = dto
+ return dto
+ }
+
+ /** A tombstone with its content erased (§14.1.3). */
+ fun deletePurchase(id: String) {
+ val p = purchases.getValue(id)
+ purchases[id] = p.copy(merchant = "", amountOre = null, expenseId = null, deletedAt = T0, cardVersion = next())
+ }
+
+ fun handle(request: HttpRequestData, path: String, body: String): Pair {
+ if (!supported) return error(HttpStatusCode.NotFound, "not_found")
+ val method = request.method
+ return when {
+ path == "/cards/providers" && method == HttpMethod.Get -> ok(KvittJson.encodeToString(CardProvidersResponse.serializer(), CardProvidersResponse(providers)))
+ path == "/cards/sync" && method == HttpMethod.Get -> sync(request)
+ path == "/cards/auth" && method == HttpMethod.Post -> {
+ authRequests += KvittJson.decodeFromString(CardAuthRequest.serializer(), body)
+ created(attempt("https://bank.example/auth?x=1"))
+ }
+ path.startsWith("/cards/auth/") && method == HttpMethod.Get -> ok(attempt(null))
+ path.startsWith("/cards/purchases/") && method == HttpMethod.Put -> put(path.removePrefix("/cards/purchases/"), KvittJson.decodeFromString(CardPurchaseUpdateRequest.serializer(), body))
+ path.startsWith("/cards/connections/") && path.endsWith("/refresh") && method == HttpMethod.Post -> refresh(path.removePrefix("/cards/connections/").removeSuffix("/refresh"))
+ path.startsWith("/cards/connections/") && method == HttpMethod.Patch -> patch(path.removePrefix("/cards/connections/"), KvittJson.decodeFromString(PatchCardConnectionRequest.serializer(), body))
+ path.startsWith("/cards/connections/") && method == HttpMethod.Delete ->
+ disconnect(path.removePrefix("/cards/connections/"), request.url.parameters["erase_handled"] == "true")
+ else -> error(HttpStatusCode.NotFound, "not_found")
+ }
+ }
+
+ private fun sync(request: HttpRequestData): Pair {
+ val since = request.url.parameters["since"]?.toLong() ?: 0
+ val limit = minOf(request.url.parameters["limit"]?.toInt() ?: 500, maxPage ?: Int.MAX_VALUE)
+ pulls += since
+ if (since > seq) return error(HttpStatusCode.Conflict, "cursor_ahead", "since")
+ val rows = (connections.values.map { it.cardVersion to (it as Any) } + purchases.values.map { it.cardVersion to (it as Any) })
+ .filter { it.first > since }.sortedBy { it.first }
+ val page = rows.take(limit)
+ val next = page.lastOrNull()?.first ?: since
+ val dto = CardSyncPage(
+ since, next, rows.size > page.size, Timestamps.format(clock.nowMillis()),
+ page.map { it.second }.filterIsInstance(), page.map { it.second }.filterIsInstance(),
+ )
+ return ok(KvittJson.encodeToString(CardSyncPage.serializer(), dto))
+ }
+
+ private fun put(id: String, request: CardPurchaseUpdateRequest): Pair {
+ puts += id to request
+ putOverride?.invoke(id, request)?.let { return it }
+ val p = purchases[id]?.takeIf { it.deletedAt == null } ?: return error(HttpStatusCode.NotFound, "not_found")
+ val expenseId = request.expenseId
+ if (expenseId != null && household.expenses[expenseId]?.takeIf { it.deletedAt == null } == null) {
+ return error(HttpStatusCode.NotFound, "not_found", "expense_id")
+ }
+ if (request.ignored && expenseId != null) return error(HttpStatusCode.UnprocessableEntity, "validation_failed", "ignored")
+ if (p.direction == "credit" && (expenseId != null || request.ignored)) return error(HttpStatusCode.UnprocessableEntity, "validation_failed", "expense_id")
+ if (expenseId != null && purchases.values.any { it.id != id && it.expenseId == expenseId }) return error(HttpStatusCode.Conflict, "expense_already_linked", "expense_id")
+ val changed = p.expenseId != expenseId || p.ignored != request.ignored
+ val next = if (changed) p.copy(expenseId = expenseId, ignored = request.ignored, cardVersion = next()) else p
+ purchases[id] = next
+ return ok(KvittJson.encodeToString(CardPurchaseDto.serializer(), next))
+ }
+
+ private fun refresh(id: String): Pair {
+ refreshes += id
+ val c = connections[id] ?: return error(HttpStatusCode.NotFound, "not_found")
+ if (c.status != "active") return error(HttpStatusCode.Conflict, "card_consent_expired")
+ val updated = c.copy(lastFetchAt = Timestamps.format(clock.nowMillis()), fetching = refreshFetching, cardVersion = next())
+ connections[id] = updated
+ return ok(KvittJson.encodeToString(CardRefreshResponse.serializer(), CardRefreshResponse(updated, !refreshFetching)))
+ }
+
+ private fun patch(id: String, request: PatchCardConnectionRequest): Pair {
+ val c = connections[id] ?: return error(HttpStatusCode.NotFound, "not_found")
+ if (request.historyDays > c.historyDays) return error(HttpStatusCode.UnprocessableEntity, "validation_failed", "history_days")
+ val updated = c.copy(historyDays = request.historyDays, cardVersion = next())
+ connections[id] = updated
+ return ok(KvittJson.encodeToString(CardConnectionDto.serializer(), updated))
+ }
+
+ /** CP-12: the purchases neither linked nor ignored are deleted, and the handled ones too with [eraseHandled]. */
+ private fun disconnect(id: String, eraseHandled: Boolean): Pair {
+ val c = connections[id] ?: return error(HttpStatusCode.NotFound, "not_found")
+ purchases.values.filter { it.deletedAt == null && (eraseHandled || (it.expenseId == null && !it.ignored)) }.forEach { deletePurchase(it.id) }
+ val updated = c.copy(status = "disconnected", cardVersion = next())
+ connections[id] = updated
+ return ok(KvittJson.encodeToString(CardConnectionDto.serializer(), updated))
+ }
+
+ private fun attempt(authUrl: String?): String {
+ val connection = if (attemptStatus == "connected") connections[CONNECTION] ?: addConnection(lastFetchAt = null) else null
+ val dto = CardAttemptDto(ATTEMPT, "sj_prio_mastercard", attemptStatus, attemptFailure, authUrl, "2026-10-17T10:15:00.000Z", connection)
+ return KvittJson.encodeToString(CardAttemptDto.serializer(), dto)
+ }
+
+ private fun ok(body: String) = HttpStatusCode.OK to body
+ private fun created(body: String) = HttpStatusCode.Created to body
+ private fun error(status: HttpStatusCode, code: String, field: String? = null) =
+ status to KvittJson.encodeToString(ErrorDto.serializer(), ErrorDto(code, code, field))
+
+ companion object {
+ const val T0 = "2026-10-15T08:00:00.000Z"
+ const val CONNECTION = "0193c000-0000-7000-8000-000000000001"
+ const val ATTEMPT = "0193c0a0-0000-7000-8000-000000000001"
+ }
+}
diff --git a/android/data/src/test/kotlin/se/hagstedt/kvitt/data/FakeServer.kt b/android/data/src/test/kotlin/se/hagstedt/kvitt/data/FakeServer.kt
index 7f10066..4561a21 100644
--- a/android/data/src/test/kotlin/se/hagstedt/kvitt/data/FakeServer.kt
+++ b/android/data/src/test/kotlin/se/hagstedt/kvitt/data/FakeServer.kt
@@ -59,6 +59,9 @@ class FakeServer(private val clock: Clock, val epoch: String = "epoch-1") {
var apkBody: ByteArray? = null
var restHandler: ((HttpRequestData, String) -> Pair?)? = null
+ /** E-36 to E-44 (card purchases); without card support every card path answers 404 `not_found`. */
+ val cards = FakeCardServer(clock, this)
+
fun next() = ++seq
fun addUser(id: String, role: String, name: String) {
@@ -92,6 +95,11 @@ class FakeServer(private val clock: Clock, val epoch: String = "epoch-1") {
failStatus?.let { (status, code) -> return scope.json(status, KvittJson.encodeToString(ErrorDto.serializer(), ErrorDto(code, code, null))) }
val path = request.url.encodedPath
restHandler?.invoke(request, path)?.let { (status, body) -> return scope.json(status, body) }
+ if (path.startsWith("/cards/")) {
+ val text = if (request.method == HttpMethod.Get || request.method == HttpMethod.Delete) "" else String(request.body.toByteArray())
+ val (status, body) = cards.handle(request, path, text)
+ return scope.json(status, body)
+ }
val apk = apkBody
if (apk != null && path.startsWith("/apk/files/")) {
return scope.respond(apk, HttpStatusCode.OK, headersOf(HttpHeaders.ContentType to listOf("application/vnd.android.package-archive")))
@@ -119,7 +127,7 @@ class FakeServer(private val clock: Clock, val epoch: String = "epoch-1") {
}
}
- private fun MockRequestHandleScope.json(status: HttpStatusCode, body: String) =
+ fun MockRequestHandleScope.json(status: HttpStatusCode, body: String) =
respond(body, status, headersOf(HttpHeaders.ContentType to listOf("application/json; charset=utf-8"), "X-Kvitt-Server-Version" to listOf("0.1.0")))
fun page(since: Long, limit: Int): PullPage {
diff --git a/android/data/src/test/kotlin/se/hagstedt/kvitt/data/TestSupport.kt b/android/data/src/test/kotlin/se/hagstedt/kvitt/data/TestSupport.kt
index e0e5c42..78babec 100644
--- a/android/data/src/test/kotlin/se/hagstedt/kvitt/data/TestSupport.kt
+++ b/android/data/src/test/kotlin/se/hagstedt/kvitt/data/TestSupport.kt
@@ -5,6 +5,7 @@ import se.hagstedt.kvitt.data.api.ExpensePushEntity
import se.hagstedt.kvitt.data.api.KvittJson
import se.hagstedt.kvitt.data.api.MutationDto
import se.hagstedt.kvitt.data.api.SplitDto
+import se.hagstedt.kvitt.data.repo.CardRepositoryImpl
import se.hagstedt.kvitt.data.repo.CategoryKeywords
import se.hagstedt.kvitt.data.repo.ExpenseRepositoryImpl
import se.hagstedt.kvitt.data.repo.SettlementRepositoryImpl
@@ -57,3 +58,5 @@ fun MutationDto.asStoredExpense(version: Long, actor: String = Harness.ME): Expe
fun runSync(block: suspend kotlinx.coroutines.CoroutineScope.() -> Unit) {
kotlinx.coroutines.runBlocking { kotlinx.coroutines.withTimeout(60_000, block) }
}
+
+fun Harness.cardRepo() = CardRepositoryImpl(db, local, api, gate, engine.cards, prefs, clock, writes, scope)
diff --git a/android/data/src/test/kotlin/se/hagstedt/kvitt/data/api/ExampleRoundTripTest.kt b/android/data/src/test/kotlin/se/hagstedt/kvitt/data/api/ExampleRoundTripTest.kt
index 2821679..60bd024 100644
--- a/android/data/src/test/kotlin/se/hagstedt/kvitt/data/api/ExampleRoundTripTest.kt
+++ b/android/data/src/test/kotlin/se/hagstedt/kvitt/data/api/ExampleRoundTripTest.kt
@@ -90,6 +90,15 @@ internal fun serializerFor(path: String): KSerializer<*>? {
33 -> if (request) ProposalRequest.serializer() else ProposalResponse.serializer()
34 -> TrendsResponse.serializer()
35 -> ForecastResponse.serializer()
+ // Card purchases (v0.35, K1's examples): E-39 is HTML and has none.
+ 36 -> CardProvidersResponse.serializer()
+ 37 -> if (request) CardAuthRequest.serializer() else CardAttemptDto.serializer()
+ 38 -> CardAttemptDto.serializer()
+ 40 -> CardSyncPage.serializer()
+ 41 -> if (request) PatchCardConnectionRequest.serializer() else CardConnectionDto.serializer()
+ 42 -> CardRefreshResponse.serializer()
+ 43 -> CardConnectionDto.serializer()
+ 44 -> if (request) CardPurchaseUpdateRequest.serializer() else CardPurchaseDto.serializer()
else -> null
}
}
diff --git a/android/data/src/test/kotlin/se/hagstedt/kvitt/data/db/MigrationTest.kt b/android/data/src/test/kotlin/se/hagstedt/kvitt/data/db/MigrationTest.kt
index fc17463..77e9dfa 100644
--- a/android/data/src/test/kotlin/se/hagstedt/kvitt/data/db/MigrationTest.kt
+++ b/android/data/src/test/kotlin/se/hagstedt/kvitt/data/db/MigrationTest.kt
@@ -22,8 +22,8 @@ import java.io.File
* Room migration test infrastructure (§14.2.3, CLAUDE.md: every schema change gets a migration and a test). A database
* is created at any exported version straight from `android/data/schemas/.json` (its `createSql` and setup
* queries), filled with user data, then opened by the current [KvittDatabase] with [KvittMigrations.ALL]: Room runs the
- * migrations and verifies the result against the compiled schema (identity hash and table info). At version 1 there
- * are no migrations; the same path proves the committed `1.json` matches the entities.
+ * migrations and verifies the result against the compiled schema (identity hash and table info). The same path from
+ * the newest version proves its committed JSON matches the entities.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [36])
@@ -60,8 +60,102 @@ class MigrationTest {
}
}
+ /**
+ * §14.2.3 (D-173): a version 1 database holding users, accounts, categories, expenses, settlements, audit entries, a
+ * pending expense and a rejection is migrated by `MIGRATION_1_2`; every row is kept and the card tables exist exactly
+ * as in the exported `2.json` (Room also validates them against the compiled schema when it opens), with the
+ * `card_sync_state` row `id` 1, `cursor` 0.
+ */
+ @Test
+ fun migration1To2_keepsEveryRow_andCreatesTheCardTablesOf2Json() = runSync {
+ val name = "migration-1-2.db"
+ val file = RuntimeEnvironment.getApplication().getDatabasePath(name)
+ file.parentFile?.mkdirs()
+ file.delete()
+ createAt(1, file) { sqlite ->
+ sqlite.execSQL("INSERT INTO users (id, role, display_name, picture_version, created_at, updated_at, server_version, local_updated_at) VALUES ('u2', 'member', 'Anna', 3, 't', 't', 2, 0)")
+ sqlite.execSQL("INSERT INTO accounts (id, name, created_by_user_id, created_at, updated_at, archived_at, deleted_at, server_version, local_updated_at) VALUES ('a1', 'Vardag', 'u1', 't', 't', NULL, NULL, 3, 0)")
+ sqlite.execSQL("INSERT INTO account_default_shares (account_id, user_id, share_bp) VALUES ('a1', 'u1', 6000), ('a1', 'u2', 4000)")
+ sqlite.execSQL(
+ "INSERT INTO categories (id, default_key, name, icon, color, sort_order, created_at, updated_at, archived_at, deleted_at, server_version, local_updated_at) " +
+ "VALUES ('c1', 'groceries', NULL, 'shopping_cart', 'green', 10, 't', 't', NULL, NULL, 4, 0)",
+ )
+ sqlite.execSQL(
+ "INSERT INTO expenses (id, account_id, category_id, amount_ore, description, notes, expense_date, payer_user_id, split_mode, recurring_template_id, recurring_period, " +
+ "created_by_user_id, updated_by_user_id, created_at, updated_at, deleted_at, server_version, pending, pending_mutation_id, local_updated_at) " +
+ "VALUES ('e2', 'a1', 'c1', 9900, 'Coop', 'kvitto', '2026-10-15', 'u2', 'default', NULL, NULL, 'u2', 'u2', 't', 't', NULL, 5, 0, NULL, 0)",
+ )
+ sqlite.execSQL(
+ "INSERT INTO settlements (id, from_user_id, to_user_id, amount_ore, settlement_date, note, created_by_user_id, updated_by_user_id, created_at, updated_at, deleted_at, " +
+ "server_version, pending, pending_mutation_id, local_updated_at) VALUES ('s1', 'u2', 'u1', 80000, '2026-10-16', '', 'u2', 'u2', 't', 't', NULL, 6, 0, NULL, 0)",
+ )
+ sqlite.execSQL("INSERT INTO settlement_allocations (settlement_id, account_id, amount_ore) VALUES ('s1', 'a1', 80000)")
+ sqlite.execSQL(
+ "INSERT INTO audit_log (id, entity_type, entity_id, action, source, actor_user_id, client_at, server_at, changes, server_version) " +
+ "VALUES ('l1', 'expense', 'e2', 'create', 'app', 'u2', 't', 't', '[]', 7)",
+ )
+ sqlite.execSQL(
+ "INSERT INTO sync_rejections (mutation_id, entity_type, entity_id, reason, message, rejected_payload, server_current, rejected_at, dismissed) " +
+ "VALUES ('m9', 'expense', 'e9', 'stale', 'stale', '{}', NULL, 't', 0)",
+ )
+ sqlite.execSQL("INSERT INTO sync_state (id, cursor, epoch, last_success_at, last_attempt_at, last_error_code, clock_skew) VALUES (1, 7, 'epoch-1', 't', 't', NULL, 0)")
+ }
+ val db = KvittDatabase.build(RuntimeEnvironment.getApplication(), AndroidSQLiteDriver(), name)
+ try {
+ assertEquals(2, db.users().all().size)
+ assertEquals(listOf("a1"), db.accounts().observeAll().first().map { it.id })
+ assertEquals(2, db.accounts().observeShares().first().size)
+ assertEquals("c1", db.categories().get("c1")?.id)
+ assertEquals(true, db.expenses().get("e1")?.pending)
+ assertEquals("kvitto", db.expenses().get("e2")?.notes)
+ assertEquals(listOf(80000L), db.settlements().allocations("s1").map { it.amountOre })
+ assertEquals("e2", db.audit().get("l1")?.entityId)
+ assertEquals(listOf("m9"), db.rejections().all().map { it.mutationId })
+ assertEquals(7L, db.syncState().get()?.cursor)
+ assertEquals(CardSyncStateEntity(1, 0, null), db.cards().syncState())
+ assertEquals(emptyList(), db.cards().connections())
+ assertEquals(emptyList(), db.cards().purchases())
+ } finally {
+ db.close()
+ }
+ // The migrated tables and indices are the exported 2.json's, statement for statement.
+ val expected = schemaStatements(2).filterKeys { it.startsWith("card_") || it.startsWith("index_card_") }
+ assertEquals(setOf("card_connections", "card_purchases", "card_sync_state", "index_card_purchases_purchase_date", "index_card_purchases_expense_id", "index_card_purchases_pending"), expected.keys)
+ val sqlite = SQLiteDatabase.openDatabase(file.path, null, SQLiteDatabase.OPEN_READONLY)
+ try {
+ expected.forEach { (objectName, sql) ->
+ sqlite.rawQuery("SELECT sql FROM sqlite_master WHERE name = ?", arrayOf(objectName)).use { c ->
+ assertTrue("$objectName exists", c.moveToFirst())
+ assertEquals(objectName, sql.normalised(), c.getString(0).normalised())
+ }
+ }
+ } finally {
+ sqlite.close()
+ file.delete()
+ }
+ }
+
+ /** Every table's and index's `createSql` of the exported [version], by name. */
+ private fun schemaStatements(version: Int): Map {
+ val schema = Json.parseToJsonElement(File(schemas, "$version.json").readText()).jsonObject.getValue("database").jsonObject
+ return buildMap {
+ for (entity in schema.getValue("entities").jsonArray) {
+ val o = entity.jsonObject
+ val table = o.getValue("tableName").jsonPrimitive.content
+ put(table, o.getValue("createSql").jsonPrimitive.content.replace("\${TABLE_NAME}", table))
+ o["indices"]?.jsonArray?.forEach { index ->
+ val i = index.jsonObject
+ put(i.getValue("name").jsonPrimitive.content, i.getValue("createSql").jsonPrimitive.content.replace("\${TABLE_NAME}", table))
+ }
+ }
+ }
+ }
+
+ /** SQLite stores a statement without `IF NOT EXISTS`. */
+ private fun String.normalised(): String = replace("IF NOT EXISTS ", "").trim()
+
/** A database file exactly as Room would have created it at [version], with one row of user data. */
- private fun createAt(version: Int, file: File) {
+ private fun createAt(version: Int, file: File, extra: (SQLiteDatabase) -> Unit = {}) {
val schema = Json.parseToJsonElement(File(schemas, "$version.json").readText()).jsonObject.getValue("database").jsonObject
assertEquals(version, schema.getValue("version").jsonPrimitive.int)
val sqlite = SQLiteDatabase.openOrCreateDatabase(file, null)
@@ -82,6 +176,7 @@ class MigrationTest {
"VALUES ('e1', 'a1', 'c1', 41200, 'ICA', '', '2026-10-16', 'u1', 'default', NULL, NULL, NULL, NULL, 't', 't', NULL, NULL, 1, 'm1', 0)",
)
sqlite.execSQL("INSERT INTO expense_splits (expense_id, user_id, share_ore, share_bp) VALUES ('e1', 'u1', 20600, 5000), ('e1', 'u2', 20600, 5000)")
+ extra(sqlite)
sqlite.version = version
} finally {
sqlite.close()
diff --git a/android/data/src/test/kotlin/se/hagstedt/kvitt/data/repo/CardRepositoryTest.kt b/android/data/src/test/kotlin/se/hagstedt/kvitt/data/repo/CardRepositoryTest.kt
new file mode 100644
index 0000000..7774624
--- /dev/null
+++ b/android/data/src/test/kotlin/se/hagstedt/kvitt/data/repo/CardRepositoryTest.kt
@@ -0,0 +1,258 @@
+package se.hagstedt.kvitt.data.repo
+
+import kotlinx.coroutines.flow.first
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import se.hagstedt.kvitt.data.FakeCardServer.Companion.ATTEMPT
+import se.hagstedt.kvitt.data.FakeCardServer.Companion.CONNECTION
+import se.hagstedt.kvitt.data.Harness
+import se.hagstedt.kvitt.data.Harness.Companion.ACCOUNT
+import se.hagstedt.kvitt.data.Harness.Companion.GROCERIES
+import se.hagstedt.kvitt.data.Harness.Companion.ME
+import se.hagstedt.kvitt.data.Harness.Companion.OTHER
+import se.hagstedt.kvitt.data.cardRepo
+import se.hagstedt.kvitt.data.draft
+import se.hagstedt.kvitt.data.expenseRepo
+import se.hagstedt.kvitt.data.runSync
+import se.hagstedt.kvitt.data.savedId
+import se.hagstedt.kvitt.data.util.Timestamps
+import se.hagstedt.kvitt.model.AccountId
+import se.hagstedt.kvitt.model.CardAttemptState
+import se.hagstedt.kvitt.model.CardConnectionId
+import se.hagstedt.kvitt.model.CardConnectionStatus
+import se.hagstedt.kvitt.model.CardFetchError
+import se.hagstedt.kvitt.model.CardFilter
+import se.hagstedt.kvitt.model.CardPurchaseId
+import se.hagstedt.kvitt.model.CategoryId
+import se.hagstedt.kvitt.model.ExpenseId
+import se.hagstedt.kvitt.model.OnlineResult
+import se.hagstedt.kvitt.model.SplitMode
+import se.hagstedt.kvitt.model.UserId
+
+/** `CardRepositoryImpl` (SPEC §14.2.4, §9.12 CP-6 to CP-12) on Room and the fake card server. */
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [36])
+class CardRepositoryTest {
+ private lateinit var h: Harness
+
+ @Before
+ fun setUp() {
+ h = Harness().ready()
+ h.server.cards.supported = true
+ h.server.cards.addConnection()
+ }
+
+ @After
+ fun tearDown() = h.close()
+
+ private val cards get() = h.server.cards
+
+ private suspend fun pulled() {
+ h.engine.cards.cycle()
+ }
+
+ @Test
+ fun theFiltersSplitThePurchases_newestFirstByDay_withCounts() = runSync {
+ cards.addPurchase("p1", date = "2026-10-14")
+ cards.addPurchase("p2", date = "2026-10-16", status = "pending")
+ cards.addPurchase("p3", date = "2026-10-16", ignored = true)
+ cards.addPurchase("p4", date = "2026-10-15", direction = "credit")
+ pulled()
+ val repo = h.cardRepo()
+ val toHandle = repo.cards(CardFilter.TO_HANDLE).first()
+ assertEquals(listOf("2026-10-16", "2026-10-14"), toHandle.days.map { it.date })
+ assertEquals(listOf("p2", "p1"), toHandle.days.flatMap { d -> d.rows.map { it.purchase.id.value } })
+ assertEquals(mapOf(CardFilter.TO_HANDLE to 2, CardFilter.LINKED to 0, CardFilter.IGNORED to 1, CardFilter.ALL to 4), toHandle.counts)
+ assertEquals(listOf("p3"), repo.cards(CardFilter.IGNORED).first().days.flatMap { d -> d.rows.map { it.purchase.id.value } })
+ // A credit purchase shows only under "Alla" (CP-8).
+ // Within a day, the newest created first, then the higher id.
+ assertEquals(listOf("p3", "p2", "p4", "p1"), repo.cards(CardFilter.ALL).first().days.flatMap { d -> d.rows.map { it.purchase.id.value } })
+ assertEquals(CONNECTION, toHandle.connection?.id?.value)
+ assertEquals("SJ Prio Mastercard", repo.providers().let { (it as OnlineResult.Ok).value.single().name })
+ assertEquals("SJ Prio Mastercard", repo.cards(CardFilter.TO_HANDLE).first().provider?.name)
+ }
+
+ @Test
+ fun matches_sameAmountWithinThreeDays_notLinkedByAnotherPurchaseOfTheViewer() = runSync {
+ val repo = h.cardRepo()
+ val near = h.expenseRepo().save(draft("ICA", amountOre = 41_200, date = "2026-10-14")).savedId()
+ val far = h.expenseRepo().save(draft("ICA", amountOre = 41_200, date = "2026-10-12")).savedId()
+ h.expenseRepo().save(draft("Coop", amountOre = 41_201, date = "2026-10-16")).savedId()
+ val linkedElsewhere = h.expenseRepo().save(draft("ICA igen", amountOre = 41_200, date = "2026-10-16")).savedId()
+ h.engine.sync()
+ cards.addPurchase("p1", date = "2026-10-16")
+ cards.addPurchase("p2", date = "2026-10-01", expenseId = linkedElsewhere.value)
+ pulled()
+ val row = repo.cards(CardFilter.TO_HANDLE).first().days.single().rows.single()
+ assertEquals(listOf(near), row.matches.map { it.id })
+ assertEquals("Vardag", row.matches.single().accountName)
+ assertEquals(ME, row.matches.single().payer.id.value)
+ val detail = repo.purchase(CardPurchaseId("p1")).first()
+ // "Välj en annan utgift": any amount within 14 days, not linked by another purchase, newest first.
+ assertEquals(3, detail?.others?.size)
+ assertTrue(detail?.others?.none { it.id == linkedElsewhere } == true)
+ assertTrue(detail?.others?.any { it.id == far } == true)
+ // Under "Kopplade" the line names the expense and its account.
+ val linked = repo.cards(CardFilter.LINKED).first().days.single().rows.single().purchase.linked
+ assertEquals("ICA igen", linked?.description)
+ assertEquals("Vardag", linked?.accountName)
+ assertEquals("p2", repo.purchaseForExpense(linkedElsewhere).first()?.id?.value)
+ }
+
+ @Test
+ fun prefill_appliesCp9() = runSync {
+ val long = "A".repeat(90) + " " + "B".repeat(30)
+ cards.addPurchase("p1", merchant = long, date = "2026-10-15")
+ cards.addPurchase("p2", amountOre = null, currency = "EUR")
+ cards.addPurchase("p3", direction = "credit")
+ pulled()
+ val repo = h.cardRepo()
+ val draft = repo.prefill(CardPurchaseId("p1"), null)
+ assertNull(draft?.id)
+ assertEquals(41_200L, draft?.amountOre)
+ assertEquals(long.take(100), draft?.description)
+ assertEquals("2026-10-15", draft?.date)
+ assertEquals(UserId(ME), draft?.payer)
+ // AC-8.6: no account used on this phone yet, so the oldest active account and its `other` category.
+ assertEquals(AccountId(ACCOUNT), draft?.accountId)
+ assertEquals(CategoryId(OTHER), draft?.categoryId)
+ assertEquals(SplitMode.DEFAULT, draft?.mode)
+ assertEquals("", draft?.notes)
+ // A foreign-currency purchase leaves the amount empty; a credit and an unknown purchase give no draft.
+ assertNull(repo.prefill(CardPurchaseId("p2"), AccountId(ACCOUNT))?.amountOre)
+ assertNull(repo.prefill(CardPurchaseId("p3"), null))
+ assertNull(repo.prefill(CardPurchaseId("nope"), null))
+ }
+
+ @Test
+ fun prefill_takesTheAccountItWasOpenedFrom_andTheLastCategoryUsedThere() = runSync {
+ h.expenseRepo().save(draft("ICA")).savedId()
+ cards.addPurchase("p1")
+ pulled()
+ val draft = h.cardRepo().prefill(CardPurchaseId("p1"), AccountId(ACCOUNT))
+ assertEquals(AccountId(ACCOUNT), draft?.accountId)
+ assertEquals(CategoryId(GROCERIES), draft?.categoryId)
+ }
+
+ @Test
+ fun linkUnlinkIgnore_areLocalAtOnce_pending_andAskForASync() = runSync {
+ cards.addPurchase("p1")
+ pulled()
+ val repo = h.cardRepo()
+ val expense = h.expenseRepo().save(draft("ICA")).savedId()
+ val writes = h.localWrites
+ repo.link(CardPurchaseId("p1"), expense)
+ assertEquals(expense.value, h.db.cards().purchase("p1")?.expenseId)
+ assertEquals(true, h.db.cards().purchase("p1")?.pending)
+ assertEquals(writes + 1, h.localWrites)
+ assertEquals(1, repo.cards(CardFilter.LINKED).first().counts[CardFilter.LINKED])
+ repo.unlink(CardPurchaseId("p1"))
+ assertNull(h.db.cards().purchase("p1")?.expenseId)
+ repo.setIgnored(CardPurchaseId("p1"), true)
+ assertEquals(true, h.db.cards().purchase("p1")?.ignored)
+ assertEquals(1, repo.cards(CardFilter.IGNORED).first().counts[CardFilter.IGNORED])
+ assertEquals(emptyList(), repo.toHandle().first().map { it.id.value })
+ // Offline: nothing reaches the network for these (CP-9).
+ h.monitor.state.value = false
+ val requests = h.requests.size
+ repo.setIgnored(CardPurchaseId("p1"), false)
+ assertEquals(requests, h.requests.size)
+ assertEquals(listOf("p1"), repo.toHandle().first().map { it.id.value })
+ }
+
+ @Test
+ fun aLocalExpenseDeleteUnlinksItsPurchase() = runSync {
+ cards.addPurchase("p1")
+ pulled()
+ val expense = h.expenseRepo().save(draft("ICA")).savedId()
+ h.cardRepo().link(CardPurchaseId("p1"), expense)
+ h.expenseRepo().delete(expense)
+ assertNull(h.db.cards().purchase("p1")?.expenseId)
+ assertEquals(listOf("p1"), h.cardRepo().toHandle().first().map { it.id.value })
+ }
+
+ @Test
+ fun refresh_callsE42ThenPulls_offlineIsOffline() = runSync {
+ pulled()
+ val repo = h.cardRepo()
+ cards.addPurchase("p1")
+ assertEquals(OnlineResult.Ok(Unit), repo.refresh())
+ assertEquals(listOf(CONNECTION), cards.refreshes)
+ assertEquals(listOf("p1"), h.db.cards().purchases().map { it.id })
+ assertEquals(Timestamps.format(h.clock.now), h.db.cards().connection(CONNECTION)?.lastFetchAt)
+ h.monitor.state.value = false
+ assertEquals(OnlineResult.Offline, repo.refresh())
+ assertEquals(1, cards.refreshes.size)
+ }
+
+ @Test
+ fun connection_expiresSoonFrom14DaysBefore_andAFetchErrorReads() = runSync {
+ cards.connections[CONNECTION] = cards.connections.getValue(CONNECTION).copy(validUntil = "2026-10-31T09:00:00.000Z", lastFetchError = "something_new", cardVersion = cards.next())
+ pulled()
+ val connection = h.cardRepo().cards(CardFilter.TO_HANDLE).first().connection
+ assertEquals(true, connection?.expiresSoon)
+ assertEquals(CardFetchError.FAILED, connection?.lastFetchError)
+ cards.connections[CONNECTION] = cards.connections.getValue(CONNECTION).copy(validUntil = "2026-11-01T11:00:00.000Z", lastFetchError = "rate_limited", cardVersion = cards.next())
+ pulled()
+ val later = h.cardRepo().cards(CardFilter.TO_HANDLE).first().connection
+ assertEquals(false, later?.expiresSoon)
+ assertEquals(CardFetchError.RATE_LIMITED, later?.lastFetchError)
+ }
+
+ @Test
+ fun connect_startsAnAttempt_andAConnectedAttemptWritesTheConnection() = runSync {
+ cards.connections.clear()
+ val repo = h.cardRepo()
+ assertFalse(repo.hasConnection.first())
+ val start = repo.startConnect("sj_prio_mastercard", 90)
+ assertEquals(ATTEMPT, (start as OnlineResult.Ok).value.attemptId)
+ assertEquals("https://bank.example/auth?x=1", start.value.authUrl)
+ assertEquals(90, cards.authRequests.single().historyDays)
+ assertEquals("android", cards.authRequests.single().client)
+ assertEquals(OnlineResult.Ok(CardAttemptState.Pending), repo.attempt(ATTEMPT))
+ cards.attemptStatus = "failed"
+ cards.attemptFailure = "cancelled"
+ assertEquals(OnlineResult.Ok(CardAttemptState.Failed("cancelled")), repo.attempt(ATTEMPT))
+ cards.attemptStatus = "connected"
+ cards.attemptFailure = null
+ val connected = repo.attempt(ATTEMPT)
+ assertEquals(CardConnectionStatus.ACTIVE, ((connected as OnlineResult.Ok).value as CardAttemptState.Connected).connection.status)
+ assertTrue(repo.hasConnection.first())
+ cards.attemptStatus = "expired"
+ assertEquals(OnlineResult.Ok(CardAttemptState.Expired), repo.attempt(ATTEMPT))
+ }
+
+ @Test
+ fun historyAndDisconnect_areOnline_andDisconnectKeepsTheHandledPurchases() = runSync {
+ cards.addPurchase("p1")
+ cards.addPurchase("p2", ignored = true)
+ pulled()
+ val repo = h.cardRepo()
+ assertEquals(OnlineResult.Ok(Unit), repo.setHistoryDays(CardConnectionId(CONNECTION), 7))
+ assertEquals(7, h.db.cards().connection(CONNECTION)?.historyDays)
+ assertEquals("validation_failed", (repo.setHistoryDays(CardConnectionId(CONNECTION), 180) as OnlineResult.Failed).code)
+ assertEquals(OnlineResult.Ok(Unit), repo.disconnect(CardConnectionId(CONNECTION), eraseHandled = false))
+ assertFalse(repo.hasConnection.first())
+ assertEquals(listOf("p2"), h.db.cards().purchases().map { it.id })
+ assertNull(repo.cards(CardFilter.TO_HANDLE).first().connection)
+ h.monitor.state.value = false
+ assertEquals(OnlineResult.Offline, repo.disconnect(CardConnectionId(CONNECTION), eraseHandled = true))
+ }
+
+ @Test
+ fun anExpenseLinkedOnTheServerButNotOnThePhoneShowsNoLinkedLine() = runSync {
+ cards.addPurchase("p1", expenseId = "0192ffff-0000-7000-8000-00000000ffff")
+ pulled()
+ val row = h.cardRepo().cards(CardFilter.LINKED).first().days.single().rows.single()
+ assertNull(row.purchase.linked)
+ assertNull(h.cardRepo().purchaseForExpense(ExpenseId("0192ffff-0000-7000-8000-00000000ffff")).first()?.linked)
+ }
+}
diff --git a/android/data/src/test/kotlin/se/hagstedt/kvitt/data/sync/CardSyncTest.kt b/android/data/src/test/kotlin/se/hagstedt/kvitt/data/sync/CardSyncTest.kt
new file mode 100644
index 0000000..975cfe1
--- /dev/null
+++ b/android/data/src/test/kotlin/se/hagstedt/kvitt/data/sync/CardSyncTest.kt
@@ -0,0 +1,222 @@
+package se.hagstedt.kvitt.data.sync
+
+import io.ktor.http.HttpStatusCode
+import kotlinx.coroutines.flow.first
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import se.hagstedt.kvitt.data.FakeCardServer.Companion.CONNECTION
+import se.hagstedt.kvitt.data.Harness
+import se.hagstedt.kvitt.data.addExpense
+import se.hagstedt.kvitt.data.api.ErrorDto
+import se.hagstedt.kvitt.data.api.KvittJson
+import se.hagstedt.kvitt.data.cardRepo
+import se.hagstedt.kvitt.data.cards.CardCycle
+import se.hagstedt.kvitt.data.expenseRepo
+import se.hagstedt.kvitt.data.runSync
+import se.hagstedt.kvitt.model.CardPurchaseId
+import se.hagstedt.kvitt.model.ExpenseId
+
+/**
+ * The card cycle (SPEC §14.2.4 `:data`, §14.2.1, CP-9) against the fake card server behind Ktor's MockEngine: paging,
+ * tombstones, pending rows skipped on pull, the E-44 outcomes, a server without card support, `cursor_ahead` and the
+ * household epoch change, and its place after the household push.
+ */
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [36])
+class CardSyncTest {
+ private lateinit var h: Harness
+
+ @Before
+ fun setUp() {
+ h = Harness().ready()
+ h.server.cards.supported = true
+ }
+
+ @After
+ fun tearDown() = h.close()
+
+ private val cards get() = h.server.cards
+
+ @Test
+ fun pull_pagesUntilHasMoreFalse_storesRowsAndTheCursor() = runSync {
+ cards.addConnection()
+ cards.addPurchase("p1")
+ cards.addPurchase("p2")
+ cards.addPurchase("p3")
+ cards.maxPage = 2
+ assertEquals(CardCycle.Success, h.engine.cards.cycle())
+ // Four rows (the connection and three purchases) in pages of two.
+ assertEquals(listOf(0L, 2L), cards.pulls)
+ assertEquals(listOf("p1", "p2", "p3"), h.db.cards().purchases().map { it.id }.sorted())
+ assertEquals(CONNECTION, h.db.cards().connections().single().id)
+ assertEquals(4L, h.db.cards().syncState()?.cursor)
+ assertTrue(h.db.cards().syncState()?.lastSuccessAt != null)
+ }
+
+ @Test
+ fun pull_aTombstoneDeletesTheLocalRow_andAnOlderVersionIsNotApplied() = runSync {
+ cards.addConnection()
+ cards.addPurchase("p1")
+ cards.addPurchase("p2")
+ h.engine.cards.cycle()
+ cards.deletePurchase("p1")
+ h.engine.cards.cycle()
+ assertEquals(listOf("p2"), h.db.cards().purchases().map { it.id })
+ }
+
+ @Test
+ fun pull_skipsAPendingLocalRow() = runSync {
+ cards.addConnection()
+ cards.addPurchase("p1", merchant = "ICA")
+ h.engine.cards.cycle()
+ h.cardRepo().setIgnored(CardPurchaseId("p1"), true)
+ // The server changes the row meanwhile; the pending local change is not overwritten by the pull.
+ cards.purchases["p1"] = cards.purchases.getValue("p1").copy(merchant = "ICA MAXI", cardVersion = cards.next())
+ cards.putOverride = { _, _ -> HttpStatusCode.ServiceUnavailable to KvittJson.encodeToString(ErrorDto.serializer(), ErrorDto("unavailable", "x", null)) }
+ assertEquals(CardCycle.Failed("unavailable"), h.engine.cards.cycle())
+ cards.putOverride = null
+ val local = h.db.cards().purchase("p1")
+ assertEquals(true, local?.pending)
+ assertEquals(true, local?.ignored)
+ assertEquals("ICA", local?.merchant)
+ }
+
+ @Test
+ fun push_200OverwritesTheRowAndClearsPending() = runSync {
+ cards.addConnection()
+ cards.addPurchase("p1")
+ h.engine.cards.cycle()
+ h.cardRepo().setIgnored(CardPurchaseId("p1"), true)
+ assertEquals(CardCycle.Success, h.engine.cards.cycle())
+ assertEquals(listOf("p1"), cards.puts.map { it.first })
+ assertEquals(true, cards.puts.single().second.ignored)
+ val local = h.db.cards().purchase("p1")
+ assertEquals(false, local?.pending)
+ assertEquals(cards.purchases.getValue("p1").cardVersion, local?.cardVersion)
+ }
+
+ @Test
+ fun push_aLinkToAnExpenseNotOnTheServerYetStaysPending_andGoesOutAfterTheHouseholdPush() = runSync {
+ cards.addConnection()
+ cards.addPurchase("p1")
+ h.engine.cards.cycle()
+ val expense = h.addExpense("ICA MAXI LINDHAGEN")
+ h.cardRepo().link(CardPurchaseId("p1"), ExpenseId(expense))
+ // The card cycle alone: the expense is still only on the phone, E-44 answers not_found with field expense_id.
+ h.engine.cards.cycle()
+ assertEquals(true, h.db.cards().purchase("p1")?.pending)
+ assertEquals(expense, h.db.cards().purchase("p1")?.expenseId)
+ // A household cycle pushes the expense first, then the card cycle sends the link.
+ assertEquals(SyncOutcome.Success, h.engine.sync())
+ assertEquals(expense, cards.purchases.getValue("p1").expenseId)
+ assertEquals(false, h.db.cards().purchase("p1")?.pending)
+ val order = h.requests.map { it.method.value + " " + it.url.encodedPath }
+ assertTrue(order.indexOf("POST /sync") < order.lastIndexOf("PUT /cards/purchases/p1"))
+ }
+
+ @Test
+ fun push_notFoundForThePurchaseDeletesTheLocalRow() = runSync {
+ cards.addConnection()
+ cards.addPurchase("p1")
+ h.engine.cards.cycle()
+ h.cardRepo().setIgnored(CardPurchaseId("p1"), true)
+ cards.purchases.remove("p1")
+ h.engine.cards.cycle()
+ assertNull(h.db.cards().purchase("p1"))
+ }
+
+ @Test
+ fun push_expenseAlreadyLinkedTakesTheServersStateAtTheNextPull() = runSync {
+ cards.addConnection()
+ val expense = h.addExpense("Coop")
+ h.engine.sync()
+ cards.addPurchase("p1")
+ cards.addPurchase("p2", expenseId = expense)
+ h.engine.cards.cycle()
+ h.cardRepo().link(CardPurchaseId("p1"), ExpenseId(expense))
+ h.engine.cards.cycle()
+ assertEquals(1, cards.puts.size)
+ val local = h.db.cards().purchase("p1")
+ assertEquals(false, local?.pending)
+ // The server's row did not change, yet the phone has its state again: no link.
+ assertNull(local?.expenseId)
+ assertEquals(cards.purchases.getValue("p1").cardVersion, local?.cardVersion)
+ }
+
+ @Test
+ fun push_aLinkToAnExpenseDeletedOnThePhoneIsClearedBeforeItIsSent() = runSync {
+ cards.addConnection()
+ cards.addPurchase("p1")
+ h.engine.cards.cycle()
+ val expense = h.addExpense("ICA")
+ h.cardRepo().link(CardPurchaseId("p1"), ExpenseId(expense))
+ h.expenseRepo().delete(ExpenseId(expense))
+ assertNull(h.db.cards().purchase("p1")?.expenseId)
+ h.engine.sync()
+ assertNull(cards.purchases.getValue("p1").expenseId)
+ assertEquals(false, h.db.cards().purchase("p1")?.pending)
+ }
+
+ @Test
+ fun aServerWithoutCardSupportAnswers404_theTablesStayEmpty_andTheHouseholdSyncSucceeds() = runSync {
+ cards.supported = false
+ cards.addConnection()
+ cards.addPurchase("p1")
+ assertEquals(CardCycle.NoSupport, h.engine.cards.cycle())
+ assertEquals(SyncOutcome.Success, h.engine.sync())
+ assertTrue(h.db.cards().purchases().isEmpty())
+ assertTrue(h.db.cards().connections().isEmpty())
+ assertFalse(h.cardRepo().hasConnection.first())
+ }
+
+ @Test
+ fun cursorAhead_dropsTheCardCacheKeepingPendingRows_andPullsFromZero() = runSync {
+ cards.addConnection()
+ cards.addPurchase("p1")
+ cards.addPurchase("p2")
+ h.engine.cards.cycle()
+ h.cardRepo().setIgnored(CardPurchaseId("p2"), true)
+ // The pending change cannot go out yet (it stays pending), and the phone's cursor is ahead of the server's.
+ cards.putOverride = { _, _ -> HttpStatusCode.NotFound to KvittJson.encodeToString(ErrorDto.serializer(), ErrorDto("not_found", "x", "expense_id")) }
+ h.db.cards().upsertSyncState(requireNotNull(h.db.cards().syncState()).copy(cursor = 99))
+ cards.pulls.clear()
+ assertEquals(CardCycle.Success, h.engine.cards.cycle())
+ assertEquals(listOf(99L, 0L), cards.pulls)
+ assertEquals(setOf("p1", "p2"), h.db.cards().purchases().map { it.id }.toSet())
+ assertEquals(true, h.db.cards().purchase("p2")?.pending)
+ assertEquals(true, h.db.cards().purchase("p2")?.ignored)
+ assertEquals(CONNECTION, h.db.cards().connections().single().id)
+ }
+
+ @Test
+ fun aHouseholdEpochChangeDropsTheCardCache_andTheNextCycleRefillsIt() = runSync {
+ cards.addConnection()
+ cards.addPurchase("p1")
+ h.engine.sync()
+ assertEquals(1, h.db.cards().purchases().size)
+ h.server.currentEpoch = "epoch-2"
+ cards.pulls.clear()
+ assertEquals(SyncOutcome.Success, h.engine.sync())
+ assertEquals(0L, cards.pulls.first())
+ assertEquals(listOf("p1"), h.db.cards().purchases().map { it.id })
+ }
+
+ @Test
+ fun theRevokeWipeDeletesTheCardTables() = runSync {
+ cards.addConnection()
+ cards.addPurchase("p1")
+ h.engine.cards.cycle()
+ h.wipe.wipe()
+ assertTrue(h.db.cards().purchases().isEmpty())
+ assertTrue(h.db.cards().connections().isEmpty())
+ assertNull(h.db.cards().syncState())
+ }
+}
diff --git a/android/domain/src/main/kotlin/se/hagstedt/kvitt/domain/CardMatch.kt b/android/domain/src/main/kotlin/se/hagstedt/kvitt/domain/CardMatch.kt
new file mode 100644
index 0000000..bfe0366
--- /dev/null
+++ b/android/domain/src/main/kotlin/se/hagstedt/kvitt/domain/CardMatch.kt
@@ -0,0 +1,37 @@
+package se.hagstedt.kvitt.domain
+
+import kotlinx.datetime.LocalDate
+import kotlin.math.abs
+
+// Card purchase matches, SPEC §14.5.8 (the rule of §9.12 CP-10, D-170). Pure: dates are passed in, no clock.
+
+/** A purchase to match: [direction] is the wire value `debit` or `credit`; [amountOre] null for a foreign currency. */
+data class MatchablePurchase(val amountOre: Long?, val date: LocalDate, val direction: String)
+
+/** A non-deleted household expense; [linkedByViewer]: another purchase of the viewer already links it. */
+data class MatchableExpense(val id: String, val amountOre: Long, val date: LocalDate, val createdAt: String, val linkedByViewer: Boolean)
+
+/** CP-10 calls [matchCardPurchase] with these. */
+const val CARD_MATCH_WINDOW_DAYS = 3
+const val CARD_MATCH_MAX = 3
+
+/**
+ * The ids of the [expenses] with the purchase's amount, `|date − purchase.date| ≤ windowDays` and not linked by the
+ * viewer, ordered by the absolute day difference, then `createdAt` descending, then id ascending, at most [max]. Empty
+ * for a `credit` purchase or a null amount. Callers pass non-deleted expenses only.
+ */
+fun matchCardPurchase(purchase: MatchablePurchase, expenses: List, windowDays: Int, max: Int): List {
+ val amount = purchase.amountOre ?: return emptyList()
+ if (purchase.direction != DEBIT) return emptyList()
+ val day = purchase.date.toEpochDays()
+ return expenses.asSequence()
+ .filter { it.amountOre == amount && !it.linkedByViewer }
+ .map { it to abs(it.date.toEpochDays() - day) }
+ .filter { (_, distance) -> distance <= windowDays }
+ .sortedWith(compareBy> { it.second }.thenByDescending { it.first.createdAt }.thenBy { it.first.id })
+ .take(max.coerceAtLeast(0))
+ .map { it.first.id }
+ .toList()
+}
+
+private const val DEBIT = "debit"
diff --git a/android/domain/src/test/kotlin/se/hagstedt/kvitt/domain/CardMatchTest.kt b/android/domain/src/test/kotlin/se/hagstedt/kvitt/domain/CardMatchTest.kt
new file mode 100644
index 0000000..8b770c1
--- /dev/null
+++ b/android/domain/src/test/kotlin/se/hagstedt/kvitt/domain/CardMatchTest.kt
@@ -0,0 +1,58 @@
+package se.hagstedt.kvitt.domain
+
+import kotlinx.datetime.LocalDate
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+/** SPEC §9.12 CP-10 and §14.5.8 through [matchCardPurchase]; the shared cases are `match_card_purchase.json` (VectorTest). */
+class CardMatchTest {
+ private val day = LocalDate(2026, 10, 16)
+ private fun purchase(amount: Long? = 41_200, date: LocalDate = day, direction: String = "debit") = MatchablePurchase(amount, date, direction)
+ private fun expense(id: String, amount: Long = 41_200, date: LocalDate = day, createdAt: String = "2026-10-16T10:00:00.000Z", linked: Boolean = false) =
+ MatchableExpense(id, amount, date, createdAt, linked)
+
+ private fun match(p: MatchablePurchase, vararg e: MatchableExpense) = matchCardPurchase(p, e.toList(), CARD_MATCH_WINDOW_DAYS, CARD_MATCH_MAX)
+
+ @Test
+ fun sameAmountWithinThreeDaysMatches_fourDaysDoesNot() {
+ assertEquals(
+ listOf("a", "b", "c"),
+ match(
+ purchase(),
+ expense("a", date = day),
+ expense("b", date = LocalDate(2026, 10, 13)),
+ expense("c", date = LocalDate(2026, 10, 19)),
+ expense("d", date = LocalDate(2026, 10, 20)),
+ ),
+ )
+ }
+
+ @Test
+ fun aDifferentAmountOrAnExpenseLinkedByTheViewerIsLeftOut() {
+ assertEquals(emptyList(), match(purchase(), expense("a", amount = 41_201), expense("b", linked = true)))
+ }
+
+ @Test
+ fun orderedByDayDifferenceThenNewestCreatedThenId_cutAtMax() {
+ val result = match(
+ purchase(),
+ expense("z", date = LocalDate(2026, 10, 17), createdAt = "2026-10-17T08:00:00.000Z"),
+ expense("y", date = LocalDate(2026, 10, 15), createdAt = "2026-10-17T09:00:00.000Z"),
+ expense("b", date = day, createdAt = "2026-10-16T08:00:00.000Z"),
+ expense("a", date = day, createdAt = "2026-10-16T08:00:00.000Z"),
+ )
+ assertEquals(listOf("a", "b", "y"), result)
+ }
+
+ @Test
+ fun aCreditOrAForeignPurchaseHasNoMatches() {
+ assertEquals(emptyList(), match(purchase(direction = "credit"), expense("a")))
+ assertEquals(emptyList(), match(purchase(amount = null), expense("a")))
+ }
+
+ @Test
+ fun theWindowCrossesAYearBoundary() {
+ val p = purchase(date = LocalDate(2027, 1, 1))
+ assertEquals(listOf("a"), match(p, expense("a", date = LocalDate(2026, 12, 29)), expense("b", date = LocalDate(2026, 12, 28))))
+ }
+}
diff --git a/android/domain/src/test/kotlin/se/hagstedt/kvitt/domain/VectorFilesTest.kt b/android/domain/src/test/kotlin/se/hagstedt/kvitt/domain/VectorFilesTest.kt
index 934cf46..936f3eb 100644
--- a/android/domain/src/test/kotlin/se/hagstedt/kvitt/domain/VectorFilesTest.kt
+++ b/android/domain/src/test/kotlin/se/hagstedt/kvitt/domain/VectorFilesTest.kt
@@ -5,7 +5,7 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
-/** The vector directory holds exactly the eight files of SPEC §14.5.5; a missing or untested file fails. */
+/** The vector directory holds exactly the files of SPEC §14.5.5 ([VECTOR_FUNCTIONS]); a missing or untested file fails. */
class VectorFilesTest {
@Test
fun everyVectorFileIsPresentAndTested() {
diff --git a/android/domain/src/test/kotlin/se/hagstedt/kvitt/domain/VectorTest.kt b/android/domain/src/test/kotlin/se/hagstedt/kvitt/domain/VectorTest.kt
index a92122b..9f59f75 100644
--- a/android/domain/src/test/kotlin/se/hagstedt/kvitt/domain/VectorTest.kt
+++ b/android/domain/src/test/kotlin/se/hagstedt/kvitt/domain/VectorTest.kt
@@ -50,6 +50,7 @@ class VectorTest(private val case: VectorCase, private val label: String) {
"spend_series" -> series(input)
"resolve_range" -> range(input)
"suggest_category" -> suggest(input)
+ "match_card_purchase" -> matchCard(input)
else -> error("no runner for $function")
}
@@ -164,6 +165,18 @@ class VectorTest(private val case: VectorCase, private val label: String) {
return ok(r?.let { JsonPrimitive(it.value) } ?: JsonNull)
}
+ /** §14.5.8: input `purchase` {amount_ore, date, direction}, `expenses`, `window_days`, `max`; output {"ok": [ids]}. */
+ private fun matchCard(input: JsonObject): JsonElement {
+ val p = input.getValue("purchase").jsonObject
+ val purchase = MatchablePurchase(p.longOrNull("amount_ore"), LocalDate.parse(p.str("date")), p.str("direction"))
+ val expenses = input.objects("expenses").map {
+ MatchableExpense(it.str("id"), it.long("amount_ore"), LocalDate.parse(it.str("date")), it.str("created_at"), it.bool("linked_by_viewer"))
+ }
+ val window = input.getValue("window_days").jsonPrimitive.content.toInt()
+ val max = input.getValue("max").jsonPrimitive.content.toInt()
+ return ok(strings(matchCardPurchase(purchase, expenses, window, max)))
+ }
+
private fun range(input: JsonObject): JsonElement {
val preset = RangePreset.valueOf(input.str("preset").uppercase())
fun date(key: String) = input.strOrNull(key)?.let { LocalDate.parse(it) }
diff --git a/android/domain/src/test/kotlin/se/hagstedt/kvitt/domain/Vectors.kt b/android/domain/src/test/kotlin/se/hagstedt/kvitt/domain/Vectors.kt
index c7d89de..48ac982 100644
--- a/android/domain/src/test/kotlin/se/hagstedt/kvitt/domain/Vectors.kt
+++ b/android/domain/src/test/kotlin/se/hagstedt/kvitt/domain/Vectors.kt
@@ -13,10 +13,10 @@ import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.long
-/** The eight vector files of SPEC §14.5.5 (with suggest_category, UX-3), keyed by function name. */
+/** The vector files of SPEC §14.5.5 (with suggest_category, UX-3, and match_card_purchase, §14.5.8), keyed by function name. */
val VECTOR_FUNCTIONS: List = listOf(
"compute_split", "compute_balances", "propose_allocation", "validate_settlement", "over_settled_accounts",
- "spend_series", "resolve_range", "suggest_category",
+ "spend_series", "resolve_range", "suggest_category", "match_card_purchase",
)
data class VectorCase(val function: String, val name: String, val input: JsonObject, val expected: JsonElement)
@@ -46,6 +46,7 @@ object Vectors {
internal fun JsonObject.str(key: String): String = getValue(key).jsonPrimitive.content
internal fun JsonObject.strOrNull(key: String): String? = (get(key) as? JsonPrimitive)?.takeIf { it !is JsonNull }?.content
internal fun JsonObject.long(key: String): Long = getValue(key).jsonPrimitive.long
+internal fun JsonObject.longOrNull(key: String): Long? = (get(key) as? JsonPrimitive)?.takeIf { it !is JsonNull }?.long
internal fun JsonObject.bool(key: String): Boolean = getValue(key).jsonPrimitive.boolean
internal fun JsonObject.strings(key: String): List = getValue(key).jsonArray.map { it.jsonPrimitive.content }
internal fun JsonObject.longMap(key: String): Map = getValue(key).jsonObject.mapValues { it.value.jsonPrimitive.long }
diff --git a/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountDetailScreen.kt b/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountDetailScreen.kt
index 9ff7074..3635284 100644
--- a/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountDetailScreen.kt
+++ b/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountDetailScreen.kt
@@ -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)
diff --git a/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountDetailViewModel.kt b/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountDetailViewModel.kt
index 9b6cb4a..37d5990 100644
--- a/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountDetailViewModel.kt
+++ b/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountDetailViewModel.kt
@@ -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(ARG_ACCOUNT_ID)) { "missing $ARG_ACCOUNT_ID" })
private val local = MutableStateFlow(AccountLocal())
- val ui: StateFlow = combine(accounts.detail(id), accounts.all(), connectivity.online, local) { detail, all, online, l ->
+ val ui: StateFlow = 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)
diff --git a/android/feature/account/src/main/res/values-sv/strings.xml b/android/feature/account/src/main/res/values-sv/strings.xml
index e9a480c..125517c 100644
--- a/android/feature/account/src/main/res/values-sv/strings.xml
+++ b/android/feature/account/src/main/res/values-sv/strings.xml
@@ -31,4 +31,6 @@
Konton sparas direkt på servern. Anslut telefonen till internet för att spara ändringen.
Det finns redan ett konto som heter %1$s
+
+ Från kortköp
diff --git a/android/feature/account/src/main/res/values/strings.xml b/android/feature/account/src/main/res/values/strings.xml
index 553d48e..8e3bd8b 100644
--- a/android/feature/account/src/main/res/values/strings.xml
+++ b/android/feature/account/src/main/res/values/strings.xml
@@ -31,4 +31,6 @@
Accounts are saved straight to the server. Connect the phone to the internet to save the change.
There is already an account called %1$s
+
+ From card purchases
diff --git a/android/feature/account/src/test/kotlin/se/hagstedt/kvitt/feature/account/AccountScreenshotTest.kt b/android/feature/account/src/test/kotlin/se/hagstedt/kvitt/feature/account/AccountScreenshotTest.kt
index 2442c4a..7153e43 100644
--- a/android/feature/account/src/test/kotlin/se/hagstedt/kvitt/feature/account/AccountScreenshotTest.kt
+++ b/android/feature/account/src/test/kotlin/se/hagstedt/kvitt/feature/account/AccountScreenshotTest.kt
@@ -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 = 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> = 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",
diff --git a/android/feature/account/src/test/kotlin/se/hagstedt/kvitt/feature/account/AccountStates.kt b/android/feature/account/src/test/kotlin/se/hagstedt/kvitt/feature/account/AccountStates.kt
index 186fc91..b7f09f4 100644
--- a/android/feature/account/src/test/kotlin/se/hagstedt/kvitt/feature/account/AccountStates.kt
+++ b/android/feature/account/src/test/kotlin/se/hagstedt/kvitt/feature/account/AccountStates.kt
@@ -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
}
diff --git a/android/feature/account/src/test/kotlin/se/hagstedt/kvitt/feature/account/AccountViewModelTest.kt b/android/feature/account/src/test/kotlin/se/hagstedt/kvitt/feature/account/AccountViewModelTest.kt
index caad181..cd74472 100644
--- a/android/feature/account/src/test/kotlin/se/hagstedt/kvitt/feature/account/AccountViewModelTest.kt
+++ b/android/feature/account/src/test/kotlin/se/hagstedt/kvitt/feature/account/AccountViewModelTest.kt
@@ -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 {
diff --git a/android/feature/account/src/test/screenshots/S-5-account-menu-card_en_light.png b/android/feature/account/src/test/screenshots/S-5-account-menu-card_en_light.png
new file mode 100644
index 0000000..a9fc36e
Binary files /dev/null and b/android/feature/account/src/test/screenshots/S-5-account-menu-card_en_light.png differ
diff --git a/android/feature/account/src/test/screenshots/S-5-account-menu-card_sv_dark.png b/android/feature/account/src/test/screenshots/S-5-account-menu-card_sv_dark.png
new file mode 100644
index 0000000..64dffb2
Binary files /dev/null and b/android/feature/account/src/test/screenshots/S-5-account-menu-card_sv_dark.png differ
diff --git a/android/feature/account/src/test/screenshots/S-5-account-menu-card_sv_light.png b/android/feature/account/src/test/screenshots/S-5-account-menu-card_sv_light.png
new file mode 100644
index 0000000..8e876ff
Binary files /dev/null and b/android/feature/account/src/test/screenshots/S-5-account-menu-card_sv_light.png differ
diff --git a/android/feature/analytics/src/test/screenshots/s11_by_account_en_light.png b/android/feature/analytics/src/test/screenshots/s11_by_account_en_light.png
index 722b504..2e7e99e 100644
Binary files a/android/feature/analytics/src/test/screenshots/s11_by_account_en_light.png and b/android/feature/analytics/src/test/screenshots/s11_by_account_en_light.png differ
diff --git a/android/feature/analytics/src/test/screenshots/s11_by_account_sv_dark.png b/android/feature/analytics/src/test/screenshots/s11_by_account_sv_dark.png
index 688f0ca..51f8842 100644
Binary files a/android/feature/analytics/src/test/screenshots/s11_by_account_sv_dark.png and b/android/feature/analytics/src/test/screenshots/s11_by_account_sv_dark.png differ
diff --git a/android/feature/analytics/src/test/screenshots/s11_by_account_sv_light.png b/android/feature/analytics/src/test/screenshots/s11_by_account_sv_light.png
index 8c896c0..dbb49d4 100644
Binary files a/android/feature/analytics/src/test/screenshots/s11_by_account_sv_light.png and b/android/feature/analytics/src/test/screenshots/s11_by_account_sv_light.png differ
diff --git a/android/feature/analytics/src/test/screenshots/s11_empty_en_light.png b/android/feature/analytics/src/test/screenshots/s11_empty_en_light.png
index e9309a0..a5a0bae 100644
Binary files a/android/feature/analytics/src/test/screenshots/s11_empty_en_light.png and b/android/feature/analytics/src/test/screenshots/s11_empty_en_light.png differ
diff --git a/android/feature/analytics/src/test/screenshots/s11_empty_sv_dark.png b/android/feature/analytics/src/test/screenshots/s11_empty_sv_dark.png
index fd5f176..4148afc 100644
Binary files a/android/feature/analytics/src/test/screenshots/s11_empty_sv_dark.png and b/android/feature/analytics/src/test/screenshots/s11_empty_sv_dark.png differ
diff --git a/android/feature/analytics/src/test/screenshots/s11_empty_sv_light.png b/android/feature/analytics/src/test/screenshots/s11_empty_sv_light.png
index 7fe9a3e..3655f07 100644
Binary files a/android/feature/analytics/src/test/screenshots/s11_empty_sv_light.png and b/android/feature/analytics/src/test/screenshots/s11_empty_sv_light.png differ
diff --git a/android/feature/analytics/src/test/screenshots/s11_insights_screen_en_light.png b/android/feature/analytics/src/test/screenshots/s11_insights_screen_en_light.png
index 745c18f..56ad6ef 100644
Binary files a/android/feature/analytics/src/test/screenshots/s11_insights_screen_en_light.png and b/android/feature/analytics/src/test/screenshots/s11_insights_screen_en_light.png differ
diff --git a/android/feature/analytics/src/test/screenshots/s11_insights_screen_sv_dark.png b/android/feature/analytics/src/test/screenshots/s11_insights_screen_sv_dark.png
index 9b5e666..c4ebf32 100644
Binary files a/android/feature/analytics/src/test/screenshots/s11_insights_screen_sv_dark.png and b/android/feature/analytics/src/test/screenshots/s11_insights_screen_sv_dark.png differ
diff --git a/android/feature/analytics/src/test/screenshots/s11_insights_screen_sv_light.png b/android/feature/analytics/src/test/screenshots/s11_insights_screen_sv_light.png
index 1825c73..80d6e96 100644
Binary files a/android/feature/analytics/src/test/screenshots/s11_insights_screen_sv_light.png and b/android/feature/analytics/src/test/screenshots/s11_insights_screen_sv_light.png differ
diff --git a/android/feature/analytics/src/test/screenshots/s11_loaded_en_light.png b/android/feature/analytics/src/test/screenshots/s11_loaded_en_light.png
index e8d0527..8d1e03b 100644
Binary files a/android/feature/analytics/src/test/screenshots/s11_loaded_en_light.png and b/android/feature/analytics/src/test/screenshots/s11_loaded_en_light.png differ
diff --git a/android/feature/analytics/src/test/screenshots/s11_loaded_sv_dark.png b/android/feature/analytics/src/test/screenshots/s11_loaded_sv_dark.png
index 44dbbc5..192aa29 100644
Binary files a/android/feature/analytics/src/test/screenshots/s11_loaded_sv_dark.png and b/android/feature/analytics/src/test/screenshots/s11_loaded_sv_dark.png differ
diff --git a/android/feature/analytics/src/test/screenshots/s11_loaded_sv_light.png b/android/feature/analytics/src/test/screenshots/s11_loaded_sv_light.png
index 33b4d28..94fe589 100644
Binary files a/android/feature/analytics/src/test/screenshots/s11_loaded_sv_light.png and b/android/feature/analytics/src/test/screenshots/s11_loaded_sv_light.png differ
diff --git a/android/feature/analytics/src/test/screenshots/s11_loading_en_light.png b/android/feature/analytics/src/test/screenshots/s11_loading_en_light.png
index cb9f1c0..435813f 100644
Binary files a/android/feature/analytics/src/test/screenshots/s11_loading_en_light.png and b/android/feature/analytics/src/test/screenshots/s11_loading_en_light.png differ
diff --git a/android/feature/analytics/src/test/screenshots/s11_loading_sv_dark.png b/android/feature/analytics/src/test/screenshots/s11_loading_sv_dark.png
index f9d2c14..8a92de3 100644
Binary files a/android/feature/analytics/src/test/screenshots/s11_loading_sv_dark.png and b/android/feature/analytics/src/test/screenshots/s11_loading_sv_dark.png differ
diff --git a/android/feature/analytics/src/test/screenshots/s11_loading_sv_light.png b/android/feature/analytics/src/test/screenshots/s11_loading_sv_light.png
index e870fb8..8d8f8d4 100644
Binary files a/android/feature/analytics/src/test/screenshots/s11_loading_sv_light.png and b/android/feature/analytics/src/test/screenshots/s11_loading_sv_light.png differ
diff --git a/android/feature/analytics/src/test/screenshots/s11_long_en_light.png b/android/feature/analytics/src/test/screenshots/s11_long_en_light.png
index 18706e6..8013f6b 100644
Binary files a/android/feature/analytics/src/test/screenshots/s11_long_en_light.png and b/android/feature/analytics/src/test/screenshots/s11_long_en_light.png differ
diff --git a/android/feature/analytics/src/test/screenshots/s11_long_scrolled_en_light.png b/android/feature/analytics/src/test/screenshots/s11_long_scrolled_en_light.png
index 294c4ea..b4b42f7 100644
Binary files a/android/feature/analytics/src/test/screenshots/s11_long_scrolled_en_light.png and b/android/feature/analytics/src/test/screenshots/s11_long_scrolled_en_light.png differ
diff --git a/android/feature/analytics/src/test/screenshots/s11_long_scrolled_sv_dark.png b/android/feature/analytics/src/test/screenshots/s11_long_scrolled_sv_dark.png
index 67f8f5b..4ee7e6e 100644
Binary files a/android/feature/analytics/src/test/screenshots/s11_long_scrolled_sv_dark.png and b/android/feature/analytics/src/test/screenshots/s11_long_scrolled_sv_dark.png differ
diff --git a/android/feature/analytics/src/test/screenshots/s11_long_scrolled_sv_light.png b/android/feature/analytics/src/test/screenshots/s11_long_scrolled_sv_light.png
index 572dda5..1c60738 100644
Binary files a/android/feature/analytics/src/test/screenshots/s11_long_scrolled_sv_light.png and b/android/feature/analytics/src/test/screenshots/s11_long_scrolled_sv_light.png differ
diff --git a/android/feature/analytics/src/test/screenshots/s11_long_sv_dark.png b/android/feature/analytics/src/test/screenshots/s11_long_sv_dark.png
index e02989a..439ac1b 100644
Binary files a/android/feature/analytics/src/test/screenshots/s11_long_sv_dark.png and b/android/feature/analytics/src/test/screenshots/s11_long_sv_dark.png differ
diff --git a/android/feature/analytics/src/test/screenshots/s11_long_sv_light.png b/android/feature/analytics/src/test/screenshots/s11_long_sv_light.png
index fdbe94e..8e28323 100644
Binary files a/android/feature/analytics/src/test/screenshots/s11_long_sv_light.png and b/android/feature/analytics/src/test/screenshots/s11_long_sv_light.png differ
diff --git a/android/feature/analytics/src/test/screenshots/s11_long_sv_light_200.png b/android/feature/analytics/src/test/screenshots/s11_long_sv_light_200.png
index f73d3d6..cfb7789 100644
Binary files a/android/feature/analytics/src/test/screenshots/s11_long_sv_light_200.png and b/android/feature/analytics/src/test/screenshots/s11_long_sv_light_200.png differ
diff --git a/android/feature/analytics/src/test/screenshots/s11_my_share_en_light.png b/android/feature/analytics/src/test/screenshots/s11_my_share_en_light.png
index 217b68b..09cab70 100644
Binary files a/android/feature/analytics/src/test/screenshots/s11_my_share_en_light.png and b/android/feature/analytics/src/test/screenshots/s11_my_share_en_light.png differ
diff --git a/android/feature/analytics/src/test/screenshots/s11_my_share_sv_dark.png b/android/feature/analytics/src/test/screenshots/s11_my_share_sv_dark.png
index 538ea6b..548fdef 100644
Binary files a/android/feature/analytics/src/test/screenshots/s11_my_share_sv_dark.png and b/android/feature/analytics/src/test/screenshots/s11_my_share_sv_dark.png differ
diff --git a/android/feature/analytics/src/test/screenshots/s11_my_share_sv_light.png b/android/feature/analytics/src/test/screenshots/s11_my_share_sv_light.png
index 7a9fdd8..1b2376d 100644
Binary files a/android/feature/analytics/src/test/screenshots/s11_my_share_sv_light.png and b/android/feature/analytics/src/test/screenshots/s11_my_share_sv_light.png differ
diff --git a/android/feature/analytics/src/test/screenshots/s11_offline_en_light.png b/android/feature/analytics/src/test/screenshots/s11_offline_en_light.png
index 4964005..830d0e6 100644
Binary files a/android/feature/analytics/src/test/screenshots/s11_offline_en_light.png and b/android/feature/analytics/src/test/screenshots/s11_offline_en_light.png differ
diff --git a/android/feature/analytics/src/test/screenshots/s11_offline_sv_dark.png b/android/feature/analytics/src/test/screenshots/s11_offline_sv_dark.png
index 3fb7100..941a520 100644
Binary files a/android/feature/analytics/src/test/screenshots/s11_offline_sv_dark.png and b/android/feature/analytics/src/test/screenshots/s11_offline_sv_dark.png differ
diff --git a/android/feature/analytics/src/test/screenshots/s11_offline_sv_light.png b/android/feature/analytics/src/test/screenshots/s11_offline_sv_light.png
index 913b758..5d355b8 100644
Binary files a/android/feature/analytics/src/test/screenshots/s11_offline_sv_light.png and b/android/feature/analytics/src/test/screenshots/s11_offline_sv_light.png differ
diff --git a/android/feature/analytics/src/test/screenshots/s11_preset_all_time_en_light.png b/android/feature/analytics/src/test/screenshots/s11_preset_all_time_en_light.png
index b7fbe9e..e6a711e 100644
Binary files a/android/feature/analytics/src/test/screenshots/s11_preset_all_time_en_light.png and b/android/feature/analytics/src/test/screenshots/s11_preset_all_time_en_light.png differ
diff --git a/android/feature/analytics/src/test/screenshots/s11_preset_all_time_sv_dark.png b/android/feature/analytics/src/test/screenshots/s11_preset_all_time_sv_dark.png
index 24c8909..d7ae962 100644
Binary files a/android/feature/analytics/src/test/screenshots/s11_preset_all_time_sv_dark.png and b/android/feature/analytics/src/test/screenshots/s11_preset_all_time_sv_dark.png differ
diff --git a/android/feature/analytics/src/test/screenshots/s11_preset_all_time_sv_light.png b/android/feature/analytics/src/test/screenshots/s11_preset_all_time_sv_light.png
index c5259d2..55b1a28 100644
Binary files a/android/feature/analytics/src/test/screenshots/s11_preset_all_time_sv_light.png and b/android/feature/analytics/src/test/screenshots/s11_preset_all_time_sv_light.png differ
diff --git a/android/feature/analytics/src/test/screenshots/s11_preset_last_12_months_en_light.png b/android/feature/analytics/src/test/screenshots/s11_preset_last_12_months_en_light.png
index 38a334f..6969d9a 100644
Binary files a/android/feature/analytics/src/test/screenshots/s11_preset_last_12_months_en_light.png and b/android/feature/analytics/src/test/screenshots/s11_preset_last_12_months_en_light.png differ
diff --git a/android/feature/analytics/src/test/screenshots/s11_preset_last_12_months_sv_dark.png b/android/feature/analytics/src/test/screenshots/s11_preset_last_12_months_sv_dark.png
index be0f613..d17256e 100644
Binary files a/android/feature/analytics/src/test/screenshots/s11_preset_last_12_months_sv_dark.png and b/android/feature/analytics/src/test/screenshots/s11_preset_last_12_months_sv_dark.png differ
diff --git a/android/feature/analytics/src/test/screenshots/s11_preset_last_12_months_sv_light.png b/android/feature/analytics/src/test/screenshots/s11_preset_last_12_months_sv_light.png
index 3d1b801..5b74dfd 100644
Binary files a/android/feature/analytics/src/test/screenshots/s11_preset_last_12_months_sv_light.png and b/android/feature/analytics/src/test/screenshots/s11_preset_last_12_months_sv_light.png differ
diff --git a/android/feature/analytics/src/test/screenshots/s11_preset_last_3_months_en_light.png b/android/feature/analytics/src/test/screenshots/s11_preset_last_3_months_en_light.png
index a3d44c8..462a4e9 100644
Binary files a/android/feature/analytics/src/test/screenshots/s11_preset_last_3_months_en_light.png and b/android/feature/analytics/src/test/screenshots/s11_preset_last_3_months_en_light.png differ
diff --git a/android/feature/analytics/src/test/screenshots/s11_preset_last_3_months_sv_dark.png b/android/feature/analytics/src/test/screenshots/s11_preset_last_3_months_sv_dark.png
index ebb7401..c59407f 100644
Binary files a/android/feature/analytics/src/test/screenshots/s11_preset_last_3_months_sv_dark.png and b/android/feature/analytics/src/test/screenshots/s11_preset_last_3_months_sv_dark.png differ
diff --git a/android/feature/analytics/src/test/screenshots/s11_preset_last_3_months_sv_light.png b/android/feature/analytics/src/test/screenshots/s11_preset_last_3_months_sv_light.png
index 7636640..db7d30e 100644
Binary files a/android/feature/analytics/src/test/screenshots/s11_preset_last_3_months_sv_light.png and b/android/feature/analytics/src/test/screenshots/s11_preset_last_3_months_sv_light.png differ
diff --git a/android/feature/analytics/src/test/screenshots/s11_preset_this_month_en_light.png b/android/feature/analytics/src/test/screenshots/s11_preset_this_month_en_light.png
index 60dbc10..68baab2 100644
Binary files a/android/feature/analytics/src/test/screenshots/s11_preset_this_month_en_light.png and b/android/feature/analytics/src/test/screenshots/s11_preset_this_month_en_light.png differ
diff --git a/android/feature/analytics/src/test/screenshots/s11_preset_this_month_sv_dark.png b/android/feature/analytics/src/test/screenshots/s11_preset_this_month_sv_dark.png
index b6998a9..be089ed 100644
Binary files a/android/feature/analytics/src/test/screenshots/s11_preset_this_month_sv_dark.png and b/android/feature/analytics/src/test/screenshots/s11_preset_this_month_sv_dark.png differ
diff --git a/android/feature/analytics/src/test/screenshots/s11_preset_this_month_sv_light.png b/android/feature/analytics/src/test/screenshots/s11_preset_this_month_sv_light.png
index 5425873..5d22e89 100644
Binary files a/android/feature/analytics/src/test/screenshots/s11_preset_this_month_sv_light.png and b/android/feature/analytics/src/test/screenshots/s11_preset_this_month_sv_light.png differ
diff --git a/android/feature/analytics/src/test/screenshots/s11_preset_this_year_en_light.png b/android/feature/analytics/src/test/screenshots/s11_preset_this_year_en_light.png
index 15b71c0..9e1f305 100644
Binary files a/android/feature/analytics/src/test/screenshots/s11_preset_this_year_en_light.png and b/android/feature/analytics/src/test/screenshots/s11_preset_this_year_en_light.png differ
diff --git a/android/feature/analytics/src/test/screenshots/s11_preset_this_year_sv_dark.png b/android/feature/analytics/src/test/screenshots/s11_preset_this_year_sv_dark.png
index 7ee5c29..48ba02f 100644
Binary files a/android/feature/analytics/src/test/screenshots/s11_preset_this_year_sv_dark.png and b/android/feature/analytics/src/test/screenshots/s11_preset_this_year_sv_dark.png differ
diff --git a/android/feature/analytics/src/test/screenshots/s11_preset_this_year_sv_light.png b/android/feature/analytics/src/test/screenshots/s11_preset_this_year_sv_light.png
index 05242f4..7edaa1b 100644
Binary files a/android/feature/analytics/src/test/screenshots/s11_preset_this_year_sv_light.png and b/android/feature/analytics/src/test/screenshots/s11_preset_this_year_sv_light.png differ
diff --git a/android/feature/analytics/src/test/screenshots/s11_scrolled_en_light.png b/android/feature/analytics/src/test/screenshots/s11_scrolled_en_light.png
index 4f7c82f..3207408 100644
Binary files a/android/feature/analytics/src/test/screenshots/s11_scrolled_en_light.png and b/android/feature/analytics/src/test/screenshots/s11_scrolled_en_light.png differ
diff --git a/android/feature/analytics/src/test/screenshots/s11_scrolled_sv_dark.png b/android/feature/analytics/src/test/screenshots/s11_scrolled_sv_dark.png
index ec53c5c..1d3adac 100644
Binary files a/android/feature/analytics/src/test/screenshots/s11_scrolled_sv_dark.png and b/android/feature/analytics/src/test/screenshots/s11_scrolled_sv_dark.png differ
diff --git a/android/feature/analytics/src/test/screenshots/s11_scrolled_sv_light.png b/android/feature/analytics/src/test/screenshots/s11_scrolled_sv_light.png
index f4ced8a..c19f5ca 100644
Binary files a/android/feature/analytics/src/test/screenshots/s11_scrolled_sv_light.png and b/android/feature/analytics/src/test/screenshots/s11_scrolled_sv_light.png differ
diff --git a/android/feature/analytics/src/test/screenshots/s11_week_by_category_en_light.png b/android/feature/analytics/src/test/screenshots/s11_week_by_category_en_light.png
index 3246cd3..2964cde 100644
Binary files a/android/feature/analytics/src/test/screenshots/s11_week_by_category_en_light.png and b/android/feature/analytics/src/test/screenshots/s11_week_by_category_en_light.png differ
diff --git a/android/feature/analytics/src/test/screenshots/s11_week_by_category_sv_dark.png b/android/feature/analytics/src/test/screenshots/s11_week_by_category_sv_dark.png
index 875b152..21ad77c 100644
Binary files a/android/feature/analytics/src/test/screenshots/s11_week_by_category_sv_dark.png and b/android/feature/analytics/src/test/screenshots/s11_week_by_category_sv_dark.png differ
diff --git a/android/feature/analytics/src/test/screenshots/s11_week_by_category_sv_light.png b/android/feature/analytics/src/test/screenshots/s11_week_by_category_sv_light.png
index 7735dd3..2085a70 100644
Binary files a/android/feature/analytics/src/test/screenshots/s11_week_by_category_sv_light.png and b/android/feature/analytics/src/test/screenshots/s11_week_by_category_sv_light.png differ
diff --git a/android/feature/analytics/src/test/screenshots/s11_week_en_light.png b/android/feature/analytics/src/test/screenshots/s11_week_en_light.png
index 8a8cf2e..0ddc3fa 100644
Binary files a/android/feature/analytics/src/test/screenshots/s11_week_en_light.png and b/android/feature/analytics/src/test/screenshots/s11_week_en_light.png differ
diff --git a/android/feature/analytics/src/test/screenshots/s11_week_sv_dark.png b/android/feature/analytics/src/test/screenshots/s11_week_sv_dark.png
index c0cdddc..ecd846f 100644
Binary files a/android/feature/analytics/src/test/screenshots/s11_week_sv_dark.png and b/android/feature/analytics/src/test/screenshots/s11_week_sv_dark.png differ
diff --git a/android/feature/analytics/src/test/screenshots/s11_week_sv_light.png b/android/feature/analytics/src/test/screenshots/s11_week_sv_light.png
index 4328456..655da0e 100644
Binary files a/android/feature/analytics/src/test/screenshots/s11_week_sv_light.png and b/android/feature/analytics/src/test/screenshots/s11_week_sv_light.png differ
diff --git a/android/feature/cards/build.gradle.kts b/android/feature/cards/build.gradle.kts
new file mode 100644
index 0000000..39d66d9
--- /dev/null
+++ b/android/feature/cards/build.gradle.kts
@@ -0,0 +1,12 @@
+// :feature:cards — card purchases (D-173): the Kortköp tab with the purchase sheet (S-23), Anslut kort (S-24), connect
+// with BankID (S-25), the connection's settings (S-26), "Från kortköp" (S-27) and the card artwork (CardArt).
+// Convention: android/README.md; :core:model, :core:design, :core:fakes (debug and tests), :core:design's test fixtures
+// and the screenshot tests come from kvitt.android.feature.
+plugins {
+ id("kvitt.android.feature")
+}
+
+dependencies {
+ // S-25: the bank's BankID page opens in a Custom Tab (D-173).
+ implementation(libs.androidx.browser)
+}
diff --git a/android/feature/cards/src/main/kotlin/se/hagstedt/kvitt/feature/cards/CardArt.kt b/android/feature/cards/src/main/kotlin/se/hagstedt/kvitt/feature/cards/CardArt.kt
new file mode 100644
index 0000000..5c1ed1b
--- /dev/null
+++ b/android/feature/cards/src/main/kotlin/se/hagstedt/kvitt/feature/cards/CardArt.kt
@@ -0,0 +1,64 @@
+package se.hagstedt.kvitt.feature.cards
+
+import androidx.annotation.DrawableRes
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.semantics.clearAndSetSemantics
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import se.hagstedt.kvitt.design.theme.KvittDimens
+import se.hagstedt.kvitt.design.theme.KvittLightColors
+import se.hagstedt.kvitt.model.ColorToken
+
+/**
+ * A card's artwork (D-173, D-180, CP-1): the bundled picture for a known [imageKey], drawn at its own aspect ratio inside
+ * the card box (`ContentScale.Fit`) without clipping or a background, so its own rounded corners and notch show. Any
+ * other key draws the fallback card: 1.586:1, radius 16, the `green` category token's light value in both themes, and
+ * the card's [name] in white (4.95:1), no other text. TalkBack reads [name].
+ */
+@Composable
+fun CardArt(imageKey: String, name: String, modifier: Modifier = Modifier) {
+ val box = modifier.aspectRatio(KvittDimens.CARD_ART_ASPECT).clearAndSetSemantics { if (name.isNotEmpty()) contentDescription = name }
+ val artwork = cardArtwork(imageKey)
+ if (artwork != null) {
+ Box(box, contentAlignment = Alignment.Center) {
+ Image(painterResource(artwork), contentDescription = null, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Fit)
+ }
+ } else {
+ val shape = RoundedCornerShape(KvittDimens.cardArtCorner)
+ Box(box.clip(shape).background(KvittLightColors.category(ColorToken.GREEN), shape), contentAlignment = Alignment.BottomStart) {
+ Text(
+ name,
+ Modifier.padding(KvittDimens.cardArtNameInset),
+ style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
+ color = KvittLightColors.onCat,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+}
+
+/** The bundled artwork of an image key (CP-1); null for a key the app has no picture for. */
+@DrawableRes
+fun cardArtwork(imageKey: String): Int? = when (imageKey) {
+ SJ_PRIO_MASTERCARD -> R.drawable.card_sj_prio_mastercard
+ else -> null
+}
+
+/** The image key of the registry's SJ Prio Mastercard (CP-1); the only bundled picture (D-180). */
+const val SJ_PRIO_MASTERCARD = "sj_prio_mastercard"
diff --git a/android/feature/cards/src/main/kotlin/se/hagstedt/kvitt/feature/cards/CardConnectScreen.kt b/android/feature/cards/src/main/kotlin/se/hagstedt/kvitt/feature/cards/CardConnectScreen.kt
new file mode 100644
index 0000000..a9899cf
--- /dev/null
+++ b/android/feature/cards/src/main/kotlin/se/hagstedt/kvitt/feature/cards/CardConnectScreen.kt
@@ -0,0 +1,168 @@
+package se.hagstedt.kvitt.feature.cards
+
+import android.content.ActivityNotFoundException
+import android.content.Context
+import android.net.Uri
+import androidx.browser.customtabs.CustomTabsIntent
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.LiveRegionMode
+import androidx.compose.ui.semantics.liveRegion
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.style.TextAlign
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.lifecycle.compose.LifecycleResumeEffect
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import se.hagstedt.kvitt.design.KvittTheme
+import se.hagstedt.kvitt.design.component.Banner
+import se.hagstedt.kvitt.design.component.BannerKind
+import se.hagstedt.kvitt.design.component.ButtonKind
+import se.hagstedt.kvitt.design.component.ButtonSize
+import se.hagstedt.kvitt.design.component.KvittButton
+import se.hagstedt.kvitt.design.component.KvittIconImage
+import se.hagstedt.kvitt.design.component.KvittScaffold
+import se.hagstedt.kvitt.design.component.KvittTopAppBar
+import se.hagstedt.kvitt.design.component.NeedsConnectionHint
+import se.hagstedt.kvitt.design.component.TopBarNavigation
+import se.hagstedt.kvitt.design.icon.KvittIcon
+import se.hagstedt.kvitt.design.theme.KvittDimens
+import se.hagstedt.kvitt.core.design.R as DesignR
+
+/** What S-25 can ask its state holder to do; [CardConnectViewModel] implements it through the screen. */
+data class CardConnectActions(
+ val onBack: () -> Unit = {},
+ val onHistory: (Int) -> Unit = {},
+ val onStart: () -> Unit = {},
+ val onCancel: () -> Unit = {},
+)
+
+/**
+ * S-25 Connect a card (pushed from S-24, or from "Förnya med BankID"): the history choice (CP-3) and "Anslut med
+ * BankID"; the bank's page opens in a Custom Tab (`androidx.browser`, D-173). [returnedAttempt] is the attempt id of a
+ * return link `kvitt://cards/connected?attempt=` that arrived while this screen is open (`:app` passes it).
+ * [onConnected] goes to S-23 after "Kortet är anslutet".
+ */
+@Composable
+fun CardConnectScreen(
+ onBack: () -> Unit,
+ onConnected: () -> Unit,
+ modifier: Modifier = Modifier,
+ returnedAttempt: String? = null,
+ viewModel: CardConnectViewModel = hiltViewModel(),
+) {
+ val ui by viewModel.ui.collectAsStateWithLifecycle()
+ val context = LocalContext.current
+ LaunchedEffect(viewModel) {
+ viewModel.eventFlow.collect { event ->
+ when (event) {
+ is ConnectEvent.OpenBank -> if (!openBankPage(context, event.url)) viewModel.bankPageFailed()
+ ConnectEvent.Done -> onConnected()
+ }
+ }
+ }
+ LaunchedEffect(returnedAttempt) { returnedAttempt?.let(viewModel::onReturn) }
+ // CP-2: E-38 is polled while this screen is open and resumed (the Custom Tab on top pauses it).
+ LifecycleResumeEffect(viewModel) {
+ viewModel.onResume()
+ onPauseOrDispose { viewModel.onPause() }
+ }
+ CardConnectContent(ui, CardConnectActions(onBack, viewModel::setHistory, viewModel::start, viewModel::cancel), modifier)
+}
+
+/** Opens [url] in a Custom Tab; false when no browser can show it. */
+private fun openBankPage(context: Context, url: String): Boolean = try {
+ CustomTabsIntent.Builder().build().launchUrl(context, Uri.parse(url))
+ true
+} catch (e: ActivityNotFoundException) {
+ false
+}
+
+/** The stateless S-25, rendered by [CardConnectScreen] and the screenshot tests. */
+@Composable
+fun CardConnectContent(ui: CardConnectUi, actions: CardConnectActions, modifier: Modifier = Modifier) {
+ val provider = ui.provider
+ KvittScaffold(modifier, topBar = { KvittTopAppBar(provider?.name.orEmpty(), navigation = TopBarNavigation.Back, onNavigation = actions.onBack) }) { padding ->
+ Column(
+ Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(horizontal = KvittTheme.spacing.gutter, vertical = KvittTheme.spacing.s2),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s5),
+ ) {
+ if (provider != null) CardArt(provider.imageKey, provider.name, Modifier.width(KvittDimens.cardArtLarge))
+ when (ui.phase) {
+ ConnectPhase.Choose, ConnectPhase.Starting -> Choose(ui, actions)
+ ConnectPhase.Waiting -> Waiting(actions)
+ ConnectPhase.Connected -> Outcome(KvittIcon.CheckCircle, stringResource(R.string.cards_connected), null, null)
+ ConnectPhase.Failed -> Outcome(KvittIcon.Error, stringResource(R.string.cards_connect_failed), stringResource(DesignR.string.common_retry), actions.onStart)
+ ConnectPhase.TimedOut -> Outcome(KvittIcon.Schedule, stringResource(R.string.cards_connect_timeout), stringResource(DesignR.string.common_retry), actions.onStart)
+ }
+ }
+ }
+}
+
+@Composable
+private fun Choose(ui: CardConnectUi, actions: CardConnectActions) {
+ Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3)) {
+ val label = stringResource(R.string.cards_history_label)
+ Text(label, style = MaterialTheme.typography.titleSmall)
+ HistoryChips(HISTORY_OPTIONS, ui.historyDays, actions.onHistory, label)
+ Text(stringResource(R.string.cards_history_note), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ if (ui.couldNotReach) {
+ Banner(BannerKind.Error, KvittIcon.Error, stringResource(DesignR.string.common_could_not_reach_server))
+ }
+ Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
+ KvittButton(
+ stringResource(if (ui.renew) R.string.cards_renew else R.string.cards_connect_bankid),
+ actions.onStart,
+ Modifier.fillMaxWidth(),
+ size = ButtonSize.Medium,
+ icon = KvittIcon.Key,
+ enabled = ui.online,
+ loading = ui.phase == ConnectPhase.Starting,
+ )
+ if (!ui.online) NeedsConnectionHint()
+ }
+ }
+}
+
+@Composable
+private fun Waiting(actions: CardConnectActions) {
+ Column(
+ Modifier.fillMaxWidth().semantics(mergeDescendants = true) { liveRegion = LiveRegionMode.Polite },
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3),
+ ) {
+ CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
+ Text(stringResource(R.string.cards_waiting), style = MaterialTheme.typography.titleMedium, textAlign = TextAlign.Center)
+ Text(stringResource(R.string.cards_waiting_body), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center)
+ }
+ KvittButton(stringResource(DesignR.string.common_cancel), actions.onCancel, kind = ButtonKind.Text)
+}
+
+@Composable
+private fun Outcome(icon: KvittIcon, title: String, action: String?, onAction: (() -> Unit)?) {
+ Column(
+ Modifier.fillMaxWidth().semantics(mergeDescendants = true) { liveRegion = LiveRegionMode.Polite },
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3),
+ ) {
+ KvittIconImage(icon, null, filled = true, size = KvittDimens.emptyIconTile, tint = MaterialTheme.colorScheme.primary)
+ Text(title, style = MaterialTheme.typography.headlineSmall, textAlign = TextAlign.Center)
+ }
+ if (action != null && onAction != null) KvittButton(action, onAction, size = ButtonSize.Medium, icon = KvittIcon.Refresh)
+}
diff --git a/android/feature/cards/src/main/kotlin/se/hagstedt/kvitt/feature/cards/CardConnectViewModel.kt b/android/feature/cards/src/main/kotlin/se/hagstedt/kvitt/feature/cards/CardConnectViewModel.kt
new file mode 100644
index 0000000..448c6a8
--- /dev/null
+++ b/android/feature/cards/src/main/kotlin/se/hagstedt/kvitt/feature/cards/CardConnectViewModel.kt
@@ -0,0 +1,203 @@
+package se.hagstedt.kvitt.feature.cards
+
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import se.hagstedt.kvitt.model.CardAttemptState
+import se.hagstedt.kvitt.model.CardConnectionStatus
+import se.hagstedt.kvitt.model.CardFilter
+import se.hagstedt.kvitt.model.CardProvider
+import se.hagstedt.kvitt.model.CardRepository
+import se.hagstedt.kvitt.model.ConnectivityRepository
+import se.hagstedt.kvitt.model.OnlineResult
+import javax.inject.Inject
+
+/** Where S-25 is in the CP-2 flow. */
+enum class ConnectPhase { Choose, Starting, Waiting, Connected, Failed, TimedOut }
+
+/** One-off results of S-25: open the bank's page in a Custom Tab, or go on to S-23. */
+sealed interface ConnectEvent {
+ data class OpenBank(val url: String) : ConnectEvent
+ data object Done : ConnectEvent
+}
+
+/** What S-25 shows. [renew]: "Förnya med BankID" with the history preset to the connection's (CP-6). */
+data class CardConnectUi(
+ val provider: CardProvider? = null,
+ val historyDays: Int = DEFAULT_HISTORY_DAYS,
+ val renew: Boolean = false,
+ val phase: ConnectPhase = ConnectPhase.Choose,
+ val online: Boolean = true,
+ val couldNotReach: Boolean = false,
+)
+
+/** CP-3: "Hämta köp från de senaste 7 / 30 / 90 / 180 dagar", default 30. */
+val HISTORY_OPTIONS = listOf(7, 30, 90, 180)
+const val DEFAULT_HISTORY_DAYS = 30
+
+private data class ConnectLocal(
+ val historyDays: Int? = null,
+ val phase: ConnectPhase = ConnectPhase.Choose,
+ val attemptId: String? = null,
+ val couldNotReach: Boolean = false,
+)
+
+/**
+ * S-25 state holder (CP-2): E-37 creates the attempt and the bank URL opens in a Custom Tab; while S-25 is open and
+ * resumed it polls E-38 every 2 seconds ([POLL_MILLIS]), and once more at once when the return link
+ * `kvitt://cards/connected?attempt=` opens it. Connected shows "Kortet är anslutet" and then goes to S-23; failed and
+ * expired (the server's timeout, 15 minutes) offer "Försök igen"; "Avbryt" stops polling (the attempt expires by
+ * itself). Arguments: [ARG_PROVIDER_ID], [ARG_RENEW], [ARG_ATTEMPT].
+ */
+@HiltViewModel
+class CardConnectViewModel @Inject constructor(
+ savedStateHandle: SavedStateHandle,
+ private val cards: CardRepository,
+ connectivity: ConnectivityRepository,
+) : ViewModel() {
+ private val providerId: String = savedStateHandle.get(ARG_PROVIDER_ID).orEmpty()
+ private val renew: Boolean = savedStateHandle.get(ARG_RENEW) == "true"
+ private val local = MutableStateFlow(ConnectLocal(attemptId = savedStateHandle.get(ARG_ATTEMPT)))
+ private val events = Channel(Channel.BUFFERED)
+ private var resumed = false
+ private var polling: Job? = null
+
+ val eventFlow: Flow = events.receiveAsFlow()
+
+ val ui: StateFlow = combine(cards.cards(CardFilter.ALL), connectivity.online, local) { c, online, l ->
+ val provider = c.provider?.takeIf { it.id == providerId } ?: CardProvider(providerId, "", "", providerId, true)
+ val connected = c.connection?.takeIf { it.providerId == providerId && it.status != CardConnectionStatus.DISCONNECTED }
+ CardConnectUi(
+ provider = provider,
+ historyDays = l.historyDays ?: connected?.historyDays?.takeIf { renew && it in HISTORY_OPTIONS } ?: DEFAULT_HISTORY_DAYS,
+ renew = renew,
+ phase = l.phase,
+ online = online,
+ couldNotReach = l.couldNotReach,
+ )
+ }.stateIn(viewModelScope, SharingStarted.Eagerly, CardConnectUi(renew = renew))
+
+ init {
+ // Opened by the return link (S-25 was not on the stack): ask the attempt at once.
+ if (local.value.attemptId != null) {
+ local.update { it.copy(phase = ConnectPhase.Waiting) }
+ pollOnce()
+ }
+ }
+
+ fun setHistory(days: Int) = local.update { it.copy(historyDays = days) }
+
+ /** "Anslut med BankID" / "Förnya med BankID" and "Försök igen". */
+ fun start() {
+ if (local.value.phase == ConnectPhase.Starting) return
+ local.update { it.copy(phase = ConnectPhase.Starting, couldNotReach = false) }
+ viewModelScope.launch {
+ when (val result = cards.startConnect(providerId, ui.value.historyDays)) {
+ is OnlineResult.Ok -> {
+ local.update { it.copy(phase = ConnectPhase.Waiting, attemptId = result.value.attemptId) }
+ events.send(ConnectEvent.OpenBank(result.value.authUrl))
+ startPolling()
+ }
+ OnlineResult.Offline -> local.update { it.copy(phase = ConnectPhase.Choose) }
+ is OnlineResult.Failed -> local.update {
+ if (result.code == NETWORK) it.copy(phase = ConnectPhase.Choose, couldNotReach = true) else it.copy(phase = ConnectPhase.Failed)
+ }
+ }
+ }
+ }
+
+ /** "Avbryt": stop polling and return to the choice (the attempt expires by itself). */
+ fun cancel() {
+ polling?.cancel()
+ local.update { it.copy(phase = ConnectPhase.Choose, attemptId = null) }
+ }
+
+ /** The Custom Tab could not open (no browser on the phone). */
+ fun bankPageFailed() {
+ polling?.cancel()
+ local.update { it.copy(phase = ConnectPhase.Failed) }
+ }
+
+ /** The return link named [attemptId]: poll now (CP-2, AC-22.2's 5 seconds). */
+ fun onReturn(attemptId: String) {
+ val current = local.value
+ if (current.attemptId != null && current.attemptId != attemptId) return
+ local.update { it.copy(attemptId = attemptId, phase = if (it.phase == ConnectPhase.Choose) ConnectPhase.Waiting else it.phase) }
+ pollOnce()
+ }
+
+ fun onResume() {
+ resumed = true
+ startPolling()
+ }
+
+ fun onPause() {
+ resumed = false
+ polling?.cancel()
+ }
+
+ private fun startPolling() {
+ if (!resumed || local.value.phase != ConnectPhase.Waiting) return
+ polling?.cancel()
+ polling = viewModelScope.launch {
+ while (isActive && local.value.phase == ConnectPhase.Waiting) {
+ delay(POLL_MILLIS)
+ check()
+ }
+ }
+ }
+
+ private fun pollOnce() {
+ viewModelScope.launch { check() }
+ }
+
+ private suspend fun check() {
+ val attempt = local.value.attemptId ?: return
+ val result = cards.attempt(attempt) as? OnlineResult.Ok ?: return
+ when (result.value) {
+ CardAttemptState.Pending -> Unit
+ is CardAttemptState.Connected -> connected()
+ is CardAttemptState.Failed -> stop(ConnectPhase.Failed)
+ CardAttemptState.Expired -> stop(ConnectPhase.TimedOut)
+ }
+ }
+
+ /** The polling loop ends by itself once the phase is no longer [ConnectPhase.Waiting]. */
+ private fun connected() {
+ if (local.value.phase == ConnectPhase.Connected) return
+ stop(ConnectPhase.Connected)
+ viewModelScope.launch {
+ delay(CONNECTED_MILLIS)
+ events.send(ConnectEvent.Done)
+ }
+ }
+
+ private fun stop(phase: ConnectPhase) = local.update { it.copy(phase = phase) }
+
+ companion object {
+ const val ARG_PROVIDER_ID = "providerId"
+ const val ARG_RENEW = "renew"
+ const val ARG_ATTEMPT = "attempt"
+
+ /** CP-2: "polling E-38 every 2 seconds while its connect screen is open". */
+ const val POLL_MILLIS = 2_000L
+
+ /** "Kortet är anslutet" stays this long before S-23 opens. */
+ const val CONNECTED_MILLIS = 1_500L
+ private const val NETWORK = "network"
+ }
+}
diff --git a/android/feature/cards/src/main/kotlin/se/hagstedt/kvitt/feature/cards/CardConnectionScreen.kt b/android/feature/cards/src/main/kotlin/se/hagstedt/kvitt/feature/cards/CardConnectionScreen.kt
new file mode 100644
index 0000000..dc6f80f
--- /dev/null
+++ b/android/feature/cards/src/main/kotlin/se/hagstedt/kvitt/feature/cards/CardConnectionScreen.kt
@@ -0,0 +1,269 @@
+package se.hagstedt.kvitt.feature.cards
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.selection.toggleable
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalResources
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.Role
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import se.hagstedt.kvitt.design.KvittTheme
+import se.hagstedt.kvitt.design.component.ButtonKind
+import se.hagstedt.kvitt.design.component.ConfirmDialog
+import se.hagstedt.kvitt.design.component.KvittCard
+import se.hagstedt.kvitt.design.component.KvittIconImage
+import se.hagstedt.kvitt.design.component.KvittButton
+import se.hagstedt.kvitt.design.component.KvittScaffold
+import se.hagstedt.kvitt.design.component.KvittTopAppBar
+import se.hagstedt.kvitt.design.component.NeedsConnectionHint
+import se.hagstedt.kvitt.design.component.TopBarNavigation
+import se.hagstedt.kvitt.design.component.relativeTime
+import se.hagstedt.kvitt.design.component.showKvittSnackbar
+import se.hagstedt.kvitt.design.icon.KvittIcon
+import se.hagstedt.kvitt.design.theme.KvittDimens
+import se.hagstedt.kvitt.model.CardConnection
+import se.hagstedt.kvitt.model.CardConnectionId
+import se.hagstedt.kvitt.model.CardConnectionStatus
+import se.hagstedt.kvitt.model.CardFilter
+import se.hagstedt.kvitt.model.CardProvider
+import se.hagstedt.kvitt.model.CardRepository
+import se.hagstedt.kvitt.model.ConnectivityRepository
+import se.hagstedt.kvitt.model.OnlineResult
+import javax.inject.Inject
+import se.hagstedt.kvitt.core.design.R as DesignR
+
+/** What S-26 shows; [connection] null once it is gone (disconnected here or elsewhere). */
+data class CardConnectionUi(
+ val provider: CardProvider? = null,
+ val connection: CardConnection? = null,
+ val online: Boolean = true,
+ val disconnectDialog: Boolean = false,
+ val eraseHandled: Boolean = false,
+ val busy: Boolean = false,
+ val couldNotReach: Boolean = false,
+) {
+ /** Every action of S-26 needs the server (§9.9). */
+ val canAct: Boolean get() = online && !busy
+}
+
+/** One-off results of S-26. */
+sealed interface ConnectionEvent {
+ data object Disconnected : ConnectionEvent
+}
+
+private data class ConnectionLocal(val dialog: Boolean = false, val erase: Boolean = false, val busy: Boolean = false, val couldNotReach: Boolean = false)
+
+/** S-26 state holder (CP-3, CP-6, CP-12). Argument: [ARG_CONNECTION_ID]. */
+@HiltViewModel
+class CardConnectionViewModel @Inject constructor(
+ savedStateHandle: SavedStateHandle,
+ private val cards: CardRepository,
+ connectivity: ConnectivityRepository,
+) : ViewModel() {
+ private val id = CardConnectionId(savedStateHandle.get(ARG_CONNECTION_ID).orEmpty())
+ private val local = MutableStateFlow(ConnectionLocal())
+ private val events = Channel(Channel.BUFFERED)
+
+ val eventFlow: Flow = events.receiveAsFlow()
+
+ val ui: StateFlow = combine(cards.cards(CardFilter.ALL), connectivity.online, local) { c, online, l ->
+ CardConnectionUi(c.provider, c.connection?.takeIf { it.id == id }, online, l.dialog, l.erase, l.busy, l.couldNotReach)
+ }.stateIn(viewModelScope, SharingStarted.Eagerly, CardConnectionUi())
+
+ /** A shorter history only (E-41); a longer one is a renewal through S-25 (CP-3). */
+ fun setHistory(days: Int) {
+ val current = ui.value.connection ?: return
+ if (days >= current.historyDays) return
+ run { cards.setHistoryDays(id, days) }
+ }
+
+ fun askDisconnect() = local.update { it.copy(dialog = true, erase = false) }
+
+ fun dismissDisconnect() = local.update { it.copy(dialog = false) }
+
+ fun setEraseHandled(erase: Boolean) = local.update { it.copy(erase = erase) }
+
+ fun confirmDisconnect() {
+ val erase = local.value.erase
+ local.update { it.copy(dialog = false) }
+ run(onOk = { events.send(ConnectionEvent.Disconnected) }) { cards.disconnect(id, erase) }
+ }
+
+ fun messageShown() = local.update { it.copy(couldNotReach = false) }
+
+ private fun run(onOk: suspend () -> Unit = {}, call: suspend () -> OnlineResult) {
+ local.update { it.copy(busy = true, couldNotReach = false) }
+ viewModelScope.launch {
+ val result = call()
+ local.update { it.copy(busy = false, couldNotReach = result is OnlineResult.Failed && result.code == NETWORK) }
+ if (result is OnlineResult.Ok) onOk()
+ }
+ }
+
+ companion object {
+ const val ARG_CONNECTION_ID = "connectionId"
+ private const val NETWORK = "network"
+ }
+}
+
+/** Navigation and actions of S-26. */
+data class CardConnectionActions(
+ val onBack: () -> Unit = {},
+ val onRenew: (String) -> Unit = {},
+ val onHistory: (Int) -> Unit = {},
+ val onAskDisconnect: () -> Unit = {},
+ val onDismissDisconnect: () -> Unit = {},
+ val onEraseHandled: (Boolean) -> Unit = {},
+ val onConfirmDisconnect: () -> Unit = {},
+)
+
+/** S-26 Card connection (pushed from S-24 or S-23's header). [onDisconnected] leaves the screen after "Koppla från". */
+@Composable
+fun CardConnectionScreen(
+ onBack: () -> Unit,
+ onRenew: (String) -> Unit,
+ onDisconnected: () -> Unit,
+ modifier: Modifier = Modifier,
+ viewModel: CardConnectionViewModel = hiltViewModel(),
+) {
+ val ui by viewModel.ui.collectAsStateWithLifecycle()
+ val snackbar = remember { SnackbarHostState() }
+ val resources = LocalResources.current
+ LaunchedEffect(viewModel) { viewModel.eventFlow.collect { if (it == ConnectionEvent.Disconnected) onDisconnected() } }
+ LaunchedEffect(ui.couldNotReach) {
+ if (!ui.couldNotReach) return@LaunchedEffect
+ viewModel.messageShown()
+ snackbar.showKvittSnackbar(resources, DesignR.string.common_could_not_reach_server)
+ }
+ val actions = CardConnectionActions(
+ onBack = onBack,
+ onRenew = onRenew,
+ onHistory = viewModel::setHistory,
+ onAskDisconnect = viewModel::askDisconnect,
+ onDismissDisconnect = viewModel::dismissDisconnect,
+ onEraseHandled = viewModel::setEraseHandled,
+ onConfirmDisconnect = viewModel::confirmDisconnect,
+ )
+ CardConnectionContent(ui, actions, modifier, snackbar)
+ if (ui.disconnectDialog) {
+ ConfirmDialog(
+ icon = KvittIcon.Delete,
+ title = stringResource(R.string.cards_disconnect_title, ui.provider?.name.orEmpty()),
+ body = stringResource(R.string.cards_disconnect_body),
+ confirm = stringResource(R.string.cards_disconnect),
+ dismiss = stringResource(DesignR.string.common_cancel),
+ onConfirm = actions.onConfirmDisconnect,
+ onDismiss = actions.onDismissDisconnect,
+ destructive = true,
+ extra = { EraseAll(ui.eraseHandled, actions.onEraseHandled) },
+ )
+ }
+}
+
+/** "Radera även kopplade och ignorerade köp" (CP-12), the checkbox of the disconnect dialog. */
+@Composable
+fun EraseAll(checked: Boolean, onChange: (Boolean) -> Unit) {
+ Row(
+ Modifier.fillMaxWidth().toggleable(checked, role = Role.Checkbox, onValueChange = onChange),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2),
+ ) {
+ Checkbox(checked, onCheckedChange = null)
+ Text(stringResource(R.string.cards_disconnect_erase_all), style = MaterialTheme.typography.bodyMedium)
+ }
+}
+
+/** The stateless S-26 (without its dialog). */
+@Composable
+fun CardConnectionContent(ui: CardConnectionUi, actions: CardConnectionActions, modifier: Modifier = Modifier, snackbarHostState: SnackbarHostState? = null) {
+ val provider = ui.provider
+ KvittScaffold(
+ modifier,
+ topBar = { KvittTopAppBar(provider?.name.orEmpty(), navigation = TopBarNavigation.Back, onNavigation = actions.onBack) },
+ snackbarHostState = snackbarHostState,
+ ) { padding ->
+ val connection = ui.connection ?: return@KvittScaffold
+ Column(
+ Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(horizontal = KvittTheme.spacing.gutter, vertical = KvittTheme.spacing.s2),
+ verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.detailGap),
+ ) {
+ Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
+ CardArt(provider?.imageKey ?: connection.providerId, provider?.name.orEmpty(), Modifier.width(KvittDimens.cardArtLarge))
+ }
+ val expired = connection.status == CardConnectionStatus.EXPIRED
+ KvittCard(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s1)) {
+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2)) {
+ KvittIconImage(
+ if (expired) KvittIcon.Error else KvittIcon.CheckCircle,
+ null,
+ filled = true,
+ tint = if (expired) MaterialTheme.colorScheme.error else KvittTheme.colors.owed,
+ )
+ Text(stringResource(if (expired) R.string.cards_status_expired else R.string.cards_status_active), style = MaterialTheme.typography.titleMedium)
+ }
+ connection.validUntil?.let { Text(stringResource(R.string.cards_valid_until, instantDetailDate(it)), style = MaterialTheme.typography.bodyMedium) }
+ connection.lastFetchAt?.let {
+ Text(stringResource(R.string.cards_last_updated, relativeTime(it)), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ }
+ History(ui, connection, actions)
+ Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2)) {
+ KvittButton(
+ stringResource(R.string.cards_renew),
+ { actions.onRenew(connection.providerId) },
+ Modifier.fillMaxWidth(),
+ kind = if (connection.expiresSoon || expired) ButtonKind.Filled else ButtonKind.Tonal,
+ icon = KvittIcon.Key,
+ enabled = ui.canAct,
+ )
+ KvittButton(stringResource(R.string.cards_disconnect), actions.onAskDisconnect, Modifier.fillMaxWidth(), kind = ButtonKind.DangerText, icon = KvittIcon.Delete, enabled = ui.canAct)
+ if (!ui.online) NeedsConnectionHint()
+ }
+ }
+ }
+}
+
+/** "Historik vid anslutning": shorter values selectable (E-41), a longer one only through "Förnya med BankID" (CP-3). */
+@Composable
+private fun History(ui: CardConnectionUi, connection: CardConnection, actions: CardConnectionActions) {
+ Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2)) {
+ val label = stringResource(R.string.cards_history_setting)
+ Text(label, style = MaterialTheme.typography.titleSmall)
+ val options = HISTORY_OPTIONS.filter { it <= connection.historyDays }.ifEmpty { listOf(connection.historyDays) }
+ HistoryChips(options, connection.historyDays, actions.onHistory, label, enabled = ui.canAct)
+ Text(stringResource(R.string.cards_history_longer_note), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+}
+
diff --git a/android/feature/cards/src/main/kotlin/se/hagstedt/kvitt/feature/cards/CardParts.kt b/android/feature/cards/src/main/kotlin/se/hagstedt/kvitt/feature/cards/CardParts.kt
new file mode 100644
index 0000000..52ab926
--- /dev/null
+++ b/android/feature/cards/src/main/kotlin/se/hagstedt/kvitt/feature/cards/CardParts.kt
@@ -0,0 +1,236 @@
+package se.hagstedt.kvitt.feature.cards
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.FilterChip
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.ReadOnlyComposable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.res.pluralStringResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.clearAndSetSemantics
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.heading
+import androidx.compose.ui.semantics.role
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.style.TextOverflow
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.toLocalDateTime
+import se.hagstedt.kvitt.design.KvittTheme
+import se.hagstedt.kvitt.design.component.AmountStyle
+import se.hagstedt.kvitt.design.component.AmountTone
+import se.hagstedt.kvitt.design.component.BadgeKind
+import se.hagstedt.kvitt.design.component.ButtonKind
+import se.hagstedt.kvitt.design.component.KvittBadge
+import se.hagstedt.kvitt.design.component.KvittButton
+import se.hagstedt.kvitt.design.component.KvittIconImage
+import se.hagstedt.kvitt.design.component.LocalKvittClock
+import se.hagstedt.kvitt.design.component.MoneyText
+import se.hagstedt.kvitt.design.component.SegmentedItemScope
+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.spokenMoney
+import se.hagstedt.kvitt.design.format.DateFormatter
+import se.hagstedt.kvitt.design.format.KvittLanguage
+import se.hagstedt.kvitt.design.icon.KvittIcon
+import se.hagstedt.kvitt.design.theme.KvittDimens
+import se.hagstedt.kvitt.model.CardDirection
+import se.hagstedt.kvitt.model.CardPurchase
+import se.hagstedt.kvitt.model.CardPurchaseRow
+import se.hagstedt.kvitt.model.CardPurchaseStatus
+import se.hagstedt.kvitt.model.ExpenseMatch
+import kotlin.time.Instant
+import se.hagstedt.kvitt.core.design.R as DesignR
+
+// Shared pieces of S-23 to S-27: amounts, dates, the purchase row and the expense match row.
+
+/** "24,99 EUR" / "24.99 EUR": a foreign amount as the bank's decimal string in the app's number form (CP-7). */
+@Composable
+@ReadOnlyComposable
+fun foreignAmount(purchase: CardPurchase): String {
+ val amount = purchase.originalAmount.orEmpty().let { if (kvittLanguage() == KvittLanguage.SWEDISH) it.replace('.', ',') else it }
+ return stringResource(R.string.cards_foreign_amount, amount, purchase.originalCurrency.orEmpty())
+}
+
+/** The list date of an RFC 3339 instant in the phone's zone: "22 apr. 2027" (§9.8). */
+@Composable
+@ReadOnlyComposable
+fun instantListDate(rfc3339: String): String {
+ val clock = LocalKvittClock.current
+ return DateFormatter.listDate(Instant.parse(rfc3339).toLocalDateTime(clock.zone).date, clock.today, kvittLanguage())
+}
+
+/** The detail date of an RFC 3339 instant in the phone's zone: "torsdag 22 april 2027" (§9.8). */
+@Composable
+@ReadOnlyComposable
+fun instantDetailDate(rfc3339: String, zone: TimeZone = LocalKvittClock.current.zone): String =
+ DateFormatter.detailDate(Instant.parse(rfc3339).toLocalDateTime(zone).date, kvittLanguage())
+
+/** A day header of S-23's list: the §9.8 list date (title-small, heading), as the ledger's month header. */
+@Composable
+fun CardDayHeader(date: String, modifier: Modifier = Modifier) {
+ Text(
+ listDate(date),
+ modifier
+ .fillMaxWidth()
+ .padding(start = KvittDimens.monthHeaderSide, end = KvittDimens.monthHeaderSide, top = KvittDimens.monthHeaderTop, bottom = KvittDimens.monthHeaderBottom)
+ .semantics { heading() },
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+}
+
+/**
+ * A purchase as a segmented-group item (S-23, S-27): merchant (one line, ellipsis), its status badges ("Preliminär",
+ * "Insättning", "Utländsk valuta"), the linked line under "Kopplade" and the match chip under "Att hantera" (CP-10);
+ * the amount on the right, under the text at ≥ 1.5× font scale. One node for TalkBack, the chip a separate button.
+ * [onMatch] links the single match in one tap; [onMatches] opens the sheet for two or three. Both null: no chip (S-27).
+ */
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+fun CardPurchaseItem(
+ item: SegmentedItemScope,
+ row: CardPurchaseRow,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ showCredit: Boolean = true,
+ onMatch: ((ExpenseMatch) -> Unit)? = null,
+ onMatches: (() -> Unit)? = null,
+) {
+ val p = row.purchase
+ val large = LocalDensity.current.fontScale >= KvittDimens.LARGE_FONT_SCALE
+ val separator = stringResource(DesignR.string.common_separator_comma)
+ val amount = p.amountOre?.let { spokenMoney(it) } ?: foreignAmount(p)
+ val badges = buildList {
+ if (p.status == CardPurchaseStatus.PENDING) add(stringResource(R.string.cards_pending) to BadgeKind.Pending)
+ if (p.direction == CardDirection.CREDIT && showCredit) add(stringResource(R.string.cards_credit) to BadgeKind.Neutral)
+ if (p.amountOre == null) add(stringResource(R.string.cards_foreign) to BadgeKind.Neutral)
+ }
+ val linkedLine = p.linked?.let { stringResource(R.string.cards_linked_to, it.description, it.accountName) }
+ val spoken = (listOf(p.merchant, amount, listDate(p.date)) + badges.map { it.first } + listOfNotNull(linkedLine)).joinToString(separator)
+ Column(
+ modifier
+ .fillMaxWidth()
+ .clip(item.shape)
+ .background(item.fill)
+ .heightIn(min = KvittTheme.spacing.ledgerRowMin)
+ .padding(horizontal = KvittTheme.spacing.rowHorizontal, vertical = KvittTheme.spacing.rowVertical),
+ verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2, Alignment.CenterVertically),
+ ) {
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .clickable(onClick = onClick)
+ .clearAndSetSemantics { contentDescription = spoken; role = Role.Button },
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.rowTileGap),
+ ) {
+ Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s1)) {
+ Text(p.merchant, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ if (badges.isNotEmpty()) {
+ FlowRow(horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s1), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s1)) {
+ badges.forEach { (text, kind) -> KvittBadge(text, kind = kind) }
+ }
+ }
+ if (linkedLine != null) {
+ Text(linkedLine, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis)
+ }
+ if (large) PurchaseAmount(p)
+ }
+ if (!large) PurchaseAmount(p)
+ }
+ val matches = row.matches
+ when {
+ matches.size == 1 && onMatch != null -> KvittButton(
+ stringResource(R.string.cards_match_one, matches.single().description),
+ { onMatch(matches.single()) },
+ kind = ButtonKind.Tonal,
+ icon = KvittIcon.Link,
+ )
+ matches.size > 1 && onMatches != null -> KvittButton(
+ pluralStringResource(R.plurals.cards_match_many, matches.size, matches.size),
+ onMatches,
+ kind = ButtonKind.Outlined,
+ icon = KvittIcon.Link,
+ )
+ }
+ }
+}
+
+@Composable
+private fun PurchaseAmount(p: CardPurchase) {
+ if (p.amountOre != null) {
+ MoneyText(p.amountOre ?: 0L, AmountStyle.Small, if (p.direction == CardDirection.CREDIT) AmountTone.Muted else AmountTone.Neutral)
+ } else {
+ Text(foreignAmount(p), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface)
+ }
+}
+
+/**
+ * An existing expense to link (the sheet's matches and "Välj en annan utgift", CP-10): description, then account,
+ * payer and date, the amount on the right. One button node; a tap links.
+ */
+@Composable
+fun ExpenseMatchItem(item: SegmentedItemScope, match: ExpenseMatch, onClick: () -> Unit, modifier: Modifier = Modifier) {
+ val dot = stringResource(DesignR.string.common_separator_dot)
+ val separator = stringResource(DesignR.string.common_separator_comma)
+ val details = listOf(match.accountName, personName(match.payer), listDate(match.date)).joinToString(dot)
+ val spoken = listOf(match.description, match.accountName, personName(match.payer), listDate(match.date), spokenMoney(match.amountOre)).joinToString(separator)
+ Row(
+ modifier
+ .fillMaxWidth()
+ .clip(item.shape)
+ .background(item.fill)
+ .clickable(onClick = onClick)
+ .clearAndSetSemantics { contentDescription = spoken; role = Role.Button }
+ .heightIn(min = KvittTheme.spacing.rowMin)
+ .padding(horizontal = KvittTheme.spacing.rowHorizontal, vertical = KvittTheme.spacing.rowVertical),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.rowTileGap),
+ ) {
+ Column(Modifier.weight(1f)) {
+ Text(match.description, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ Text(details, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ MoneyText(match.amountOre, AmountStyle.Small, AmountTone.Neutral)
+ }
+}
+
+/** The history choice of S-25 and S-26 (CP-3) as wrapping chips, so "180 dagar" is never cut at large font scales. */
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+fun HistoryChips(options: List, selected: Int, onSelect: (Int) -> Unit, label: String, modifier: Modifier = Modifier, enabled: Boolean = true) {
+ FlowRow(
+ modifier.fillMaxWidth().semantics { contentDescription = label },
+ horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2),
+ ) {
+ options.forEach { days ->
+ FilterChip(
+ selected = days == selected,
+ onClick = { onSelect(days) },
+ label = { Text(pluralStringResource(R.plurals.cards_history_days, days, days)) },
+ enabled = enabled,
+ leadingIcon = if (days == selected) {
+ { KvittIconImage(KvittIcon.Check, null, size = KvittDimens.iconChip) }
+ } else {
+ null
+ },
+ )
+ }
+ }
+}
diff --git a/android/feature/cards/src/main/kotlin/se/hagstedt/kvitt/feature/cards/CardPickScreen.kt b/android/feature/cards/src/main/kotlin/se/hagstedt/kvitt/feature/cards/CardPickScreen.kt
new file mode 100644
index 0000000..d0563a6
--- /dev/null
+++ b/android/feature/cards/src/main/kotlin/se/hagstedt/kvitt/feature/cards/CardPickScreen.kt
@@ -0,0 +1,117 @@
+package se.hagstedt.kvitt.feature.cards
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.stateIn
+import se.hagstedt.kvitt.design.KvittTheme
+import se.hagstedt.kvitt.design.component.KvittScaffold
+import se.hagstedt.kvitt.design.component.KvittTopAppBar
+import se.hagstedt.kvitt.design.component.SegmentedGroup
+import se.hagstedt.kvitt.design.component.TopBarNavigation
+import se.hagstedt.kvitt.design.theme.KvittDimens
+import se.hagstedt.kvitt.model.AccountId
+import se.hagstedt.kvitt.model.AccountRepository
+import se.hagstedt.kvitt.model.CardPurchase
+import se.hagstedt.kvitt.model.CardPurchaseId
+import se.hagstedt.kvitt.model.CardPurchaseRow
+import se.hagstedt.kvitt.model.CardRepository
+import javax.inject.Inject
+
+/** What S-27 shows: the account's name and the viewer's purchases to handle; [purchases] null before the first read. */
+data class CardPickUi(val accountId: AccountId? = null, val accountName: String = "", val purchases: List? = null)
+
+/** S-27 state holder (AC-22.9): local only, works offline. Argument: [ARG_ACCOUNT_ID]. */
+@HiltViewModel
+class CardPickViewModel @Inject constructor(savedStateHandle: SavedStateHandle, cards: CardRepository, accounts: AccountRepository) : ViewModel() {
+ private val accountId = savedStateHandle.get(ARG_ACCOUNT_ID)?.let(::AccountId)
+
+ val ui: StateFlow = combine(cards.toHandle(), accounts.all()) { purchases, all ->
+ CardPickUi(accountId, all.firstOrNull { it.id == accountId }?.name.orEmpty(), purchases)
+ }.stateIn(viewModelScope, SharingStarted.Eagerly, CardPickUi(accountId))
+
+ companion object {
+ const val ARG_ACCOUNT_ID = "accountId"
+ }
+}
+
+/**
+ * S-27 Från kortköp (pushed from S-5): a tap opens S-7 prefilled with this account ([onPick], CP-9). It hosts `:app`'s
+ * [snackbarHostState], so "Utgiften sparades" shows here when S-7 returns (D-071).
+ */
+@Composable
+fun CardPickScreen(
+ onBack: () -> Unit,
+ onPick: (CardPurchaseId, AccountId?) -> Unit,
+ modifier: Modifier = Modifier,
+ snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
+ viewModel: CardPickViewModel = hiltViewModel(),
+) {
+ val ui by viewModel.ui.collectAsStateWithLifecycle()
+ CardPickContent(ui, onBack, { onPick(it, ui.accountId) }, modifier, snackbarHostState)
+}
+
+/** The stateless S-27: S-23's rows without chips, newest first. */
+@Composable
+fun CardPickContent(ui: CardPickUi, onBack: () -> Unit, onPick: (CardPurchaseId) -> Unit, modifier: Modifier = Modifier, snackbarHostState: SnackbarHostState? = null) {
+ KvittScaffold(
+ modifier,
+ snackbarHostState = snackbarHostState,
+ topBar = { KvittTopAppBar(stringResource(R.string.cards_pick_title), navigation = TopBarNavigation.Back, onNavigation = onBack, subtitle = ui.accountName.takeIf { it.isNotEmpty() }) },
+ ) { padding ->
+ val purchases = ui.purchases
+ when {
+ purchases == null -> Box(Modifier.fillMaxSize().padding(padding))
+ purchases.isEmpty() -> Column(Modifier.fillMaxSize().padding(padding).padding(KvittTheme.spacing.gutter)) {
+ Column(
+ Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surfaceContainerLow, KvittTheme.shapes.card).padding(horizontal = KvittDimens.emptyHorizontal, vertical = KvittDimens.emptyVertical),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text(stringResource(R.string.cards_pick_empty), style = MaterialTheme.typography.titleMedium, textAlign = TextAlign.Center)
+ }
+ }
+ else -> LazyColumn(
+ Modifier.fillMaxSize().padding(padding),
+ contentPadding = PaddingValues(start = KvittTheme.spacing.gutter, end = KvittTheme.spacing.gutter, top = KvittTheme.spacing.s1, bottom = KvittTheme.spacing.s6),
+ verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.sectionGap),
+ ) {
+ purchases.groupBy { it.date }.forEach { (date, inDay) ->
+ item(key = date) {
+ Column {
+ CardDayHeader(date)
+ SegmentedGroup(inDay.size) { item ->
+ val p = inDay[item.index]
+ CardPurchaseItem(item, CardPurchaseRow(p, emptyList()), onClick = { onPick(p.id) })
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/android/feature/cards/src/main/kotlin/se/hagstedt/kvitt/feature/cards/CardsScreen.kt b/android/feature/cards/src/main/kotlin/se/hagstedt/kvitt/feature/cards/CardsScreen.kt
new file mode 100644
index 0000000..d442c41
--- /dev/null
+++ b/android/feature/cards/src/main/kotlin/se/hagstedt/kvitt/feature/cards/CardsScreen.kt
@@ -0,0 +1,378 @@
+package se.hagstedt.kvitt.feature.cards
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FilterChip
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.SnackbarResult
+import androidx.compose.material3.Text
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.key
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalResources
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import kotlinx.coroutines.delay
+import se.hagstedt.kvitt.design.KvittTheme
+import se.hagstedt.kvitt.design.component.Banner
+import se.hagstedt.kvitt.design.component.BannerKind
+import se.hagstedt.kvitt.design.component.ButtonKind
+import se.hagstedt.kvitt.design.component.ButtonSize
+import se.hagstedt.kvitt.design.component.KvittButton
+import se.hagstedt.kvitt.design.component.KvittCard
+import se.hagstedt.kvitt.design.component.KvittDestination
+import se.hagstedt.kvitt.design.component.KvittNavigationBar
+import se.hagstedt.kvitt.design.component.KvittPullToRefresh
+import se.hagstedt.kvitt.design.component.KvittScaffold
+import se.hagstedt.kvitt.design.component.KvittTopAppBar
+import se.hagstedt.kvitt.design.component.NeedsConnectionHint
+import se.hagstedt.kvitt.design.component.SegmentedGroup
+import se.hagstedt.kvitt.design.component.relativeTime
+import se.hagstedt.kvitt.design.component.showKvittSnackbar
+import se.hagstedt.kvitt.design.icon.KvittIcon
+import se.hagstedt.kvitt.design.theme.KvittDimens
+import se.hagstedt.kvitt.model.CardConnectionId
+import se.hagstedt.kvitt.model.CardFilter
+import se.hagstedt.kvitt.model.CardProvider
+import se.hagstedt.kvitt.model.CardPurchaseId
+import se.hagstedt.kvitt.model.CardsState
+import se.hagstedt.kvitt.model.ExpenseId
+import se.hagstedt.kvitt.model.ExpenseMatch
+import se.hagstedt.kvitt.core.design.R as DesignR
+
+/** Navigation and actions of S-23; `:app` wires the navigation lambdas (D-017), the ViewModel the rest. */
+data class CardsActions(
+ val onNavigate: (KvittDestination) -> Unit = {},
+ val onConnect: () -> Unit = {},
+ val onOpenConnection: (CardConnectionId) -> Unit = {},
+ val onRenew: (String) -> Unit = {},
+ val onCreateExpense: (CardPurchaseId) -> Unit = {},
+ val onOpenExpense: (ExpenseId) -> Unit = {},
+ val onFilter: (CardFilter) -> Unit = {},
+ val onRefresh: () -> Unit = {},
+ val onPullToRefresh: () -> Unit = {},
+ val onOpenPurchase: (CardPurchaseId) -> Unit = {},
+ val onOpenOthers: () -> Unit = {},
+ val onDismissSheet: () -> Unit = {},
+ val onLink: (CardPurchaseId, ExpenseId) -> Unit = { _, _ -> },
+ val onIgnore: (CardPurchaseId) -> Unit = {},
+ val onUnignore: (CardPurchaseId) -> Unit = {},
+ val onUnlink: (CardPurchaseId) -> Unit = {},
+)
+
+/**
+ * S-23 Kortköp (top level, D-172): the holder's purchases by day with the filters, the purchase sheet and its second
+ * sheet "Välj en annan utgift". [snackbarHostState] is `:app`'s, so S-7's "Utgiften sparades" shows here (D-071).
+ */
+@Composable
+fun CardsScreen(
+ onNavigate: (KvittDestination) -> Unit,
+ onConnect: () -> Unit,
+ onOpenConnection: (CardConnectionId) -> Unit,
+ onRenew: (String) -> Unit,
+ onCreateExpense: (CardPurchaseId) -> Unit,
+ onOpenExpense: (ExpenseId) -> Unit,
+ modifier: Modifier = Modifier,
+ snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
+ viewModel: CardsViewModel = hiltViewModel(),
+) {
+ val ui by viewModel.ui.collectAsStateWithLifecycle()
+ val resources = LocalResources.current
+ // "Uppdaterad " is refreshed every minute, as the sync line (design-spec §3.6).
+ var minute by remember { mutableIntStateOf(0) }
+ LaunchedEffect(Unit) {
+ while (true) {
+ delay(MINUTE_MILLIS)
+ minute += 1
+ }
+ }
+ LaunchedEffect(ui.message) {
+ val message = ui.message ?: return@LaunchedEffect
+ viewModel.messageShown()
+ val (text, undo) = when (message) {
+ is CardsMessage.Linked -> R.string.cards_snackbar_linked to true
+ is CardsMessage.Ignored -> R.string.cards_snackbar_ignored to true
+ CardsMessage.CouldNotReach -> DesignR.string.common_could_not_reach_server to false
+ }
+ val result = snackbarHostState.showKvittSnackbar(resources, text, action = if (undo) DesignR.string.common_undo else null)
+ if (result == SnackbarResult.ActionPerformed) viewModel.undo(message)
+ }
+ val actions = CardsActions(
+ onNavigate = onNavigate,
+ onConnect = onConnect,
+ onOpenConnection = onOpenConnection,
+ onRenew = onRenew,
+ onCreateExpense = { id ->
+ viewModel.closeSheet()
+ onCreateExpense(id)
+ },
+ onOpenExpense = { id ->
+ viewModel.closeSheet()
+ onOpenExpense(id)
+ },
+ onFilter = viewModel::setFilter,
+ onRefresh = viewModel::refresh,
+ onPullToRefresh = viewModel::pullToRefresh,
+ onOpenPurchase = viewModel::openPurchase,
+ onOpenOthers = viewModel::openOthers,
+ onDismissSheet = viewModel::dismissSheet,
+ onLink = viewModel::link,
+ onIgnore = viewModel::ignore,
+ onUnignore = viewModel::unignore,
+ onUnlink = viewModel::unlink,
+ )
+ CardsContent(ui, actions, modifier, snackbarHostState, minute)
+}
+
+private const val MINUTE_MILLIS = 60_000L
+
+/** The stateless S-23 with its sheets, rendered by [CardsScreen], previews and screenshot tests. */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun CardsContent(ui: CardsUi, actions: CardsActions, modifier: Modifier = Modifier, snackbarHostState: SnackbarHostState? = null, tick: Int = 0) {
+ KvittScaffold(
+ modifier = modifier,
+ topBar = { KvittTopAppBar(stringResource(R.string.cards_title)) },
+ bottomBar = { KvittNavigationBar(KvittDestination.Cards, actions.onNavigate) },
+ snackbarHostState = snackbarHostState,
+ ) { padding ->
+ val cards = ui.cards
+ when {
+ cards == null -> Box(Modifier.fillMaxSize().padding(padding))
+ cards.connection == null -> NoConnection(cards.provider, actions, Modifier.padding(padding))
+ else -> KvittPullToRefresh(ui.refreshing, actions.onPullToRefresh, Modifier.fillMaxSize().padding(padding)) {
+ CardsList(ui, cards, actions, tick)
+ }
+ }
+ val detail = ui.detail
+ if (ui.sheet != null && detail != null) {
+ ModalBottomSheet(actions.onDismissSheet, sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)) {
+ if (ui.sheet == CardsSheet.Others) OthersSheet(detail.row.purchase.id, detail.others, actions) else PurchaseSheet(detail, actions)
+ }
+ }
+ }
+}
+
+/** No connection (D-172: both users always see the tab): large artwork, "Anslut ditt kort", "Anslut kort" → S-24. */
+@Composable
+private fun NoConnection(provider: CardProvider?, actions: CardsActions, modifier: Modifier) {
+ Column(
+ modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = KvittTheme.spacing.gutter, vertical = KvittTheme.spacing.s2),
+ ) {
+ Column(
+ Modifier
+ .fillMaxWidth()
+ .background(MaterialTheme.colorScheme.surfaceContainerLow, KvittTheme.shapes.card)
+ .padding(horizontal = KvittDimens.emptyHorizontal, vertical = KvittDimens.emptyVertical),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(KvittDimens.emptyGap),
+ ) {
+ CardArt(provider?.imageKey ?: SJ_PRIO_MASTERCARD, provider?.name.orEmpty(), Modifier.width(KvittDimens.cardArtLarge))
+ Text(stringResource(R.string.cards_empty_title), style = MaterialTheme.typography.headlineSmall, textAlign = TextAlign.Center)
+ Text(stringResource(R.string.cards_empty_body), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center)
+ KvittButton(stringResource(R.string.cards_connect), actions.onConnect, size = ButtonSize.Medium, icon = KvittIcon.CreditCard)
+ }
+ }
+}
+
+@Composable
+private fun CardsList(ui: CardsUi, cards: CardsState, actions: CardsActions, tick: Int) {
+ val spacing = KvittTheme.spacing
+ LazyColumn(
+ Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(start = spacing.gutter, end = spacing.gutter, top = spacing.s1, bottom = spacing.s6),
+ verticalArrangement = Arrangement.spacedBy(spacing.sectionGap),
+ ) {
+ item(key = "banner") { StatusBanner(ui, cards, actions) }
+ item(key = "header") { Header(ui, cards, actions, tick) }
+ if (ui.firstFetch) {
+ item(key = "first") { FirstFetch() }
+ return@LazyColumn
+ }
+ item(key = "filters") { Filters(cards, actions) }
+ if (cards.days.isEmpty()) {
+ item(key = "empty") { EmptyFilter(cards.filter) }
+ }
+ cards.days.forEach { day ->
+ item(key = "day-${day.date}") {
+ Column {
+ CardDayHeader(day.date)
+ SegmentedGroup(day.rows.size) { item ->
+ val row = day.rows[item.index]
+ val id = row.purchase.id
+ CardPurchaseItem(
+ item,
+ row,
+ onClick = { actions.onOpenPurchase(id) },
+ onMatch = { match -> actions.onLink(id, match.id) },
+ onMatches = { actions.onOpenPurchase(id) },
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+/** Expired (CP-6) wins over expiring and fetch failed (CP-14); at most one banner. */
+@Composable
+private fun StatusBanner(ui: CardsUi, cards: CardsState, actions: CardsActions) {
+ val connection = cards.connection ?: return
+ val provider = cards.provider?.id ?: connection.providerId
+ when {
+ ui.expired -> Banner(BannerKind.Error, KvittIcon.Error, stringResource(R.string.cards_expired)) { _ ->
+ KvittButton(stringResource(R.string.cards_renew), { actions.onRenew(provider) }, kind = ButtonKind.ErrorFilled, icon = KvittIcon.Key, enabled = ui.online)
+ }
+ ui.expiring -> Banner(BannerKind.Warning, KvittIcon.Warning, stringResource(R.string.cards_expiring, connection.validUntil?.let { instantListDate(it) }.orEmpty())) { content ->
+ KvittButton(stringResource(R.string.cards_renew), { actions.onRenew(provider) }, kind = ButtonKind.Text, contentColor = content, icon = KvittIcon.Key, enabled = ui.online)
+ }
+ ui.fetchFailed -> Banner(BannerKind.Error, KvittIcon.Error, stringResource(R.string.cards_fetch_failed, connection.lastFetchAt?.let { relativeTime(it) }.orEmpty())) { content ->
+ KvittButton(stringResource(DesignR.string.common_retry), actions.onRefresh, kind = ButtonKind.Text, contentColor = content, icon = KvittIcon.Refresh, enabled = ui.canRefresh)
+ }
+ }
+}
+
+/** The header card: small artwork, name, "Bara du ser dina kortköp", "Uppdaterad …" and "Uppdatera"; a tap opens S-26. */
+@Composable
+private fun Header(ui: CardsUi, cards: CardsState, actions: CardsActions, tick: Int) {
+ val connection = cards.connection ?: return
+ val provider = cards.provider
+ KvittCard(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3)) {
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .clickable(role = Role.Button, onClickLabel = stringResource(R.string.cards_connection_settings)) { actions.onOpenConnection(connection.id) }
+ .semantics(mergeDescendants = true) {},
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.cardTileGap),
+ ) {
+ CardArt(provider?.imageKey ?: connection.providerId, provider?.name.orEmpty(), Modifier.width(KvittDimens.cardArtHeader))
+ Column(Modifier.weight(1f)) {
+ Text(provider?.name.orEmpty(), style = MaterialTheme.typography.titleMedium)
+ Text(stringResource(R.string.cards_private_note), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ connection.lastFetchAt?.let { at ->
+ key(tick) {
+ Text(stringResource(R.string.cards_updated, relativeTime(at)), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ }
+ }
+ }
+ Column {
+ val fetching = cards.fetching
+ KvittButton(
+ stringResource(if (fetching) R.string.cards_refreshing else R.string.cards_refresh),
+ actions.onRefresh,
+ kind = ButtonKind.Tonal,
+ icon = KvittIcon.Refresh,
+ enabled = ui.online && !ui.expired,
+ loading = fetching,
+ )
+ if (!ui.online) NeedsConnectionHint()
+ }
+ }
+}
+
+@Composable
+private fun FirstFetch() {
+ Column(Modifier.fillMaxWidth().padding(vertical = KvittTheme.spacing.s8), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s4)) {
+ CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
+ Text(stringResource(R.string.cards_first_fetch), style = MaterialTheme.typography.titleMedium, textAlign = TextAlign.Center)
+ }
+}
+
+/** "Att hantera" (default), "Kopplade", "Ignorerade", "Alla", each with its count. */
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+private fun Filters(cards: CardsState, actions: CardsActions) {
+ // The four chips wrap rather than scroll, so the selected one is always in view (Swedish labels do not fit one row).
+ FlowRow(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2)) {
+ CardFilter.entries.forEach { filter ->
+ FilterChip(
+ selected = cards.filter == filter,
+ onClick = { actions.onFilter(filter) },
+ label = {
+ Row(horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s1)) {
+ Text(stringResource(filterLabel(filter)))
+ Text((cards.counts[filter] ?: 0).toString(), fontWeight = FontWeight.Bold)
+ }
+ },
+ )
+ }
+ }
+}
+
+fun filterLabel(filter: CardFilter): Int = when (filter) {
+ CardFilter.TO_HANDLE -> R.string.cards_filter_to_handle
+ CardFilter.LINKED -> R.string.cards_filter_linked
+ CardFilter.IGNORED -> R.string.cards_filter_ignored
+ CardFilter.ALL -> R.string.cards_filter_all
+}
+
+@Composable
+private fun EmptyFilter(filter: CardFilter) {
+ Column(
+ Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surfaceContainerLow, KvittTheme.shapes.card).padding(horizontal = KvittDimens.emptyHorizontal, vertical = KvittDimens.emptyVertical),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(KvittDimens.emptyGap),
+ ) {
+ if (filter == CardFilter.TO_HANDLE) {
+ Text(stringResource(R.string.cards_nothing_to_handle), style = MaterialTheme.typography.headlineSmall, textAlign = TextAlign.Center)
+ Text(stringResource(R.string.cards_nothing_to_handle_body), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center)
+ } else {
+ Text(stringResource(R.string.cards_filter_empty), style = MaterialTheme.typography.titleMedium, textAlign = TextAlign.Center)
+ }
+ }
+}
+
+/** "Välj en annan utgift" (CP-10): any amount within 14 days, newest first; one tap links. */
+@Composable
+fun OthersSheet(purchase: CardPurchaseId, others: List, actions: CardsActions) {
+ SheetColumn {
+ Text(stringResource(R.string.cards_sheet_other), style = MaterialTheme.typography.titleLarge)
+ if (others.isEmpty()) {
+ Text(stringResource(R.string.cards_sheet_no_matches), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ } else {
+ SegmentedGroup(others.size) { item -> ExpenseMatchItem(item, others[item.index], { actions.onLink(purchase, others[item.index].id) }) }
+ }
+ }
+}
+
+@Composable
+internal fun SheetColumn(content: @Composable ColumnScope.() -> Unit) {
+ Column(
+ Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(start = KvittTheme.spacing.gutter, end = KvittTheme.spacing.gutter, bottom = KvittTheme.spacing.s6),
+ verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3),
+ content = content,
+ )
+}
diff --git a/android/feature/cards/src/main/kotlin/se/hagstedt/kvitt/feature/cards/CardsViewModel.kt b/android/feature/cards/src/main/kotlin/se/hagstedt/kvitt/feature/cards/CardsViewModel.kt
new file mode 100644
index 0000000..4b5e0b7
--- /dev/null
+++ b/android/feature/cards/src/main/kotlin/se/hagstedt/kvitt/feature/cards/CardsViewModel.kt
@@ -0,0 +1,176 @@
+package se.hagstedt.kvitt.feature.cards
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import se.hagstedt.kvitt.model.CardConnectionStatus
+import se.hagstedt.kvitt.model.CardFetchError
+import se.hagstedt.kvitt.model.CardFilter
+import se.hagstedt.kvitt.model.CardPurchaseDetail
+import se.hagstedt.kvitt.model.CardPurchaseId
+import se.hagstedt.kvitt.model.CardRepository
+import se.hagstedt.kvitt.model.CardsState
+import se.hagstedt.kvitt.model.ConnectivityRepository
+import se.hagstedt.kvitt.model.ExpenseId
+import se.hagstedt.kvitt.model.OnlineResult
+import javax.inject.Inject
+
+/** A snackbar of S-23. [Linked] and [Ignored] carry "Ångra" (undo of that purchase's change). */
+sealed interface CardsMessage {
+ data class Linked(val purchase: CardPurchaseId) : CardsMessage
+ data class Ignored(val purchase: CardPurchaseId) : CardsMessage
+
+ /** "Kunde inte nå servern" (a failed "Uppdatera", §9.9). */
+ data object CouldNotReach : CardsMessage
+}
+
+/** Which sheet of S-23 is open: the purchase sheet, or its second sheet "Välj en annan utgift" (CP-10). */
+enum class CardsSheet { Purchase, Others }
+
+/**
+ * What S-23 shows. [cards] null before the first emission of the local copy. [detail] the open purchase's sheet;
+ * [refreshing] a pull to refresh is running (the indicator stays until it ends, D-127).
+ */
+data class CardsUi(
+ val cards: CardsState? = null,
+ val online: Boolean = true,
+ val sheet: CardsSheet? = null,
+ val detail: CardPurchaseDetail? = null,
+ val refreshing: Boolean = false,
+ val message: CardsMessage? = null,
+) {
+ val connection get() = cards?.connection
+
+ /** The consent has ended (CP-6): status `expired`, or the last fetch said so. */
+ val expired: Boolean get() = connection?.let { it.status == CardConnectionStatus.EXPIRED || it.lastFetchError == CardFetchError.CONSENT_EXPIRED } == true
+
+ /** Connected and no fetch has finished yet: "Hämtar dina köp…" (S-23 first fetch). */
+ val firstFetch: Boolean get() = connection?.let { it.status == CardConnectionStatus.ACTIVE && it.lastFetchAt == null && it.lastFetchError == null } == true
+
+ /** CP-14: a failed fetch other than an ended consent shows the banner with "Försök igen". */
+ val fetchFailed: Boolean get() = !expired && connection?.lastFetchError != null
+
+ /** CP-6: from 14 days before `valid_until`. */
+ val expiring: Boolean get() = !expired && connection?.expiresSoon == true
+
+ /** "Uppdatera" needs the server and an active consent (§9.9, AC-22.11). */
+ val canRefresh: Boolean get() = online && !expired && cards?.fetching != true
+}
+
+private data class CardsLocal(
+ val filter: CardFilter = CardFilter.TO_HANDLE,
+ val sheet: CardsSheet? = null,
+ val purchase: CardPurchaseId? = null,
+ val refreshing: Boolean = false,
+ val message: CardsMessage? = null,
+)
+
+/**
+ * S-23 state holder (SPEC §13.3 S-23, §9.12). Reads are Room's and work offline; linking and ignoring are local first
+ * (CP-9).
+ */
+@OptIn(ExperimentalCoroutinesApi::class)
+@HiltViewModel
+class CardsViewModel @Inject constructor(
+ private val cards: CardRepository,
+ private val connectivity: ConnectivityRepository,
+) : ViewModel() {
+ private val local = MutableStateFlow(CardsLocal())
+
+ private val state = local.map { it.filter }.let { filters -> filters.flatMapLatest { cards.cards(it) } }
+ private val detail = local.map { it.purchase }.flatMapLatest { id -> id?.let { cards.purchase(it) } ?: flowOf(null) }
+
+ val ui: StateFlow = combine(state, detail, connectivity.online, local) { s, d, online, l ->
+ CardsUi(cards = s, online = online, sheet = l.sheet.takeIf { d != null }, detail = d, refreshing = l.refreshing, message = l.message)
+ }.stateIn(viewModelScope, SharingStarted.Eagerly, CardsUi())
+
+ fun setFilter(filter: CardFilter) = local.update { it.copy(filter = filter) }
+
+ /** Pull to refresh (CP-5): E-42 and a card pull; offline it ends at once (§9.9). */
+ fun pullToRefresh() {
+ if (local.value.refreshing) return
+ local.update { it.copy(refreshing = true) }
+ viewModelScope.launch {
+ try {
+ if (connectivity.online.first()) cards.refresh()
+ } finally {
+ local.update { it.copy(refreshing = false) }
+ }
+ }
+ }
+
+ /** "Uppdatera" and the fetch-failed banner's "Försök igen". */
+ fun refresh() {
+ viewModelScope.launch {
+ when (val result = cards.refresh()) {
+ is OnlineResult.Failed -> if (result.code == NETWORK) local.update { it.copy(message = CardsMessage.CouldNotReach) }
+ OnlineResult.Offline, is OnlineResult.Ok -> Unit
+ }
+ }
+ }
+
+ fun openPurchase(id: CardPurchaseId) = local.update { it.copy(purchase = id, sheet = CardsSheet.Purchase) }
+
+ fun openOthers() = local.update { it.copy(sheet = CardsSheet.Others) }
+
+ /** Back from "Välj en annan utgift" returns to the purchase sheet; from the purchase sheet it closes. */
+ fun dismissSheet() = local.update { if (it.sheet == CardsSheet.Others) it.copy(sheet = CardsSheet.Purchase) else it.copy(sheet = null, purchase = null) }
+
+ fun closeSheet() = local.update { it.copy(sheet = null, purchase = null) }
+
+ /** One tap links (the row's chip or a match in the sheet), closes the sheet and shows "Kopplad" with "Ångra" (CP-9). */
+ fun link(purchase: CardPurchaseId, expense: ExpenseId) {
+ closeSheet()
+ viewModelScope.launch {
+ cards.link(purchase, expense)
+ local.update { it.copy(message = CardsMessage.Linked(purchase)) }
+ }
+ }
+
+ fun ignore(purchase: CardPurchaseId) {
+ closeSheet()
+ viewModelScope.launch {
+ cards.setIgnored(purchase, true)
+ local.update { it.copy(message = CardsMessage.Ignored(purchase)) }
+ }
+ }
+
+ fun unignore(purchase: CardPurchaseId) {
+ closeSheet()
+ viewModelScope.launch { cards.setIgnored(purchase, false) }
+ }
+
+ fun unlink(purchase: CardPurchaseId) {
+ closeSheet()
+ viewModelScope.launch { cards.unlink(purchase) }
+ }
+
+ /** "Ångra" on a snackbar. */
+ fun undo(message: CardsMessage) {
+ viewModelScope.launch {
+ when (message) {
+ is CardsMessage.Linked -> cards.unlink(message.purchase)
+ is CardsMessage.Ignored -> cards.setIgnored(message.purchase, false)
+ CardsMessage.CouldNotReach -> Unit
+ }
+ }
+ }
+
+ fun messageShown() = local.update { it.copy(message = null) }
+
+ private companion object {
+ const val NETWORK = "network"
+ }
+}
diff --git a/android/feature/cards/src/main/kotlin/se/hagstedt/kvitt/feature/cards/ConnectCardScreen.kt b/android/feature/cards/src/main/kotlin/se/hagstedt/kvitt/feature/cards/ConnectCardScreen.kt
new file mode 100644
index 0000000..be015b5
--- /dev/null
+++ b/android/feature/cards/src/main/kotlin/se/hagstedt/kvitt/feature/cards/ConnectCardScreen.kt
@@ -0,0 +1,169 @@
+package se.hagstedt.kvitt.feature.cards
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.semantics
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import se.hagstedt.kvitt.design.KvittTheme
+import se.hagstedt.kvitt.design.component.Banner
+import se.hagstedt.kvitt.design.component.BannerKind
+import se.hagstedt.kvitt.design.component.ButtonKind
+import se.hagstedt.kvitt.design.component.KvittButton
+import se.hagstedt.kvitt.design.component.KvittCard
+import se.hagstedt.kvitt.design.component.KvittScaffold
+import se.hagstedt.kvitt.design.component.KvittTopAppBar
+import se.hagstedt.kvitt.design.component.NeedsConnectionBanner
+import se.hagstedt.kvitt.design.component.Skeleton
+import se.hagstedt.kvitt.design.component.TopBarNavigation
+import se.hagstedt.kvitt.design.icon.KvittIcon
+import se.hagstedt.kvitt.design.theme.KvittDimens
+import se.hagstedt.kvitt.model.CardConnection
+import se.hagstedt.kvitt.model.CardConnectionId
+import se.hagstedt.kvitt.model.CardFilter
+import se.hagstedt.kvitt.model.CardProvider
+import se.hagstedt.kvitt.model.CardRepository
+import se.hagstedt.kvitt.model.ConnectivityRepository
+import se.hagstedt.kvitt.model.OnlineResult
+import javax.inject.Inject
+import se.hagstedt.kvitt.core.design.R as DesignR
+
+/** What E-36 gave S-24. */
+sealed interface CatalogueLoad {
+ data object Loading : CatalogueLoad
+ data class Loaded(val providers: List) : CatalogueLoad
+
+ /** Offline when S-24 opened: the catalogue comes from the server (§9.9); [known] the cached entry, drawn disabled. */
+ data class Offline(val known: CardProvider?) : CatalogueLoad
+ data object Failed : CatalogueLoad
+}
+
+/** S-24: the catalogue and which provider the viewer has connected (ACTIVE or EXPIRED). */
+data class ConnectCardUi(val load: CatalogueLoad = CatalogueLoad.Loading, val connection: CardConnection? = null, val online: Boolean = true)
+
+/** S-24 state holder: E-36 when the screen opens, again on "Försök igen" or when the phone comes online. */
+@HiltViewModel
+class ConnectCardViewModel @Inject constructor(private val cards: CardRepository, private val connectivity: ConnectivityRepository) : ViewModel() {
+ private val load = MutableStateFlow(CatalogueLoad.Loading)
+
+ val ui: StateFlow = combine(load, cards.cards(CardFilter.ALL), connectivity.online) { l, c, online ->
+ ConnectCardUi(l, c.connection, online)
+ }.stateIn(viewModelScope, SharingStarted.Eagerly, ConnectCardUi())
+
+ init {
+ retry()
+ viewModelScope.launch {
+ connectivity.online.collect { online -> if (online && load.value is CatalogueLoad.Offline) retry() }
+ }
+ }
+
+ fun retry() {
+ load.value = CatalogueLoad.Loading
+ viewModelScope.launch {
+ load.value = when (val result = cards.providers()) {
+ is OnlineResult.Ok -> CatalogueLoad.Loaded(result.value)
+ OnlineResult.Offline -> CatalogueLoad.Offline(cards.cards(CardFilter.ALL).first().provider)
+ is OnlineResult.Failed -> CatalogueLoad.Failed
+ }
+ }
+ }
+}
+
+/** S-24 Anslut kort (pushed from S-13): one tile per catalogue entry; a connected card opens S-26, another S-25. */
+@Composable
+fun ConnectCardScreen(
+ onBack: () -> Unit,
+ onOpenProvider: (String) -> Unit,
+ onOpenConnection: (CardConnectionId) -> Unit,
+ modifier: Modifier = Modifier,
+ viewModel: ConnectCardViewModel = hiltViewModel(),
+) {
+ val ui by viewModel.ui.collectAsStateWithLifecycle()
+ ConnectCardContent(ui, onBack, onOpenProvider, onOpenConnection, viewModel::retry, modifier)
+}
+
+@Composable
+fun ConnectCardContent(
+ ui: ConnectCardUi,
+ onBack: () -> Unit,
+ onOpenProvider: (String) -> Unit,
+ onOpenConnection: (CardConnectionId) -> Unit,
+ onRetry: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ KvittScaffold(modifier, topBar = { KvittTopAppBar(stringResource(R.string.cards_connect_title), navigation = TopBarNavigation.Back, onNavigation = onBack) }) { padding ->
+ Column(
+ Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(horizontal = KvittTheme.spacing.gutter, vertical = KvittTheme.spacing.s2),
+ verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.sectionGap),
+ ) {
+ Text(stringResource(R.string.cards_connect_intro), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ when (val load = ui.load) {
+ CatalogueLoad.Loading -> KvittCard(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3)) {
+ Skeleton(KvittDimens.cardArtLarge, KvittDimens.cardArtLarge / KvittDimens.CARD_ART_ASPECT, Modifier.fillMaxWidth())
+ Skeleton(KvittDimens.skeletonPillWidth, KvittTheme.spacing.s5)
+ }
+ is CatalogueLoad.Loaded -> load.providers.forEach { provider ->
+ val connection = ui.connection?.takeIf { it.providerId == provider.id }
+ ProviderTile(provider, connection, enabled = ui.online && (connection != null || provider.available)) {
+ if (connection != null) onOpenConnection(connection.id) else onOpenProvider(provider.id)
+ }
+ }
+ is CatalogueLoad.Offline -> {
+ NeedsConnectionBanner(stringResource(R.string.cards_connect_offline_body))
+ load.known?.let { ProviderTile(it, ui.connection?.takeIf { c -> c.providerId == it.id }, enabled = false) {} }
+ }
+ CatalogueLoad.Failed -> Banner(BannerKind.Error, KvittIcon.Error, stringResource(DesignR.string.common_could_not_reach_server)) { content ->
+ KvittButton(stringResource(DesignR.string.common_retry), onRetry, kind = ButtonKind.Text, contentColor = content, icon = KvittIcon.Refresh)
+ }
+ }
+ }
+ }
+}
+
+/** A catalogue tile: artwork (full width, card aspect), name, issuer, and its connected or unavailable line. */
+@Composable
+private fun ProviderTile(provider: CardProvider, connection: CardConnection?, enabled: Boolean, onClick: () -> Unit) {
+ val line = when {
+ connection != null -> connection.validUntil?.let { stringResource(R.string.cards_provider_connected, instantListDate(it)) } ?: stringResource(R.string.cards_status_active)
+ !provider.available -> stringResource(R.string.cards_provider_unavailable)
+ else -> null
+ }
+ KvittCard(
+ Modifier
+ .fillMaxWidth()
+ .clickable(enabled = enabled, role = Role.Button, onClick = onClick)
+ .semantics(mergeDescendants = true) {},
+ verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3),
+ ) {
+ CardArt(provider.imageKey, provider.name, Modifier.fillMaxWidth().alpha(if (enabled) 1f else KvittDimens.DISABLED_CONTENT_ALPHA * 2))
+ Column {
+ Text(provider.name, style = MaterialTheme.typography.titleMedium)
+ Text(provider.issuer, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ if (line != null) Text(line, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ }
+}
diff --git a/android/feature/cards/src/main/kotlin/se/hagstedt/kvitt/feature/cards/PurchaseSheet.kt b/android/feature/cards/src/main/kotlin/se/hagstedt/kvitt/feature/cards/PurchaseSheet.kt
new file mode 100644
index 0000000..1ebc832
--- /dev/null
+++ b/android/feature/cards/src/main/kotlin/se/hagstedt/kvitt/feature/cards/PurchaseSheet.kt
@@ -0,0 +1,89 @@
+package se.hagstedt.kvitt.feature.cards
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import se.hagstedt.kvitt.design.KvittTheme
+import se.hagstedt.kvitt.design.component.AmountStyle
+import se.hagstedt.kvitt.design.component.AmountTone
+import se.hagstedt.kvitt.design.component.BadgeKind
+import se.hagstedt.kvitt.design.component.ButtonKind
+import se.hagstedt.kvitt.design.component.ButtonSize
+import se.hagstedt.kvitt.design.component.KvittBadge
+import se.hagstedt.kvitt.design.component.KvittButton
+import se.hagstedt.kvitt.design.component.MoneyText
+import se.hagstedt.kvitt.design.component.SegmentedGroup
+import se.hagstedt.kvitt.design.component.detailDate
+import se.hagstedt.kvitt.design.icon.KvittIcon
+import se.hagstedt.kvitt.model.CardDirection
+import se.hagstedt.kvitt.model.CardPurchase
+import se.hagstedt.kvitt.model.CardPurchaseDetail
+import se.hagstedt.kvitt.model.CardPurchaseStatus
+
+/**
+ * The purchase sheet of S-23 (§13.3): merchant, the purchase date in the detail form, amount and status. To handle:
+ * "Skapa utgift" (primary, S-7 prefilled, CP-9), "Koppla till befintlig utgift" with up to three matches (one tap links)
+ * or "Ingen utgift med samma belopp runt det här datumet", "Välj en annan utgift", "Ignorera". Linked: "Öppna utgift",
+ * "Ta bort koppling". Ignored: "Sluta ignorera". Credit: the note, no action. Foreign currency: the note above
+ * "Skapa utgift". None of these needs the server (CP-9). Linked means [CardPurchase.linked] is set: the contract has no
+ * link while the expense is not on the phone (§14.2.4), so such a purchase shows the to-handle actions until it arrives.
+ */
+@Composable
+fun PurchaseSheet(detail: CardPurchaseDetail, actions: CardsActions) {
+ val p = detail.row.purchase
+ val linked = p.linked
+ SheetColumn {
+ Column(verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s1)) {
+ Text(p.merchant, style = MaterialTheme.typography.titleLarge)
+ Text(detailDate(p.date), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3)) {
+ if (p.amountOre != null) {
+ MoneyText(p.amountOre ?: 0L, AmountStyle.Large, AmountTone.Neutral)
+ } else {
+ Text(foreignAmount(p), style = MaterialTheme.typography.headlineSmall)
+ }
+ val pending = p.status == CardPurchaseStatus.PENDING
+ KvittBadge(stringResource(if (pending) R.string.cards_pending else R.string.cards_booked), kind = if (pending) BadgeKind.Pending else BadgeKind.Neutral)
+ if (p.direction == CardDirection.CREDIT) KvittBadge(stringResource(R.string.cards_credit))
+ }
+ when {
+ p.direction == CardDirection.CREDIT -> Note(stringResource(R.string.cards_sheet_credit_note))
+ linked != null -> {
+ Note(stringResource(R.string.cards_linked_to, linked.description, linked.accountName))
+ KvittButton(stringResource(R.string.cards_sheet_open_expense), { actions.onOpenExpense(linked.id) }, Modifier.fillMaxWidth(), size = ButtonSize.Medium, icon = KvittIcon.OpenInNew)
+ KvittButton(stringResource(R.string.cards_sheet_unlink), { actions.onUnlink(p.id) }, Modifier.fillMaxWidth(), kind = ButtonKind.Outlined, icon = KvittIcon.Close)
+ }
+ p.ignored -> KvittButton(stringResource(R.string.cards_sheet_unignore), { actions.onUnignore(p.id) }, Modifier.fillMaxWidth(), kind = ButtonKind.Tonal, icon = KvittIcon.Unarchive)
+ else -> ToHandle(detail, actions)
+ }
+ }
+}
+
+@Composable
+private fun ToHandle(detail: CardPurchaseDetail, actions: CardsActions) {
+ val p = detail.row.purchase
+ if (p.amountOre == null) Note(stringResource(R.string.cards_sheet_foreign_note, p.originalCurrency.orEmpty()))
+ KvittButton(stringResource(R.string.cards_sheet_create), { actions.onCreateExpense(p.id) }, Modifier.fillMaxWidth(), size = ButtonSize.Medium, icon = KvittIcon.Add)
+ Text(stringResource(R.string.cards_sheet_matches), style = MaterialTheme.typography.titleSmall)
+ val matches = detail.row.matches
+ if (matches.isEmpty()) {
+ Note(stringResource(R.string.cards_sheet_no_matches))
+ } else {
+ SegmentedGroup(matches.size) { item -> ExpenseMatchItem(item, matches[item.index], { actions.onLink(p.id, matches[item.index].id) }) }
+ }
+ KvittButton(stringResource(R.string.cards_sheet_other), actions.onOpenOthers, Modifier.fillMaxWidth(), kind = ButtonKind.Outlined, icon = KvittIcon.Link)
+ KvittButton(stringResource(R.string.cards_sheet_ignore), { actions.onIgnore(p.id) }, Modifier.fillMaxWidth(), kind = ButtonKind.Text, icon = KvittIcon.Block)
+}
+
+@Composable
+private fun Note(text: String) {
+ Text(text, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
+}
diff --git a/android/feature/cards/src/main/res/values-sv/strings.xml b/android/feature/cards/src/main/res/values-sv/strings.xml
new file mode 100644
index 0000000..20013dc
--- /dev/null
+++ b/android/feature/cards/src/main/res/values-sv/strings.xml
@@ -0,0 +1,144 @@
+
+
+
+
+ Kortköp
+
+ Bara du ser dina kortköp
+
+ Uppdaterad %1$s
+
+ Uppdatera
+
+ Uppdaterar…
+
+ Att hantera
+
+ Kopplade
+
+ Ignorerade
+
+ Alla
+
+ Preliminär
+
+ Bokförd
+
+ Insättning
+
+ Utländsk valuta
+
+ %1$s %2$s
+
+ Koppla till %1$s
+
+
+ - %1$d möjlig utgift
+ - %1$d möjliga utgifter
+
+
+ Kopplad till %1$s · %2$s
+
+ Anslut ditt kort
+
+ Se dina köp här och gör om dem till utgifter med några tryck. Bara du ser dem.
+
+ Anslut kort
+
+ Hämtar dina köp…
+
+ Inget att hantera
+
+ Nya kortköp visas här.
+
+ Inga köp här
+
+ Kortanslutningen upphör %1$s
+
+ Kortanslutningen har upphört
+
+ Förnya med BankID
+
+ Kunde inte hämta köp. Senast uppdaterad %1$s
+
+ Skapa utgift
+
+ Koppla till befintlig utgift
+
+ Ingen utgift med samma belopp runt det här datumet
+
+ Välj en annan utgift
+
+ Ignorera
+
+ Sluta ignorera
+
+ Ta bort koppling
+
+ Öppna utgift
+
+ Insättningar och återbetalningar är inga utgifter. Ändra den ursprungliga utgiften för hand vid behov.
+
+ Beloppet är i %1$s: skriv beloppet i kronor.
+
+ Kopplad
+
+ Ignorerad
+
+ Kortanslutning
+
+ Anslut kort
+
+ Välj ditt kort. Kvitt läser dina köp men kan aldrig betala eller flytta pengar.
+
+ Listan över kort hämtas från servern. Anslut telefonen till internet.
+
+ Ansluten · giltig till %1$s
+
+ Inte konfigurerat på servern
+
+ Hämta köp från de senaste
+
+
+ - %1$d dag
+ - %1$d dagar
+
+
+ Du kan korta perioden senare. En längre period kräver BankID igen.
+
+ Anslut med BankID
+
+ Väntar på BankID…
+
+ Slutför i webbläsaren och i BankID och kom sedan tillbaka hit.
+
+ Kortet är anslutet
+
+ Det gick inte att ansluta kortet
+
+ Anslutningsförsöket tog för lång tid
+
+ Ansluten
+
+ Har upphört
+
+ Giltig till %1$s
+
+ Senast uppdaterad %1$s
+
+ Historik vid anslutning
+
+ En längre period kräver BankID igen
+
+ Koppla från
+
+ Koppla från %1$s?
+
+ Köp som du inte har kopplat eller ignorerat raderas. Utgifter du har skapat finns kvar.
+
+ Radera även kopplade och ignorerade köp
+
+ Från kortköp
+
+ Inga kortköp att hantera
+
diff --git a/android/feature/cards/src/main/res/values/strings.xml b/android/feature/cards/src/main/res/values/strings.xml
new file mode 100644
index 0000000..171337c
--- /dev/null
+++ b/android/feature/cards/src/main/res/values/strings.xml
@@ -0,0 +1,144 @@
+
+
+
+
+ Card purchases
+
+ Only you see your card purchases
+
+ Updated %1$s
+
+ Refresh
+
+ Refreshing…
+
+ To handle
+
+ Linked
+
+ Ignored
+
+ All
+
+ Pending
+
+ Booked
+
+ Deposit
+
+ Foreign currency
+
+ %1$s %2$s
+
+ Link to %1$s
+
+
+ - %1$d possible expense
+ - %1$d possible expenses
+
+
+ Linked to %1$s · %2$s
+
+ Connect your card
+
+ See your purchases here and turn them into expenses in a few taps. Only you see them.
+
+ Connect card
+
+ Fetching your purchases…
+
+ Nothing to handle
+
+ New card purchases show up here.
+
+ No purchases here
+
+ Your card connection ends on %1$s
+
+ Your card connection has ended
+
+ Renew with BankID
+
+ Could not fetch purchases. Last updated %1$s
+
+ Create expense
+
+ Link to an existing expense
+
+ No expense with the same amount around this date
+
+ Choose another expense
+
+ Ignore
+
+ Stop ignoring
+
+ Remove link
+
+ Open expense
+
+ Deposits and refunds are not expenses. Change the original expense by hand if needed.
+
+ The amount is in %1$s: type the amount in kronor.
+
+ Linked
+
+ Ignored
+
+ Card connection
+
+ Connect card
+
+ Choose your card. Kvitt reads your purchases but can never pay or move money.
+
+ The list of cards comes from the server. Connect the phone to the internet.
+
+ Connected · valid until %1$s
+
+ Not set up on the server
+
+ Fetch purchases from the last
+
+
+ - %1$d day
+ - %1$d days
+
+
+ You can shorten this later. A longer period needs BankID again.
+
+ Connect with BankID
+
+ Waiting for BankID…
+
+ Finish in the browser and in BankID, then come back here.
+
+ Card connected
+
+ The card could not be connected
+
+ The connection attempt took too long
+
+ Connected
+
+ Ended
+
+ Valid until %1$s
+
+ Last updated %1$s
+
+ History when connecting
+
+ A longer period needs BankID again
+
+ Disconnect
+
+ Disconnect %1$s?
+
+ Purchases you have not linked or ignored are deleted. Expenses you created stay.
+
+ Also delete linked and ignored purchases
+
+ From card purchases
+
+ No card purchases to handle
+
diff --git a/android/feature/cards/src/test/kotlin/se/hagstedt/kvitt/feature/cards/CardStateHoldersTest.kt b/android/feature/cards/src/test/kotlin/se/hagstedt/kvitt/feature/cards/CardStateHoldersTest.kt
new file mode 100644
index 0000000..5ab90ff
--- /dev/null
+++ b/android/feature/cards/src/test/kotlin/se/hagstedt/kvitt/feature/cards/CardStateHoldersTest.kt
@@ -0,0 +1,206 @@
+package se.hagstedt.kvitt.feature.cards
+
+import androidx.lifecycle.SavedStateHandle
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.advanceTimeBy
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import se.hagstedt.kvitt.design.testing.MainDispatcherRule
+import se.hagstedt.kvitt.fakes.FakeRepositories
+import se.hagstedt.kvitt.fakes.FakeScenario
+import se.hagstedt.kvitt.fakes.cards.CardScenarios
+import se.hagstedt.kvitt.fakes.home.HomeScenarios
+import se.hagstedt.kvitt.model.CardAttemptState
+import se.hagstedt.kvitt.model.CardFilter
+import se.hagstedt.kvitt.model.CardRepository
+import se.hagstedt.kvitt.model.OnlineResult
+
+/** The state holders of S-23 to S-27 on the fakes (SPEC §13.3, §9.12 CP-2, CP-3, CP-9, CP-12). */
+@OptIn(ExperimentalCoroutinesApi::class)
+class CardStateHoldersTest {
+ @get:Rule
+ val main = MainDispatcherRule()
+
+ private fun cards(scenario: FakeScenario): Pair {
+ val fakes = FakeRepositories(scenario)
+ return CardsViewModel(fakes.cards, fakes.connectivity) to fakes
+ }
+
+ @Test
+ fun s23_startsUnderToHandle_withMatchChips_andTheFiltersSwitch() = runTest(main.dispatcher) {
+ val (vm, _) = cards(CardScenarios.loaded)
+ val ui = vm.ui.value
+ assertEquals(CardFilter.TO_HANDLE, ui.cards?.filter)
+ val rows = ui.cards?.days.orEmpty().flatMap { it.rows }
+ assertEquals("ICA Maxi Lindhagen", rows.first { it.purchase.id == CardScenarios.ica.id }.matches.single().description)
+ assertEquals(2, rows.first { it.purchase.id == CardScenarios.twoMatches.id }.matches.size)
+ assertTrue(rows.none { it.purchase.id == CardScenarios.payment.id })
+ vm.setFilter(CardFilter.ALL)
+ assertTrue(vm.ui.value.cards?.days.orEmpty().flatMap { it.rows }.any { it.purchase.id == CardScenarios.payment.id })
+ }
+
+ @Test
+ fun s23_linkFromTheChip_isLocal_showsKopplad_andUndoUnlinks() = runTest(main.dispatcher) {
+ val (vm, fakes) = cards(CardScenarios.offline)
+ vm.link(CardScenarios.ica.id, HomeScenarios.icaMaxi.id)
+ assertEquals(HomeScenarios.icaMaxi.id, fakes.store.current.cards.purchases.first { it.id == CardScenarios.ica.id }.expenseId)
+ val message = vm.ui.value.message
+ assertEquals(CardsMessage.Linked(CardScenarios.ica.id), message)
+ vm.messageShown()
+ vm.undo(requireNotNull(message))
+ assertNull(fakes.store.current.cards.purchases.first { it.id == CardScenarios.ica.id }.expenseId)
+ }
+
+ @Test
+ fun s23_ignore_closesTheSheet_andUndoStopsIgnoring() = runTest(main.dispatcher) {
+ val (vm, fakes) = cards(CardScenarios.loaded)
+ vm.openPurchase(CardScenarios.wolt.id)
+ assertEquals(CardsSheet.Purchase, vm.ui.value.sheet)
+ vm.openOthers()
+ assertEquals(CardsSheet.Others, vm.ui.value.sheet)
+ vm.dismissSheet()
+ assertEquals(CardsSheet.Purchase, vm.ui.value.sheet)
+ vm.ignore(CardScenarios.wolt.id)
+ assertNull(vm.ui.value.sheet)
+ assertTrue(fakes.store.current.cards.purchases.first { it.id == CardScenarios.wolt.id }.ignored)
+ vm.undo(CardsMessage.Ignored(CardScenarios.wolt.id))
+ assertFalse(fakes.store.current.cards.purchases.first { it.id == CardScenarios.wolt.id }.ignored)
+ }
+
+ @Test
+ fun s23_states_readTheConnection() = runTest(main.dispatcher) {
+ assertNull(cards(CardScenarios.none).first.ui.value.connection)
+ assertTrue(cards(CardScenarios.firstFetch).first.ui.value.firstFetch)
+ assertTrue(cards(CardScenarios.expiring).first.ui.value.expiring)
+ val expired = cards(CardScenarios.expired).first.ui.value
+ assertTrue(expired.expired)
+ assertFalse(expired.fetchFailed)
+ assertFalse(expired.canRefresh)
+ assertTrue(cards(CardScenarios.fetchFailed).first.ui.value.fetchFailed)
+ assertFalse(cards(CardScenarios.offline).first.ui.value.canRefresh)
+ }
+
+ @Test
+ fun s23_pullToRefreshOffline_endsAtOnce() = runTest(main.dispatcher) {
+ val (vm, _) = cards(CardScenarios.offline)
+ vm.pullToRefresh()
+ assertFalse(vm.ui.value.refreshing)
+ }
+
+ /** Counts E-38 calls. */
+ private class CountingCards(private val delegate: CardRepository) : CardRepository by delegate {
+ var attempts = 0
+ override suspend fun attempt(attemptId: String): OnlineResult {
+ attempts++
+ return delegate.attempt(attemptId)
+ }
+ }
+
+ @Test
+ fun s25_pollsE38EveryTwoSecondsWhileResumed_andStopsWhenPaused() = runTest(StandardTestDispatcher(main.dispatcher.scheduler)) {
+ val fakes = FakeRepositories(CardScenarios.attemptPending)
+ val counting = CountingCards(fakes.cards)
+ val vm = CardConnectViewModel(SavedStateHandle(mapOf(CardConnectViewModel.ARG_PROVIDER_ID to "sj_prio_mastercard")), counting, fakes.connectivity)
+ val events = mutableListOf()
+ backgroundScope.launch { vm.eventFlow.collect { events += it } }
+ vm.onResume()
+ vm.start()
+ runCurrent()
+ assertEquals(ConnectPhase.Waiting, vm.ui.value.phase)
+ assertTrue(events.single() is ConnectEvent.OpenBank)
+ advanceTimeBy(CardConnectViewModel.POLL_MILLIS * 3 + 1)
+ assertEquals(3, counting.attempts)
+ vm.onPause()
+ advanceTimeBy(CardConnectViewModel.POLL_MILLIS * 3)
+ assertEquals(3, counting.attempts)
+ vm.onResume()
+ advanceTimeBy(CardConnectViewModel.POLL_MILLIS + 1)
+ assertEquals(4, counting.attempts)
+ vm.cancel()
+ advanceTimeBy(CardConnectViewModel.POLL_MILLIS * 3)
+ assertEquals(4, counting.attempts)
+ assertEquals(ConnectPhase.Choose, vm.ui.value.phase)
+ }
+
+ @Test
+ fun s25_connected_showsTheResult_thenGoesOn() = runTest(StandardTestDispatcher(main.dispatcher.scheduler)) {
+ val fakes = FakeRepositories(CardScenarios.attemptConnected)
+ val vm = CardConnectViewModel(SavedStateHandle(mapOf(CardConnectViewModel.ARG_PROVIDER_ID to "sj_prio_mastercard")), fakes.cards, fakes.connectivity)
+ val events = mutableListOf()
+ backgroundScope.launch { vm.eventFlow.collect { events += it } }
+ vm.onResume()
+ vm.start()
+ advanceTimeBy(CardConnectViewModel.POLL_MILLIS + 1)
+ assertEquals(ConnectPhase.Connected, vm.ui.value.phase)
+ assertTrue(fakes.cards.hasConnection.first())
+ advanceTimeBy(CardConnectViewModel.CONNECTED_MILLIS + 1)
+ assertEquals(ConnectEvent.Done, events.last())
+ }
+
+ @Test
+ fun s25_failedAndExpiredAttempts_andTheReturnLinkAsksAtOnce() = runTest(main.dispatcher) {
+ val args = mapOf(CardConnectViewModel.ARG_PROVIDER_ID to "sj_prio_mastercard")
+ val failed = FakeRepositories(CardScenarios.attemptFailed)
+ val vm = CardConnectViewModel(SavedStateHandle(args), failed.cards, failed.connectivity)
+ vm.onReturn("01920000-0000-7000-8000-0000000000aa")
+ assertEquals(ConnectPhase.Failed, vm.ui.value.phase)
+ val expired = FakeRepositories(CardScenarios.attemptExpired)
+ val other = CardConnectViewModel(SavedStateHandle(args + (CardConnectViewModel.ARG_ATTEMPT to "01920000-0000-7000-8000-0000000000aa")), expired.cards, expired.connectivity)
+ advanceUntilIdle()
+ assertEquals(ConnectPhase.TimedOut, other.ui.value.phase)
+ }
+
+ @Test
+ fun s25_historyDefaultsTo30_aRenewalStartsFromTheConnections_andOfflineCannotStart() = runTest(main.dispatcher) {
+ val none = FakeRepositories(CardScenarios.none)
+ val vm = CardConnectViewModel(SavedStateHandle(mapOf(CardConnectViewModel.ARG_PROVIDER_ID to "sj_prio_mastercard")), none.cards, none.connectivity)
+ assertEquals(30, vm.ui.value.historyDays)
+ vm.setHistory(180)
+ assertEquals(180, vm.ui.value.historyDays)
+ val renew = FakeRepositories(CardScenarios.loaded.copy(cards = CardScenarios.loaded.cards.copy(connection = CardScenarios.connection.copy(historyDays = 90))))
+ val renewing = CardConnectViewModel(SavedStateHandle(mapOf(CardConnectViewModel.ARG_PROVIDER_ID to "sj_prio_mastercard", CardConnectViewModel.ARG_RENEW to "true")), renew.cards, renew.connectivity)
+ assertEquals(90, renewing.ui.value.historyDays)
+ assertTrue(renewing.ui.value.renew)
+ val offline = FakeRepositories(CardScenarios.connectOffline)
+ val off = CardConnectViewModel(SavedStateHandle(mapOf(CardConnectViewModel.ARG_PROVIDER_ID to "sj_prio_mastercard")), offline.cards, offline.connectivity)
+ off.start()
+ assertEquals(ConnectPhase.Choose, off.ui.value.phase)
+ assertFalse(off.ui.value.online)
+ }
+
+ @Test
+ fun s26_historyOnlyShorter_andDisconnectKeepsTheHandledPurchases() = runTest(main.dispatcher) {
+ val fakes = FakeRepositories(CardScenarios.loaded)
+ val vm = CardConnectionViewModel(SavedStateHandle(mapOf(CardConnectionViewModel.ARG_CONNECTION_ID to CardScenarios.connectionId.value)), fakes.cards, fakes.connectivity)
+ val events = mutableListOf()
+ backgroundScope.launch { vm.eventFlow.collect { events += it } }
+ vm.setHistory(90)
+ assertEquals(30, fakes.store.current.cards.connection?.historyDays)
+ vm.setHistory(7)
+ assertEquals(7, fakes.store.current.cards.connection?.historyDays)
+ vm.askDisconnect()
+ assertTrue(vm.ui.value.disconnectDialog)
+ vm.confirmDisconnect()
+ assertEquals(listOf(ConnectionEvent.Disconnected), events)
+ assertEquals(setOf(CardScenarios.willys.id, CardScenarios.bauhaus.id, CardScenarios.spotify.id), fakes.store.current.cards.purchases.map { it.id }.toSet())
+ }
+
+ @Test
+ fun s27_listsThePurchasesToHandle_withTheAccountsName() = runTest(main.dispatcher) {
+ val fakes = FakeRepositories(CardScenarios.loaded)
+ val vm = CardPickViewModel(SavedStateHandle(mapOf(CardPickViewModel.ARG_ACCOUNT_ID to HomeScenarios.vardag.id.value)), fakes.cards, fakes.accounts)
+ assertEquals("Vardag", vm.ui.value.accountName)
+ val ids = vm.ui.value.purchases.orEmpty().map { it.id }
+ assertEquals(listOf(CardScenarios.pressbyran.id, CardScenarios.systembolaget.id, CardScenarios.ica.id, CardScenarios.wolt.id, CardScenarios.amazon.id, CardScenarios.twoMatches.id), ids)
+ }
+}
diff --git a/android/feature/cards/src/test/kotlin/se/hagstedt/kvitt/feature/cards/screenshot/CardsScreenshotTest.kt b/android/feature/cards/src/test/kotlin/se/hagstedt/kvitt/feature/cards/screenshot/CardsScreenshotTest.kt
new file mode 100644
index 0000000..ab6e09e
--- /dev/null
+++ b/android/feature/cards/src/test/kotlin/se/hagstedt/kvitt/feature/cards/screenshot/CardsScreenshotTest.kt
@@ -0,0 +1,212 @@
+package se.hagstedt.kvitt.feature.cards.screenshot
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.test.junit4.v2.createComposeRule
+import androidx.lifecycle.SavedStateHandle
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.ParameterizedRobolectricTestRunner
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.GraphicsMode
+import se.hagstedt.kvitt.design.component.ConfirmDialogContent
+import se.hagstedt.kvitt.design.component.ProvideHousehold
+import se.hagstedt.kvitt.design.icon.KvittIcon
+import se.hagstedt.kvitt.design.testing.ScreenSnapshots
+import se.hagstedt.kvitt.fakes.FakeCardPurchase
+import se.hagstedt.kvitt.fakes.FakeCardRepository
+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.home.HomeScenarios
+import se.hagstedt.kvitt.feature.cards.CardConnectActions
+import se.hagstedt.kvitt.feature.cards.CardConnectContent
+import se.hagstedt.kvitt.feature.cards.CardConnectViewModel
+import se.hagstedt.kvitt.feature.cards.CardConnectionActions
+import se.hagstedt.kvitt.feature.cards.CardConnectionContent
+import se.hagstedt.kvitt.feature.cards.CardConnectionViewModel
+import se.hagstedt.kvitt.feature.cards.CardPickContent
+import se.hagstedt.kvitt.feature.cards.CardPickViewModel
+import se.hagstedt.kvitt.feature.cards.CardsActions
+import se.hagstedt.kvitt.feature.cards.CardsContent
+import se.hagstedt.kvitt.feature.cards.CardsSheet
+import se.hagstedt.kvitt.feature.cards.CardsViewModel
+import se.hagstedt.kvitt.feature.cards.ConnectCardContent
+import se.hagstedt.kvitt.feature.cards.ConnectCardViewModel
+import se.hagstedt.kvitt.feature.cards.EraseAll
+import se.hagstedt.kvitt.feature.cards.OthersSheet
+import se.hagstedt.kvitt.feature.cards.PurchaseSheet
+import se.hagstedt.kvitt.feature.cards.R
+import se.hagstedt.kvitt.model.CardFilter
+import se.hagstedt.kvitt.core.design.R as DesignR
+
+/** One golden state: its name, whether it has a 200 % golden, and its content. */
+class CardCase(val name: String, val largeFont: Boolean = false, val content: () -> @Composable () -> Unit)
+
+/** The household every card screen is rendered in (the match rows name the payer). */
+@Composable
+private fun Household(content: @Composable () -> Unit) = ProvideHousehold(FakeScenarios.owner, FakeScenarios.partner, content)
+
+/** S-23 in [scenario] after [steps], with the purchase sheet or "Välj en annan utgift" when one is open. */
+private fun s23(name: String, scenario: FakeScenario, largeFont: Boolean = false, steps: CardsViewModel.() -> Unit = {}) = CardCase(name, largeFont) {
+ val ui = buildState { idle ->
+ val fakes = FakeRepositories(scenario)
+ val vm = CardsViewModel(fakes.cards, fakes.connectivity)
+ idle()
+ vm.steps()
+ idle()
+ vm.ui.value
+ }
+ val detail = ui.detail
+ if (ui.sheet == null || detail == null) {
+ { Household { CardsContent(ui, CardsActions()) } }
+ } else {
+ {
+ Household {
+ OverlayFrame(sheet = true, { CardsContent(ui.copy(sheet = null), CardsActions()) }) {
+ if (ui.sheet == CardsSheet.Others) OthersSheet(detail.row.purchase.id, detail.others, CardsActions()) else PurchaseSheet(detail, CardsActions())
+ }
+ }
+ }
+ }
+}
+
+private fun open(p: FakeCardPurchase): CardsViewModel.() -> Unit = { openPurchase(p.id) }
+
+private fun s24(name: String, scenario: FakeScenario, largeFont: Boolean = false) = CardCase(name, largeFont) {
+ val ui = buildState { idle ->
+ val fakes = FakeRepositories(scenario)
+ val vm = ConnectCardViewModel(fakes.cards, fakes.connectivity)
+ idle()
+ vm.ui.value
+ }
+ ({ ConnectCardContent(ui, {}, {}, {}, {}) })
+}
+
+private fun s25(name: String, scenario: FakeScenario, renew: Boolean = false, largeFont: Boolean = false, steps: CardConnectViewModel.() -> Unit = {}) = CardCase(name, largeFont) {
+ val ui = buildState { idle ->
+ val fakes = FakeRepositories(scenario)
+ val args = buildMap {
+ put(CardConnectViewModel.ARG_PROVIDER_ID, "sj_prio_mastercard")
+ if (renew) put(CardConnectViewModel.ARG_RENEW, "true")
+ }
+ val vm = CardConnectViewModel(SavedStateHandle(args), fakes.cards, fakes.connectivity)
+ idle()
+ vm.steps()
+ // The state before the 1.5 s hand-over to S-23 (connected) is what the screen shows.
+ vm.ui.value
+ }
+ ({ CardConnectContent(ui, CardConnectActions()) })
+}
+
+private fun s26(name: String, scenario: FakeScenario, largeFont: Boolean = false, dialog: Boolean = false) = CardCase(name, largeFont) {
+ val ui = buildState { idle ->
+ val fakes = FakeRepositories(scenario)
+ val vm = CardConnectionViewModel(SavedStateHandle(mapOf(CardConnectionViewModel.ARG_CONNECTION_ID to CardScenarios.connectionId.value)), fakes.cards, fakes.connectivity)
+ idle()
+ if (dialog) vm.askDisconnect()
+ vm.ui.value
+ }
+ if (!dialog) {
+ { CardConnectionContent(ui, CardConnectionActions()) }
+ } else {
+ {
+ OverlayFrame(sheet = false, { CardConnectionContent(ui.copy(disconnectDialog = false), CardConnectionActions()) }) {
+ ConfirmDialogContent(
+ icon = KvittIcon.Delete,
+ title = stringResource(R.string.cards_disconnect_title, ui.provider?.name.orEmpty()),
+ body = stringResource(R.string.cards_disconnect_body),
+ confirm = stringResource(R.string.cards_disconnect),
+ dismiss = stringResource(DesignR.string.common_cancel),
+ onConfirm = {},
+ onDismiss = {},
+ destructive = true,
+ extra = { EraseAll(ui.eraseHandled) {} },
+ )
+ }
+ }
+ }
+}
+
+private fun s27(name: String, scenario: FakeScenario, largeFont: Boolean = false) = CardCase(name, largeFont) {
+ val ui = buildState { idle ->
+ val fakes = FakeRepositories(scenario)
+ val vm = CardPickViewModel(SavedStateHandle(mapOf(CardPickViewModel.ARG_ACCOUNT_ID to HomeScenarios.vardag.id.value)), fakes.cards, fakes.accounts)
+ idle()
+ vm.ui.value
+ }
+ ({ CardPickContent(ui, {}, {}) })
+}
+
+/** Every state of S-23 to S-27 (§13.3, PLAN-K2 §5) × sv light, sv dark, en light, plus 200 % for the long ones. */
+val cardCases: List = listOf(
+ s23("s23_no_connection", CardScenarios.none),
+ s23("s23_first_fetch", CardScenarios.firstFetch),
+ s23("s23_to_handle", CardScenarios.loaded),
+ s23("s23_linked", CardScenarios.loaded) { setFilter(CardFilter.LINKED) },
+ s23("s23_ignored", CardScenarios.loaded) { setFilter(CardFilter.IGNORED) },
+ s23("s23_all", CardScenarios.loaded) { setFilter(CardFilter.ALL) },
+ s23("s23_nothing_to_handle", CardScenarios.nothingToHandle),
+ s23("s23_filter_empty", CardScenarios.noPurchases) { setFilter(CardFilter.LINKED) },
+ s23("s23_offline", CardScenarios.offline),
+ s23("s23_refreshing", CardScenarios.refreshing),
+ s23("s23_expiring", CardScenarios.expiring),
+ s23("s23_expired", CardScenarios.expired),
+ s23("s23_fetch_failed", CardScenarios.fetchFailed),
+ s23("s23_long", CardScenarios.long, largeFont = true),
+ s23("s23_sheet_one_match", CardScenarios.loaded, largeFont = true, steps = open(CardScenarios.ica)),
+ s23("s23_sheet_two_matches", CardScenarios.loaded, steps = open(CardScenarios.twoMatches)),
+ s23("s23_sheet_no_match", CardScenarios.loaded, steps = open(CardScenarios.wolt)),
+ s23("s23_sheet_foreign", CardScenarios.loaded, steps = open(CardScenarios.amazon)),
+ s23("s23_sheet_linked", CardScenarios.loaded, steps = open(CardScenarios.willys)),
+ s23("s23_sheet_ignored", CardScenarios.loaded, steps = open(CardScenarios.spotify)),
+ s23("s23_sheet_credit", CardScenarios.loaded, steps = open(CardScenarios.payment)),
+ s23("s23_sheet_other", CardScenarios.loaded) { openPurchase(CardScenarios.wolt.id); openOthers() },
+ s24("s24_catalogue", CardScenarios.none, largeFont = true),
+ s24("s24_connected", CardScenarios.loaded),
+ s24("s24_unavailable", CardScenarios.unavailable),
+ s24("s24_fallback_artwork", CardScenarios.unknownArtwork),
+ s24("s24_loading", CardScenarios.providersLoading),
+ s24("s24_offline", CardScenarios.connectOffline),
+ s24("s24_error", CardScenarios.providersFailed),
+ s25("s25_choose", CardScenarios.none, largeFont = true),
+ s25("s25_renew", CardScenarios.expiring, renew = true),
+ s25("s25_waiting", CardScenarios.attemptPending) { start() },
+ s25("s25_connected", CardScenarios.attemptConnected) { onReturn(FakeCardRepository.ATTEMPT_ID) },
+ s25("s25_failed", CardScenarios.attemptFailed) { onReturn(FakeCardRepository.ATTEMPT_ID) },
+ s25("s25_timed_out", CardScenarios.attemptExpired) { onReturn(FakeCardRepository.ATTEMPT_ID) },
+ s25("s25_offline", CardScenarios.connectOffline),
+ s26("s26_active", CardScenarios.loaded, largeFont = true),
+ s26("s26_expiring", CardScenarios.expiring),
+ s26("s26_expired", CardScenarios.expired),
+ s26("s26_offline", CardScenarios.offline),
+ s26("s26_disconnect", CardScenarios.loaded, dialog = true),
+ s27("s27_to_handle", CardScenarios.loaded, largeFont = true),
+ s27("s27_empty", CardScenarios.nothingToHandle),
+)
+
+/**
+ * Goldens of every card screen state. `check` verifies them; re-record an intended change with
+ * `tools/gradle-serial.sh :feature:cards:recordRoborazziDebug` and open every changed PNG.
+ */
+@RunWith(ParameterizedRobolectricTestRunner::class)
+@GraphicsMode(GraphicsMode.Mode.NATIVE)
+@Config(sdk = [36], qualifiers = ScreenSnapshots.QUALIFIERS)
+class CardsScreenshotTest(private val name: String) {
+ @get:Rule
+ val compose = createComposeRule()
+
+ @Test
+ fun screen() {
+ val case = cardCases.first { it.name == name }
+ ScreenSnapshots.snap(compose, case.name, case.largeFont, jvmLocale = true, content = case.content())
+ }
+
+ companion object {
+ @JvmStatic
+ @ParameterizedRobolectricTestRunner.Parameters(name = "{0}")
+ fun cases(): List> = cardCases.map { arrayOf(it.name) }
+ }
+}
diff --git a/android/feature/cards/src/test/kotlin/se/hagstedt/kvitt/feature/cards/screenshot/Harness.kt b/android/feature/cards/src/test/kotlin/se/hagstedt/kvitt/feature/cards/screenshot/Harness.kt
new file mode 100644
index 0000000..79b54fb
--- /dev/null
+++ b/android/feature/cards/src/test/kotlin/se/hagstedt/kvitt/feature/cards/screenshot/Harness.kt
@@ -0,0 +1,66 @@
+package se.hagstedt.kvitt.feature.cards.screenshot
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.BottomSheetDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import se.hagstedt.kvitt.design.theme.KvittDimens
+
+/**
+ * Runs [setup] with the view models' main dispatcher set to a test dispatcher and returns its result; [setup] calls
+ * the given `idle` after actions that wait.
+ */
+@OptIn(ExperimentalCoroutinesApi::class)
+fun buildState(setup: (idle: () -> Unit) -> T): T {
+ val dispatcher = UnconfinedTestDispatcher()
+ Dispatchers.setMain(dispatcher)
+ val out = mutableListOf()
+ try {
+ runTest(dispatcher) { out += setup { advanceUntilIdle() } }
+ } finally {
+ Dispatchers.resetMain()
+ }
+ return out.single()
+}
+
+/**
+ * A sheet or dialog drawn over the screen in the same window, as `:feature:expense`'s goldens do: Robolectric captures
+ * of separate windows lose the configuration's locale, so the goldens draw the real content in the Material frame.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun OverlayFrame(sheet: Boolean, base: @Composable () -> Unit, overlay: @Composable () -> Unit) {
+ Box(Modifier.fillMaxSize()) {
+ base()
+ Box(Modifier.matchParentSize().background(MaterialTheme.colorScheme.scrim.copy(alpha = if (sheet) SHEET_SCRIM else DIALOG_SCRIM)))
+ if (sheet) {
+ Surface(Modifier.align(Alignment.BottomCenter).fillMaxWidth(), shape = BottomSheetDefaults.ExpandedShape, color = BottomSheetDefaults.ContainerColor) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ BottomSheetDefaults.DragHandle()
+ Box(Modifier.fillMaxWidth()) { overlay() }
+ }
+ }
+ } else {
+ Box(Modifier.align(Alignment.Center).padding(horizontal = KvittDimens.dialogPadding)) { overlay() }
+ }
+ }
+}
+
+private const val SHEET_SCRIM = 0.32f
+private const val DIALOG_SCRIM = 0.38f
diff --git a/android/feature/cards/src/test/screenshots/s23_all_en_light.png b/android/feature/cards/src/test/screenshots/s23_all_en_light.png
new file mode 100644
index 0000000..abd11ff
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s23_all_en_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s23_all_sv_dark.png b/android/feature/cards/src/test/screenshots/s23_all_sv_dark.png
new file mode 100644
index 0000000..cb9fc3c
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s23_all_sv_dark.png differ
diff --git a/android/feature/cards/src/test/screenshots/s23_all_sv_light.png b/android/feature/cards/src/test/screenshots/s23_all_sv_light.png
new file mode 100644
index 0000000..ef26c30
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s23_all_sv_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s23_expired_en_light.png b/android/feature/cards/src/test/screenshots/s23_expired_en_light.png
new file mode 100644
index 0000000..93fc0bb
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s23_expired_en_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s23_expired_sv_dark.png b/android/feature/cards/src/test/screenshots/s23_expired_sv_dark.png
new file mode 100644
index 0000000..4182f72
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s23_expired_sv_dark.png differ
diff --git a/android/feature/cards/src/test/screenshots/s23_expired_sv_light.png b/android/feature/cards/src/test/screenshots/s23_expired_sv_light.png
new file mode 100644
index 0000000..c8f6322
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s23_expired_sv_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s23_expiring_en_light.png b/android/feature/cards/src/test/screenshots/s23_expiring_en_light.png
new file mode 100644
index 0000000..2d36ea0
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s23_expiring_en_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s23_expiring_sv_dark.png b/android/feature/cards/src/test/screenshots/s23_expiring_sv_dark.png
new file mode 100644
index 0000000..c96a459
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s23_expiring_sv_dark.png differ
diff --git a/android/feature/cards/src/test/screenshots/s23_expiring_sv_light.png b/android/feature/cards/src/test/screenshots/s23_expiring_sv_light.png
new file mode 100644
index 0000000..6748fee
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s23_expiring_sv_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s23_fetch_failed_en_light.png b/android/feature/cards/src/test/screenshots/s23_fetch_failed_en_light.png
new file mode 100644
index 0000000..17da320
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s23_fetch_failed_en_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s23_fetch_failed_sv_dark.png b/android/feature/cards/src/test/screenshots/s23_fetch_failed_sv_dark.png
new file mode 100644
index 0000000..1a970bd
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s23_fetch_failed_sv_dark.png differ
diff --git a/android/feature/cards/src/test/screenshots/s23_fetch_failed_sv_light.png b/android/feature/cards/src/test/screenshots/s23_fetch_failed_sv_light.png
new file mode 100644
index 0000000..cea6487
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s23_fetch_failed_sv_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s23_filter_empty_en_light.png b/android/feature/cards/src/test/screenshots/s23_filter_empty_en_light.png
new file mode 100644
index 0000000..9cf05bb
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s23_filter_empty_en_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s23_filter_empty_sv_dark.png b/android/feature/cards/src/test/screenshots/s23_filter_empty_sv_dark.png
new file mode 100644
index 0000000..19be6a6
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s23_filter_empty_sv_dark.png differ
diff --git a/android/feature/cards/src/test/screenshots/s23_filter_empty_sv_light.png b/android/feature/cards/src/test/screenshots/s23_filter_empty_sv_light.png
new file mode 100644
index 0000000..c134ea4
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s23_filter_empty_sv_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s23_first_fetch_en_light.png b/android/feature/cards/src/test/screenshots/s23_first_fetch_en_light.png
new file mode 100644
index 0000000..3371c5d
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s23_first_fetch_en_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s23_first_fetch_sv_dark.png b/android/feature/cards/src/test/screenshots/s23_first_fetch_sv_dark.png
new file mode 100644
index 0000000..9e15f87
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s23_first_fetch_sv_dark.png differ
diff --git a/android/feature/cards/src/test/screenshots/s23_first_fetch_sv_light.png b/android/feature/cards/src/test/screenshots/s23_first_fetch_sv_light.png
new file mode 100644
index 0000000..fa00c6e
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s23_first_fetch_sv_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s23_ignored_en_light.png b/android/feature/cards/src/test/screenshots/s23_ignored_en_light.png
new file mode 100644
index 0000000..2995094
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s23_ignored_en_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s23_ignored_sv_dark.png b/android/feature/cards/src/test/screenshots/s23_ignored_sv_dark.png
new file mode 100644
index 0000000..ad949c1
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s23_ignored_sv_dark.png differ
diff --git a/android/feature/cards/src/test/screenshots/s23_ignored_sv_light.png b/android/feature/cards/src/test/screenshots/s23_ignored_sv_light.png
new file mode 100644
index 0000000..7337521
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s23_ignored_sv_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s23_linked_en_light.png b/android/feature/cards/src/test/screenshots/s23_linked_en_light.png
new file mode 100644
index 0000000..35b0a8b
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s23_linked_en_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s23_linked_sv_dark.png b/android/feature/cards/src/test/screenshots/s23_linked_sv_dark.png
new file mode 100644
index 0000000..6161253
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s23_linked_sv_dark.png differ
diff --git a/android/feature/cards/src/test/screenshots/s23_linked_sv_light.png b/android/feature/cards/src/test/screenshots/s23_linked_sv_light.png
new file mode 100644
index 0000000..c1975a7
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s23_linked_sv_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s23_long_en_light.png b/android/feature/cards/src/test/screenshots/s23_long_en_light.png
new file mode 100644
index 0000000..f21e87b
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s23_long_en_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s23_long_sv_dark.png b/android/feature/cards/src/test/screenshots/s23_long_sv_dark.png
new file mode 100644
index 0000000..036d9cd
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s23_long_sv_dark.png differ
diff --git a/android/feature/cards/src/test/screenshots/s23_long_sv_light.png b/android/feature/cards/src/test/screenshots/s23_long_sv_light.png
new file mode 100644
index 0000000..5a5fb18
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s23_long_sv_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s23_long_sv_light_200.png b/android/feature/cards/src/test/screenshots/s23_long_sv_light_200.png
new file mode 100644
index 0000000..d69afe9
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s23_long_sv_light_200.png differ
diff --git a/android/feature/cards/src/test/screenshots/s23_no_connection_en_light.png b/android/feature/cards/src/test/screenshots/s23_no_connection_en_light.png
new file mode 100644
index 0000000..f88fbf3
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s23_no_connection_en_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s23_no_connection_sv_dark.png b/android/feature/cards/src/test/screenshots/s23_no_connection_sv_dark.png
new file mode 100644
index 0000000..619c6fe
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s23_no_connection_sv_dark.png differ
diff --git a/android/feature/cards/src/test/screenshots/s23_no_connection_sv_light.png b/android/feature/cards/src/test/screenshots/s23_no_connection_sv_light.png
new file mode 100644
index 0000000..d527a54
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s23_no_connection_sv_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s23_nothing_to_handle_en_light.png b/android/feature/cards/src/test/screenshots/s23_nothing_to_handle_en_light.png
new file mode 100644
index 0000000..64f7116
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s23_nothing_to_handle_en_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s23_nothing_to_handle_sv_dark.png b/android/feature/cards/src/test/screenshots/s23_nothing_to_handle_sv_dark.png
new file mode 100644
index 0000000..8013c19
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s23_nothing_to_handle_sv_dark.png differ
diff --git a/android/feature/cards/src/test/screenshots/s23_nothing_to_handle_sv_light.png b/android/feature/cards/src/test/screenshots/s23_nothing_to_handle_sv_light.png
new file mode 100644
index 0000000..bf658cd
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s23_nothing_to_handle_sv_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s23_offline_en_light.png b/android/feature/cards/src/test/screenshots/s23_offline_en_light.png
new file mode 100644
index 0000000..cbbeade
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s23_offline_en_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s23_offline_sv_dark.png b/android/feature/cards/src/test/screenshots/s23_offline_sv_dark.png
new file mode 100644
index 0000000..f4abf67
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s23_offline_sv_dark.png differ
diff --git a/android/feature/cards/src/test/screenshots/s23_offline_sv_light.png b/android/feature/cards/src/test/screenshots/s23_offline_sv_light.png
new file mode 100644
index 0000000..4a517bf
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s23_offline_sv_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s23_refreshing_en_light.png b/android/feature/cards/src/test/screenshots/s23_refreshing_en_light.png
new file mode 100644
index 0000000..ea75c3d
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s23_refreshing_en_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s23_refreshing_sv_dark.png b/android/feature/cards/src/test/screenshots/s23_refreshing_sv_dark.png
new file mode 100644
index 0000000..4ce975e
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s23_refreshing_sv_dark.png differ
diff --git a/android/feature/cards/src/test/screenshots/s23_refreshing_sv_light.png b/android/feature/cards/src/test/screenshots/s23_refreshing_sv_light.png
new file mode 100644
index 0000000..ac5437d
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s23_refreshing_sv_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s23_sheet_credit_en_light.png b/android/feature/cards/src/test/screenshots/s23_sheet_credit_en_light.png
new file mode 100644
index 0000000..8d10163
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s23_sheet_credit_en_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s23_sheet_credit_sv_dark.png b/android/feature/cards/src/test/screenshots/s23_sheet_credit_sv_dark.png
new file mode 100644
index 0000000..6a2da78
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s23_sheet_credit_sv_dark.png differ
diff --git a/android/feature/cards/src/test/screenshots/s23_sheet_credit_sv_light.png b/android/feature/cards/src/test/screenshots/s23_sheet_credit_sv_light.png
new file mode 100644
index 0000000..a7c5ad6
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s23_sheet_credit_sv_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s23_sheet_foreign_en_light.png b/android/feature/cards/src/test/screenshots/s23_sheet_foreign_en_light.png
new file mode 100644
index 0000000..072db74
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s23_sheet_foreign_en_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s23_sheet_foreign_sv_dark.png b/android/feature/cards/src/test/screenshots/s23_sheet_foreign_sv_dark.png
new file mode 100644
index 0000000..0e28496
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s23_sheet_foreign_sv_dark.png differ
diff --git a/android/feature/cards/src/test/screenshots/s23_sheet_foreign_sv_light.png b/android/feature/cards/src/test/screenshots/s23_sheet_foreign_sv_light.png
new file mode 100644
index 0000000..c953e33
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s23_sheet_foreign_sv_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s23_sheet_ignored_en_light.png b/android/feature/cards/src/test/screenshots/s23_sheet_ignored_en_light.png
new file mode 100644
index 0000000..9cff19c
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s23_sheet_ignored_en_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s23_sheet_ignored_sv_dark.png b/android/feature/cards/src/test/screenshots/s23_sheet_ignored_sv_dark.png
new file mode 100644
index 0000000..74bdb17
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s23_sheet_ignored_sv_dark.png differ
diff --git a/android/feature/cards/src/test/screenshots/s23_sheet_ignored_sv_light.png b/android/feature/cards/src/test/screenshots/s23_sheet_ignored_sv_light.png
new file mode 100644
index 0000000..1f74c51
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s23_sheet_ignored_sv_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s23_sheet_linked_en_light.png b/android/feature/cards/src/test/screenshots/s23_sheet_linked_en_light.png
new file mode 100644
index 0000000..e2c75e3
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s23_sheet_linked_en_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s23_sheet_linked_sv_dark.png b/android/feature/cards/src/test/screenshots/s23_sheet_linked_sv_dark.png
new file mode 100644
index 0000000..5744d76
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s23_sheet_linked_sv_dark.png differ
diff --git a/android/feature/cards/src/test/screenshots/s23_sheet_linked_sv_light.png b/android/feature/cards/src/test/screenshots/s23_sheet_linked_sv_light.png
new file mode 100644
index 0000000..0fe2613
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s23_sheet_linked_sv_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s23_sheet_no_match_en_light.png b/android/feature/cards/src/test/screenshots/s23_sheet_no_match_en_light.png
new file mode 100644
index 0000000..d0f1542
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s23_sheet_no_match_en_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s23_sheet_no_match_sv_dark.png b/android/feature/cards/src/test/screenshots/s23_sheet_no_match_sv_dark.png
new file mode 100644
index 0000000..4068bba
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s23_sheet_no_match_sv_dark.png differ
diff --git a/android/feature/cards/src/test/screenshots/s23_sheet_no_match_sv_light.png b/android/feature/cards/src/test/screenshots/s23_sheet_no_match_sv_light.png
new file mode 100644
index 0000000..53772ef
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s23_sheet_no_match_sv_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s23_sheet_one_match_en_light.png b/android/feature/cards/src/test/screenshots/s23_sheet_one_match_en_light.png
new file mode 100644
index 0000000..6ede200
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s23_sheet_one_match_en_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s23_sheet_one_match_sv_dark.png b/android/feature/cards/src/test/screenshots/s23_sheet_one_match_sv_dark.png
new file mode 100644
index 0000000..b82fd0a
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s23_sheet_one_match_sv_dark.png differ
diff --git a/android/feature/cards/src/test/screenshots/s23_sheet_one_match_sv_light.png b/android/feature/cards/src/test/screenshots/s23_sheet_one_match_sv_light.png
new file mode 100644
index 0000000..6799b08
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s23_sheet_one_match_sv_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s23_sheet_one_match_sv_light_200.png b/android/feature/cards/src/test/screenshots/s23_sheet_one_match_sv_light_200.png
new file mode 100644
index 0000000..e1a60eb
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s23_sheet_one_match_sv_light_200.png differ
diff --git a/android/feature/cards/src/test/screenshots/s23_sheet_other_en_light.png b/android/feature/cards/src/test/screenshots/s23_sheet_other_en_light.png
new file mode 100644
index 0000000..b220ca1
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s23_sheet_other_en_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s23_sheet_other_sv_dark.png b/android/feature/cards/src/test/screenshots/s23_sheet_other_sv_dark.png
new file mode 100644
index 0000000..8b727a0
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s23_sheet_other_sv_dark.png differ
diff --git a/android/feature/cards/src/test/screenshots/s23_sheet_other_sv_light.png b/android/feature/cards/src/test/screenshots/s23_sheet_other_sv_light.png
new file mode 100644
index 0000000..fa4069e
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s23_sheet_other_sv_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s23_sheet_two_matches_en_light.png b/android/feature/cards/src/test/screenshots/s23_sheet_two_matches_en_light.png
new file mode 100644
index 0000000..2fcb203
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s23_sheet_two_matches_en_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s23_sheet_two_matches_sv_dark.png b/android/feature/cards/src/test/screenshots/s23_sheet_two_matches_sv_dark.png
new file mode 100644
index 0000000..843878b
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s23_sheet_two_matches_sv_dark.png differ
diff --git a/android/feature/cards/src/test/screenshots/s23_sheet_two_matches_sv_light.png b/android/feature/cards/src/test/screenshots/s23_sheet_two_matches_sv_light.png
new file mode 100644
index 0000000..2f60979
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s23_sheet_two_matches_sv_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s23_to_handle_en_light.png b/android/feature/cards/src/test/screenshots/s23_to_handle_en_light.png
new file mode 100644
index 0000000..195101c
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s23_to_handle_en_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s23_to_handle_sv_dark.png b/android/feature/cards/src/test/screenshots/s23_to_handle_sv_dark.png
new file mode 100644
index 0000000..570f6f9
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s23_to_handle_sv_dark.png differ
diff --git a/android/feature/cards/src/test/screenshots/s23_to_handle_sv_light.png b/android/feature/cards/src/test/screenshots/s23_to_handle_sv_light.png
new file mode 100644
index 0000000..845aea6
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s23_to_handle_sv_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s24_catalogue_en_light.png b/android/feature/cards/src/test/screenshots/s24_catalogue_en_light.png
new file mode 100644
index 0000000..1b38ba6
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s24_catalogue_en_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s24_catalogue_sv_dark.png b/android/feature/cards/src/test/screenshots/s24_catalogue_sv_dark.png
new file mode 100644
index 0000000..caae458
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s24_catalogue_sv_dark.png differ
diff --git a/android/feature/cards/src/test/screenshots/s24_catalogue_sv_light.png b/android/feature/cards/src/test/screenshots/s24_catalogue_sv_light.png
new file mode 100644
index 0000000..f74d2d8
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s24_catalogue_sv_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s24_catalogue_sv_light_200.png b/android/feature/cards/src/test/screenshots/s24_catalogue_sv_light_200.png
new file mode 100644
index 0000000..84f8b4f
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s24_catalogue_sv_light_200.png differ
diff --git a/android/feature/cards/src/test/screenshots/s24_connected_en_light.png b/android/feature/cards/src/test/screenshots/s24_connected_en_light.png
new file mode 100644
index 0000000..cb4570a
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s24_connected_en_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s24_connected_sv_dark.png b/android/feature/cards/src/test/screenshots/s24_connected_sv_dark.png
new file mode 100644
index 0000000..8ef6b13
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s24_connected_sv_dark.png differ
diff --git a/android/feature/cards/src/test/screenshots/s24_connected_sv_light.png b/android/feature/cards/src/test/screenshots/s24_connected_sv_light.png
new file mode 100644
index 0000000..00390fd
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s24_connected_sv_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s24_error_en_light.png b/android/feature/cards/src/test/screenshots/s24_error_en_light.png
new file mode 100644
index 0000000..3ebc8a9
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s24_error_en_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s24_error_sv_dark.png b/android/feature/cards/src/test/screenshots/s24_error_sv_dark.png
new file mode 100644
index 0000000..eac29c7
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s24_error_sv_dark.png differ
diff --git a/android/feature/cards/src/test/screenshots/s24_error_sv_light.png b/android/feature/cards/src/test/screenshots/s24_error_sv_light.png
new file mode 100644
index 0000000..cfa9da7
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s24_error_sv_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s24_fallback_artwork_en_light.png b/android/feature/cards/src/test/screenshots/s24_fallback_artwork_en_light.png
new file mode 100644
index 0000000..fc6ff8d
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s24_fallback_artwork_en_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s24_fallback_artwork_sv_dark.png b/android/feature/cards/src/test/screenshots/s24_fallback_artwork_sv_dark.png
new file mode 100644
index 0000000..91effe9
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s24_fallback_artwork_sv_dark.png differ
diff --git a/android/feature/cards/src/test/screenshots/s24_fallback_artwork_sv_light.png b/android/feature/cards/src/test/screenshots/s24_fallback_artwork_sv_light.png
new file mode 100644
index 0000000..f79fec8
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s24_fallback_artwork_sv_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s24_loading_en_light.png b/android/feature/cards/src/test/screenshots/s24_loading_en_light.png
new file mode 100644
index 0000000..765bc89
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s24_loading_en_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s24_loading_sv_dark.png b/android/feature/cards/src/test/screenshots/s24_loading_sv_dark.png
new file mode 100644
index 0000000..9a43741
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s24_loading_sv_dark.png differ
diff --git a/android/feature/cards/src/test/screenshots/s24_loading_sv_light.png b/android/feature/cards/src/test/screenshots/s24_loading_sv_light.png
new file mode 100644
index 0000000..a4c0e10
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s24_loading_sv_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s24_offline_en_light.png b/android/feature/cards/src/test/screenshots/s24_offline_en_light.png
new file mode 100644
index 0000000..2d5d3e4
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s24_offline_en_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s24_offline_sv_dark.png b/android/feature/cards/src/test/screenshots/s24_offline_sv_dark.png
new file mode 100644
index 0000000..d24ea5f
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s24_offline_sv_dark.png differ
diff --git a/android/feature/cards/src/test/screenshots/s24_offline_sv_light.png b/android/feature/cards/src/test/screenshots/s24_offline_sv_light.png
new file mode 100644
index 0000000..dadc50b
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s24_offline_sv_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s24_unavailable_en_light.png b/android/feature/cards/src/test/screenshots/s24_unavailable_en_light.png
new file mode 100644
index 0000000..af075f4
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s24_unavailable_en_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s24_unavailable_sv_dark.png b/android/feature/cards/src/test/screenshots/s24_unavailable_sv_dark.png
new file mode 100644
index 0000000..6dfa87a
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s24_unavailable_sv_dark.png differ
diff --git a/android/feature/cards/src/test/screenshots/s24_unavailable_sv_light.png b/android/feature/cards/src/test/screenshots/s24_unavailable_sv_light.png
new file mode 100644
index 0000000..af895d7
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s24_unavailable_sv_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s25_choose_en_light.png b/android/feature/cards/src/test/screenshots/s25_choose_en_light.png
new file mode 100644
index 0000000..270361f
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s25_choose_en_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s25_choose_sv_dark.png b/android/feature/cards/src/test/screenshots/s25_choose_sv_dark.png
new file mode 100644
index 0000000..1b7bb22
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s25_choose_sv_dark.png differ
diff --git a/android/feature/cards/src/test/screenshots/s25_choose_sv_light.png b/android/feature/cards/src/test/screenshots/s25_choose_sv_light.png
new file mode 100644
index 0000000..646fc96
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s25_choose_sv_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s25_choose_sv_light_200.png b/android/feature/cards/src/test/screenshots/s25_choose_sv_light_200.png
new file mode 100644
index 0000000..515ed40
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s25_choose_sv_light_200.png differ
diff --git a/android/feature/cards/src/test/screenshots/s25_connected_en_light.png b/android/feature/cards/src/test/screenshots/s25_connected_en_light.png
new file mode 100644
index 0000000..3d4f299
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s25_connected_en_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s25_connected_sv_dark.png b/android/feature/cards/src/test/screenshots/s25_connected_sv_dark.png
new file mode 100644
index 0000000..b66737b
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s25_connected_sv_dark.png differ
diff --git a/android/feature/cards/src/test/screenshots/s25_connected_sv_light.png b/android/feature/cards/src/test/screenshots/s25_connected_sv_light.png
new file mode 100644
index 0000000..d3eee40
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s25_connected_sv_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s25_failed_en_light.png b/android/feature/cards/src/test/screenshots/s25_failed_en_light.png
new file mode 100644
index 0000000..a2df979
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s25_failed_en_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s25_failed_sv_dark.png b/android/feature/cards/src/test/screenshots/s25_failed_sv_dark.png
new file mode 100644
index 0000000..0b043ab
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s25_failed_sv_dark.png differ
diff --git a/android/feature/cards/src/test/screenshots/s25_failed_sv_light.png b/android/feature/cards/src/test/screenshots/s25_failed_sv_light.png
new file mode 100644
index 0000000..fa93490
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s25_failed_sv_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s25_offline_en_light.png b/android/feature/cards/src/test/screenshots/s25_offline_en_light.png
new file mode 100644
index 0000000..428b3d0
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s25_offline_en_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s25_offline_sv_dark.png b/android/feature/cards/src/test/screenshots/s25_offline_sv_dark.png
new file mode 100644
index 0000000..7bc3749
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s25_offline_sv_dark.png differ
diff --git a/android/feature/cards/src/test/screenshots/s25_offline_sv_light.png b/android/feature/cards/src/test/screenshots/s25_offline_sv_light.png
new file mode 100644
index 0000000..6716704
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s25_offline_sv_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s25_renew_en_light.png b/android/feature/cards/src/test/screenshots/s25_renew_en_light.png
new file mode 100644
index 0000000..3c7eb6a
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s25_renew_en_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s25_renew_sv_dark.png b/android/feature/cards/src/test/screenshots/s25_renew_sv_dark.png
new file mode 100644
index 0000000..b7adc03
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s25_renew_sv_dark.png differ
diff --git a/android/feature/cards/src/test/screenshots/s25_renew_sv_light.png b/android/feature/cards/src/test/screenshots/s25_renew_sv_light.png
new file mode 100644
index 0000000..5f3553b
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s25_renew_sv_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s25_timed_out_en_light.png b/android/feature/cards/src/test/screenshots/s25_timed_out_en_light.png
new file mode 100644
index 0000000..688ef6c
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s25_timed_out_en_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s25_timed_out_sv_dark.png b/android/feature/cards/src/test/screenshots/s25_timed_out_sv_dark.png
new file mode 100644
index 0000000..f978ab2
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s25_timed_out_sv_dark.png differ
diff --git a/android/feature/cards/src/test/screenshots/s25_timed_out_sv_light.png b/android/feature/cards/src/test/screenshots/s25_timed_out_sv_light.png
new file mode 100644
index 0000000..a93c4ee
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s25_timed_out_sv_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s25_waiting_en_light.png b/android/feature/cards/src/test/screenshots/s25_waiting_en_light.png
new file mode 100644
index 0000000..7f21385
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s25_waiting_en_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s25_waiting_sv_dark.png b/android/feature/cards/src/test/screenshots/s25_waiting_sv_dark.png
new file mode 100644
index 0000000..3c5bea0
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s25_waiting_sv_dark.png differ
diff --git a/android/feature/cards/src/test/screenshots/s25_waiting_sv_light.png b/android/feature/cards/src/test/screenshots/s25_waiting_sv_light.png
new file mode 100644
index 0000000..e723c52
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s25_waiting_sv_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s26_active_en_light.png b/android/feature/cards/src/test/screenshots/s26_active_en_light.png
new file mode 100644
index 0000000..e93b8b8
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s26_active_en_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s26_active_sv_dark.png b/android/feature/cards/src/test/screenshots/s26_active_sv_dark.png
new file mode 100644
index 0000000..8ba96c4
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s26_active_sv_dark.png differ
diff --git a/android/feature/cards/src/test/screenshots/s26_active_sv_light.png b/android/feature/cards/src/test/screenshots/s26_active_sv_light.png
new file mode 100644
index 0000000..331b6e9
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s26_active_sv_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s26_active_sv_light_200.png b/android/feature/cards/src/test/screenshots/s26_active_sv_light_200.png
new file mode 100644
index 0000000..a0a9f1e
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s26_active_sv_light_200.png differ
diff --git a/android/feature/cards/src/test/screenshots/s26_disconnect_en_light.png b/android/feature/cards/src/test/screenshots/s26_disconnect_en_light.png
new file mode 100644
index 0000000..f84b3d6
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s26_disconnect_en_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s26_disconnect_sv_dark.png b/android/feature/cards/src/test/screenshots/s26_disconnect_sv_dark.png
new file mode 100644
index 0000000..7946689
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s26_disconnect_sv_dark.png differ
diff --git a/android/feature/cards/src/test/screenshots/s26_disconnect_sv_light.png b/android/feature/cards/src/test/screenshots/s26_disconnect_sv_light.png
new file mode 100644
index 0000000..8d8e496
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s26_disconnect_sv_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s26_expired_en_light.png b/android/feature/cards/src/test/screenshots/s26_expired_en_light.png
new file mode 100644
index 0000000..60d45d6
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s26_expired_en_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s26_expired_sv_dark.png b/android/feature/cards/src/test/screenshots/s26_expired_sv_dark.png
new file mode 100644
index 0000000..a78bb02
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s26_expired_sv_dark.png differ
diff --git a/android/feature/cards/src/test/screenshots/s26_expired_sv_light.png b/android/feature/cards/src/test/screenshots/s26_expired_sv_light.png
new file mode 100644
index 0000000..33f515f
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s26_expired_sv_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s26_expiring_en_light.png b/android/feature/cards/src/test/screenshots/s26_expiring_en_light.png
new file mode 100644
index 0000000..0d1e4ba
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s26_expiring_en_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s26_expiring_sv_dark.png b/android/feature/cards/src/test/screenshots/s26_expiring_sv_dark.png
new file mode 100644
index 0000000..85d805c
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s26_expiring_sv_dark.png differ
diff --git a/android/feature/cards/src/test/screenshots/s26_expiring_sv_light.png b/android/feature/cards/src/test/screenshots/s26_expiring_sv_light.png
new file mode 100644
index 0000000..c30d9ab
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s26_expiring_sv_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s26_offline_en_light.png b/android/feature/cards/src/test/screenshots/s26_offline_en_light.png
new file mode 100644
index 0000000..15c199d
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s26_offline_en_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s26_offline_sv_dark.png b/android/feature/cards/src/test/screenshots/s26_offline_sv_dark.png
new file mode 100644
index 0000000..3aae9ed
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s26_offline_sv_dark.png differ
diff --git a/android/feature/cards/src/test/screenshots/s26_offline_sv_light.png b/android/feature/cards/src/test/screenshots/s26_offline_sv_light.png
new file mode 100644
index 0000000..55ea095
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s26_offline_sv_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s27_empty_en_light.png b/android/feature/cards/src/test/screenshots/s27_empty_en_light.png
new file mode 100644
index 0000000..fa33617
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s27_empty_en_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s27_empty_sv_dark.png b/android/feature/cards/src/test/screenshots/s27_empty_sv_dark.png
new file mode 100644
index 0000000..5c68ccd
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s27_empty_sv_dark.png differ
diff --git a/android/feature/cards/src/test/screenshots/s27_empty_sv_light.png b/android/feature/cards/src/test/screenshots/s27_empty_sv_light.png
new file mode 100644
index 0000000..e4dcfd2
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s27_empty_sv_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s27_to_handle_en_light.png b/android/feature/cards/src/test/screenshots/s27_to_handle_en_light.png
new file mode 100644
index 0000000..4bcdd44
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s27_to_handle_en_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s27_to_handle_sv_dark.png b/android/feature/cards/src/test/screenshots/s27_to_handle_sv_dark.png
new file mode 100644
index 0000000..921662b
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s27_to_handle_sv_dark.png differ
diff --git a/android/feature/cards/src/test/screenshots/s27_to_handle_sv_light.png b/android/feature/cards/src/test/screenshots/s27_to_handle_sv_light.png
new file mode 100644
index 0000000..f185dc5
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s27_to_handle_sv_light.png differ
diff --git a/android/feature/cards/src/test/screenshots/s27_to_handle_sv_light_200.png b/android/feature/cards/src/test/screenshots/s27_to_handle_sv_light_200.png
new file mode 100644
index 0000000..8156bfc
Binary files /dev/null and b/android/feature/cards/src/test/screenshots/s27_to_handle_sv_light_200.png differ
diff --git a/android/feature/expense/src/main/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseDetailScreen.kt b/android/feature/expense/src/main/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseDetailScreen.kt
index 97b6a60..e98c8ee 100644
--- a/android/feature/expense/src/main/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseDetailScreen.kt
+++ b/android/feature/expense/src/main/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseDetailScreen.kt
@@ -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: , " 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) {
diff --git a/android/feature/expense/src/main/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseDetailViewModel.kt b/android/feature/expense/src/main/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseDetailViewModel.kt
index cb2cba0..ede0a7c 100644
--- a/android/feature/expense/src/main/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseDetailViewModel.kt
+++ b/android/feature/expense/src/main/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseDetailViewModel.kt
@@ -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(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) }
diff --git a/android/feature/expense/src/main/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseEditorViewModel.kt b/android/feature/expense/src/main/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseEditorViewModel.kt
index 9c168d0..6d1012e 100644
--- a/android/feature/expense/src/main/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseEditorViewModel.kt
+++ b/android/feature/expense/src/main/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseEditorViewModel.kt
@@ -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(ARG_EXPENSE_ID)?.let(::ExpenseId)
private val openerAccount: AccountId? = savedStateHandle.get(ARG_ACCOUNT_ID)?.let(::AccountId)
+ private val cardPurchase: CardPurchaseId? = savedStateHandle.get(ARG_CARD_PURCHASE_ID)?.let(::CardPurchaseId)
private data class Environment(val options: ExpenseEditorOptions, val summaries: List, val all: List, 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"
diff --git a/android/feature/expense/src/main/res/values-sv/strings.xml b/android/feature/expense/src/main/res/values-sv/strings.xml
index c9e7dbd..3dfc85b 100644
--- a/android/feature/expense/src/main/res/values-sv/strings.xml
+++ b/android/feature/expense/src/main/res/values-sv/strings.xml
@@ -18,4 +18,8 @@
Utgiften finns inte på telefonen än
Den kan ha lagts till nyss på en annan telefon. Synka för att hämta den.
Synka nu
+
+ Från kortköp: %1$s, %2$s
+
+ Ta bort koppling
diff --git a/android/feature/expense/src/main/res/values/strings.xml b/android/feature/expense/src/main/res/values/strings.xml
index aa190af..ca03c86 100644
--- a/android/feature/expense/src/main/res/values/strings.xml
+++ b/android/feature/expense/src/main/res/values/strings.xml
@@ -18,4 +18,8 @@
This expense is not on the phone yet
It may have just been added on another phone. Sync to fetch it.
Sync now
+
+ From card purchase: %1$s, %2$s
+
+ Remove link
diff --git a/android/feature/expense/src/test/kotlin/se/hagstedt/kvitt/feature/expense/EditorOptionRowsLayoutTest.kt b/android/feature/expense/src/test/kotlin/se/hagstedt/kvitt/feature/expense/EditorOptionRowsLayoutTest.kt
index e461e85..92b5eab 100644
--- a/android/feature/expense/src/test/kotlin/se/hagstedt/kvitt/feature/expense/EditorOptionRowsLayoutTest.kt
+++ b/android/feature/expense/src/test/kotlin/se/hagstedt/kvitt/feature/expense/EditorOptionRowsLayoutTest.kt
@@ -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")
diff --git a/android/feature/expense/src/test/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseDetailViewModelTest.kt b/android/feature/expense/src/test/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseDetailViewModelTest.kt
index 9c46436..3e9d226 100644
--- a/android/feature/expense/src/test/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseDetailViewModelTest.kt
+++ b/android/feature/expense/src/test/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseDetailViewModelTest.kt
@@ -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 {
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)
+ }
}
diff --git a/android/feature/expense/src/test/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseEditorViewModelTest.kt b/android/feature/expense/src/test/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseEditorViewModelTest.kt
index 94c487c..48804a7 100644
--- a/android/feature/expense/src/test/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseEditorViewModelTest.kt
+++ b/android/feature/expense/src/test/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseEditorViewModelTest.kt
@@ -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 = emptyMap()): Pair {
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)
+ }
}
diff --git a/android/feature/expense/src/test/kotlin/se/hagstedt/kvitt/feature/expense/screenshot/Cases.kt b/android/feature/expense/src/test/kotlin/se/hagstedt/kvitt/feature/expense/screenshot/Cases.kt
index 7fc7f3c..8790c05 100644
--- a/android/feature/expense/src/test/kotlin/se/hagstedt/kvitt/feature/expense/screenshot/Cases.kt
+++ b/android/feature/expense/src/test/kotlin/se/hagstedt/kvitt/feature/expense/screenshot/Cases.kt
@@ -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: , " with "Ta bort koppling".
+ detail("s8_from_card", HomeScenarios.willys.id, CardScenarios.loaded),
)
/** Long content on a tall frame so the whole screen shows. */
diff --git a/android/feature/expense/src/test/screenshots/s7_from_card_en_light.png b/android/feature/expense/src/test/screenshots/s7_from_card_en_light.png
new file mode 100644
index 0000000..298d2a8
Binary files /dev/null and b/android/feature/expense/src/test/screenshots/s7_from_card_en_light.png differ
diff --git a/android/feature/expense/src/test/screenshots/s7_from_card_foreign_en_light.png b/android/feature/expense/src/test/screenshots/s7_from_card_foreign_en_light.png
new file mode 100644
index 0000000..a9388e4
Binary files /dev/null and b/android/feature/expense/src/test/screenshots/s7_from_card_foreign_en_light.png differ
diff --git a/android/feature/expense/src/test/screenshots/s7_from_card_foreign_sv_dark.png b/android/feature/expense/src/test/screenshots/s7_from_card_foreign_sv_dark.png
new file mode 100644
index 0000000..b422cb1
Binary files /dev/null and b/android/feature/expense/src/test/screenshots/s7_from_card_foreign_sv_dark.png differ
diff --git a/android/feature/expense/src/test/screenshots/s7_from_card_foreign_sv_light.png b/android/feature/expense/src/test/screenshots/s7_from_card_foreign_sv_light.png
new file mode 100644
index 0000000..17dd623
Binary files /dev/null and b/android/feature/expense/src/test/screenshots/s7_from_card_foreign_sv_light.png differ
diff --git a/android/feature/expense/src/test/screenshots/s7_from_card_sv_dark.png b/android/feature/expense/src/test/screenshots/s7_from_card_sv_dark.png
new file mode 100644
index 0000000..8248bae
Binary files /dev/null and b/android/feature/expense/src/test/screenshots/s7_from_card_sv_dark.png differ
diff --git a/android/feature/expense/src/test/screenshots/s7_from_card_sv_light.png b/android/feature/expense/src/test/screenshots/s7_from_card_sv_light.png
new file mode 100644
index 0000000..193c7a8
Binary files /dev/null and b/android/feature/expense/src/test/screenshots/s7_from_card_sv_light.png differ
diff --git a/android/feature/expense/src/test/screenshots/s8_from_card_en_light.png b/android/feature/expense/src/test/screenshots/s8_from_card_en_light.png
new file mode 100644
index 0000000..a36e48a
Binary files /dev/null and b/android/feature/expense/src/test/screenshots/s8_from_card_en_light.png differ
diff --git a/android/feature/expense/src/test/screenshots/s8_from_card_sv_dark.png b/android/feature/expense/src/test/screenshots/s8_from_card_sv_dark.png
new file mode 100644
index 0000000..7d63f1d
Binary files /dev/null and b/android/feature/expense/src/test/screenshots/s8_from_card_sv_dark.png differ
diff --git a/android/feature/expense/src/test/screenshots/s8_from_card_sv_light.png b/android/feature/expense/src/test/screenshots/s8_from_card_sv_light.png
new file mode 100644
index 0000000..fab14de
Binary files /dev/null and b/android/feature/expense/src/test/screenshots/s8_from_card_sv_light.png differ
diff --git a/android/feature/home/src/test/screenshots/S-17-install-blocked_en_light.png b/android/feature/home/src/test/screenshots/S-17-install-blocked_en_light.png
index 57afe8e..ec4769a 100644
Binary files a/android/feature/home/src/test/screenshots/S-17-install-blocked_en_light.png and b/android/feature/home/src/test/screenshots/S-17-install-blocked_en_light.png differ
diff --git a/android/feature/home/src/test/screenshots/S-17-install-blocked_sv_dark.png b/android/feature/home/src/test/screenshots/S-17-install-blocked_sv_dark.png
index eea34f8..48a205d 100644
Binary files a/android/feature/home/src/test/screenshots/S-17-install-blocked_sv_dark.png and b/android/feature/home/src/test/screenshots/S-17-install-blocked_sv_dark.png differ
diff --git a/android/feature/home/src/test/screenshots/S-17-install-blocked_sv_light.png b/android/feature/home/src/test/screenshots/S-17-install-blocked_sv_light.png
index 75e183d..abe4285 100644
Binary files a/android/feature/home/src/test/screenshots/S-17-install-blocked_sv_light.png and b/android/feature/home/src/test/screenshots/S-17-install-blocked_sv_light.png differ
diff --git a/android/feature/home/src/test/screenshots/S-17-install-failed_en_light.png b/android/feature/home/src/test/screenshots/S-17-install-failed_en_light.png
index 536bc75..d5729b0 100644
Binary files a/android/feature/home/src/test/screenshots/S-17-install-failed_en_light.png and b/android/feature/home/src/test/screenshots/S-17-install-failed_en_light.png differ
diff --git a/android/feature/home/src/test/screenshots/S-17-install-failed_sv_dark.png b/android/feature/home/src/test/screenshots/S-17-install-failed_sv_dark.png
index 4016303..d99adf2 100644
Binary files a/android/feature/home/src/test/screenshots/S-17-install-failed_sv_dark.png and b/android/feature/home/src/test/screenshots/S-17-install-failed_sv_dark.png differ
diff --git a/android/feature/home/src/test/screenshots/S-17-install-failed_sv_light.png b/android/feature/home/src/test/screenshots/S-17-install-failed_sv_light.png
index 4ddb691..a714d11 100644
Binary files a/android/feature/home/src/test/screenshots/S-17-install-failed_sv_light.png and b/android/feature/home/src/test/screenshots/S-17-install-failed_sv_light.png differ
diff --git a/android/feature/home/src/test/screenshots/S-17-update-banner_en_light.png b/android/feature/home/src/test/screenshots/S-17-update-banner_en_light.png
index 66af372..1e25b2e 100644
Binary files a/android/feature/home/src/test/screenshots/S-17-update-banner_en_light.png and b/android/feature/home/src/test/screenshots/S-17-update-banner_en_light.png differ
diff --git a/android/feature/home/src/test/screenshots/S-17-update-banner_sv_dark.png b/android/feature/home/src/test/screenshots/S-17-update-banner_sv_dark.png
index cf88e0d..3c7645f 100644
Binary files a/android/feature/home/src/test/screenshots/S-17-update-banner_sv_dark.png and b/android/feature/home/src/test/screenshots/S-17-update-banner_sv_dark.png differ
diff --git a/android/feature/home/src/test/screenshots/S-17-update-banner_sv_light.png b/android/feature/home/src/test/screenshots/S-17-update-banner_sv_light.png
index e602321..da52802 100644
Binary files a/android/feature/home/src/test/screenshots/S-17-update-banner_sv_light.png and b/android/feature/home/src/test/screenshots/S-17-update-banner_sv_light.png differ
diff --git a/android/feature/home/src/test/screenshots/S-17-update-failed_en_light.png b/android/feature/home/src/test/screenshots/S-17-update-failed_en_light.png
index 42e3d56..db1158b 100644
Binary files a/android/feature/home/src/test/screenshots/S-17-update-failed_en_light.png and b/android/feature/home/src/test/screenshots/S-17-update-failed_en_light.png differ
diff --git a/android/feature/home/src/test/screenshots/S-17-update-failed_sv_dark.png b/android/feature/home/src/test/screenshots/S-17-update-failed_sv_dark.png
index 8e8e83a..9dd2335 100644
Binary files a/android/feature/home/src/test/screenshots/S-17-update-failed_sv_dark.png and b/android/feature/home/src/test/screenshots/S-17-update-failed_sv_dark.png differ
diff --git a/android/feature/home/src/test/screenshots/S-17-update-failed_sv_light.png b/android/feature/home/src/test/screenshots/S-17-update-failed_sv_light.png
index b783374..f63ba48 100644
Binary files a/android/feature/home/src/test/screenshots/S-17-update-failed_sv_light.png and b/android/feature/home/src/test/screenshots/S-17-update-failed_sv_light.png differ
diff --git a/android/feature/home/src/test/screenshots/S-17-update-permission_en_light.png b/android/feature/home/src/test/screenshots/S-17-update-permission_en_light.png
index 66af372..1e25b2e 100644
Binary files a/android/feature/home/src/test/screenshots/S-17-update-permission_en_light.png and b/android/feature/home/src/test/screenshots/S-17-update-permission_en_light.png differ
diff --git a/android/feature/home/src/test/screenshots/S-17-update-permission_sv_dark.png b/android/feature/home/src/test/screenshots/S-17-update-permission_sv_dark.png
index cf88e0d..3c7645f 100644
Binary files a/android/feature/home/src/test/screenshots/S-17-update-permission_sv_dark.png and b/android/feature/home/src/test/screenshots/S-17-update-permission_sv_dark.png differ
diff --git a/android/feature/home/src/test/screenshots/S-17-update-permission_sv_light.png b/android/feature/home/src/test/screenshots/S-17-update-permission_sv_light.png
index e602321..da52802 100644
Binary files a/android/feature/home/src/test/screenshots/S-17-update-permission_sv_light.png and b/android/feature/home/src/test/screenshots/S-17-update-permission_sv_light.png differ
diff --git a/android/feature/home/src/test/screenshots/S-17-update-progress_en_light.png b/android/feature/home/src/test/screenshots/S-17-update-progress_en_light.png
index 948e4a2..c2f71fc 100644
Binary files a/android/feature/home/src/test/screenshots/S-17-update-progress_en_light.png and b/android/feature/home/src/test/screenshots/S-17-update-progress_en_light.png differ
diff --git a/android/feature/home/src/test/screenshots/S-17-update-progress_sv_dark.png b/android/feature/home/src/test/screenshots/S-17-update-progress_sv_dark.png
index 70db8dd..6c32d88 100644
Binary files a/android/feature/home/src/test/screenshots/S-17-update-progress_sv_dark.png and b/android/feature/home/src/test/screenshots/S-17-update-progress_sv_dark.png differ
diff --git a/android/feature/home/src/test/screenshots/S-17-update-progress_sv_light.png b/android/feature/home/src/test/screenshots/S-17-update-progress_sv_light.png
index 49a316f..33bbdf8 100644
Binary files a/android/feature/home/src/test/screenshots/S-17-update-progress_sv_light.png and b/android/feature/home/src/test/screenshots/S-17-update-progress_sv_light.png differ
diff --git a/android/feature/home/src/test/screenshots/S-17-update_en_light.png b/android/feature/home/src/test/screenshots/S-17-update_en_light.png
index ec5986a..ffd735f 100644
Binary files a/android/feature/home/src/test/screenshots/S-17-update_en_light.png and b/android/feature/home/src/test/screenshots/S-17-update_en_light.png differ
diff --git a/android/feature/home/src/test/screenshots/S-17-update_sv_dark.png b/android/feature/home/src/test/screenshots/S-17-update_sv_dark.png
index cebe1b4..32fdd25 100644
Binary files a/android/feature/home/src/test/screenshots/S-17-update_sv_dark.png and b/android/feature/home/src/test/screenshots/S-17-update_sv_dark.png differ
diff --git a/android/feature/home/src/test/screenshots/S-17-update_sv_light.png b/android/feature/home/src/test/screenshots/S-17-update_sv_light.png
index df1200d..3d9d260 100644
Binary files a/android/feature/home/src/test/screenshots/S-17-update_sv_light.png and b/android/feature/home/src/test/screenshots/S-17-update_sv_light.png differ
diff --git a/android/feature/home/src/test/screenshots/S-4-home-clock_en_light.png b/android/feature/home/src/test/screenshots/S-4-home-clock_en_light.png
index 5b4ce69..3e392e2 100644
Binary files a/android/feature/home/src/test/screenshots/S-4-home-clock_en_light.png and b/android/feature/home/src/test/screenshots/S-4-home-clock_en_light.png differ
diff --git a/android/feature/home/src/test/screenshots/S-4-home-clock_sv_dark.png b/android/feature/home/src/test/screenshots/S-4-home-clock_sv_dark.png
index ad0c134..667dd31 100644
Binary files a/android/feature/home/src/test/screenshots/S-4-home-clock_sv_dark.png and b/android/feature/home/src/test/screenshots/S-4-home-clock_sv_dark.png differ
diff --git a/android/feature/home/src/test/screenshots/S-4-home-clock_sv_light.png b/android/feature/home/src/test/screenshots/S-4-home-clock_sv_light.png
index 9621050..8eb045c 100644
Binary files a/android/feature/home/src/test/screenshots/S-4-home-clock_sv_light.png and b/android/feature/home/src/test/screenshots/S-4-home-clock_sv_light.png differ
diff --git a/android/feature/home/src/test/screenshots/S-4-home-empty_en_light.png b/android/feature/home/src/test/screenshots/S-4-home-empty_en_light.png
index ed92786..638addb 100644
Binary files a/android/feature/home/src/test/screenshots/S-4-home-empty_en_light.png and b/android/feature/home/src/test/screenshots/S-4-home-empty_en_light.png differ
diff --git a/android/feature/home/src/test/screenshots/S-4-home-empty_sv_dark.png b/android/feature/home/src/test/screenshots/S-4-home-empty_sv_dark.png
index a126685..5c29786 100644
Binary files a/android/feature/home/src/test/screenshots/S-4-home-empty_sv_dark.png and b/android/feature/home/src/test/screenshots/S-4-home-empty_sv_dark.png differ
diff --git a/android/feature/home/src/test/screenshots/S-4-home-empty_sv_light.png b/android/feature/home/src/test/screenshots/S-4-home-empty_sv_light.png
index 783eba5..702de29 100644
Binary files a/android/feature/home/src/test/screenshots/S-4-home-empty_sv_light.png and b/android/feature/home/src/test/screenshots/S-4-home-empty_sv_light.png differ
diff --git a/android/feature/home/src/test/screenshots/S-4-home-loading_en_light.png b/android/feature/home/src/test/screenshots/S-4-home-loading_en_light.png
index b480a01..a432d0d 100644
Binary files a/android/feature/home/src/test/screenshots/S-4-home-loading_en_light.png and b/android/feature/home/src/test/screenshots/S-4-home-loading_en_light.png differ
diff --git a/android/feature/home/src/test/screenshots/S-4-home-loading_sv_dark.png b/android/feature/home/src/test/screenshots/S-4-home-loading_sv_dark.png
index e2f9b94..1b84f65 100644
Binary files a/android/feature/home/src/test/screenshots/S-4-home-loading_sv_dark.png and b/android/feature/home/src/test/screenshots/S-4-home-loading_sv_dark.png differ
diff --git a/android/feature/home/src/test/screenshots/S-4-home-loading_sv_light.png b/android/feature/home/src/test/screenshots/S-4-home-loading_sv_light.png
index 19780e8..a6ba376 100644
Binary files a/android/feature/home/src/test/screenshots/S-4-home-loading_sv_light.png and b/android/feature/home/src/test/screenshots/S-4-home-loading_sv_light.png differ
diff --git a/android/feature/home/src/test/screenshots/S-4-home-long_en_light.png b/android/feature/home/src/test/screenshots/S-4-home-long_en_light.png
index 958c021..dbf078c 100644
Binary files a/android/feature/home/src/test/screenshots/S-4-home-long_en_light.png and b/android/feature/home/src/test/screenshots/S-4-home-long_en_light.png differ
diff --git a/android/feature/home/src/test/screenshots/S-4-home-long_sv_dark.png b/android/feature/home/src/test/screenshots/S-4-home-long_sv_dark.png
index 0e4a93e..1e9fae6 100644
Binary files a/android/feature/home/src/test/screenshots/S-4-home-long_sv_dark.png and b/android/feature/home/src/test/screenshots/S-4-home-long_sv_dark.png differ
diff --git a/android/feature/home/src/test/screenshots/S-4-home-long_sv_light.png b/android/feature/home/src/test/screenshots/S-4-home-long_sv_light.png
index bb4decf..8ac7974 100644
Binary files a/android/feature/home/src/test/screenshots/S-4-home-long_sv_light.png and b/android/feature/home/src/test/screenshots/S-4-home-long_sv_light.png differ
diff --git a/android/feature/home/src/test/screenshots/S-4-home-long_sv_light_200.png b/android/feature/home/src/test/screenshots/S-4-home-long_sv_light_200.png
index b53aa52..a796a0c 100644
Binary files a/android/feature/home/src/test/screenshots/S-4-home-long_sv_light_200.png and b/android/feature/home/src/test/screenshots/S-4-home-long_sv_light_200.png differ
diff --git a/android/feature/home/src/test/screenshots/S-4-home-offline_en_light.png b/android/feature/home/src/test/screenshots/S-4-home-offline_en_light.png
index 574106d..3060722 100644
Binary files a/android/feature/home/src/test/screenshots/S-4-home-offline_en_light.png and b/android/feature/home/src/test/screenshots/S-4-home-offline_en_light.png differ
diff --git a/android/feature/home/src/test/screenshots/S-4-home-offline_sv_dark.png b/android/feature/home/src/test/screenshots/S-4-home-offline_sv_dark.png
index f668259..2d49e67 100644
Binary files a/android/feature/home/src/test/screenshots/S-4-home-offline_sv_dark.png and b/android/feature/home/src/test/screenshots/S-4-home-offline_sv_dark.png differ
diff --git a/android/feature/home/src/test/screenshots/S-4-home-offline_sv_light.png b/android/feature/home/src/test/screenshots/S-4-home-offline_sv_light.png
index 06c84be..79c2451 100644
Binary files a/android/feature/home/src/test/screenshots/S-4-home-offline_sv_light.png and b/android/feature/home/src/test/screenshots/S-4-home-offline_sv_light.png differ
diff --git a/android/feature/home/src/test/screenshots/S-4-home-only-archived_en_light.png b/android/feature/home/src/test/screenshots/S-4-home-only-archived_en_light.png
index 02ef53c..4169873 100644
Binary files a/android/feature/home/src/test/screenshots/S-4-home-only-archived_en_light.png and b/android/feature/home/src/test/screenshots/S-4-home-only-archived_en_light.png differ
diff --git a/android/feature/home/src/test/screenshots/S-4-home-only-archived_sv_dark.png b/android/feature/home/src/test/screenshots/S-4-home-only-archived_sv_dark.png
index bd28716..c61aa6e 100644
Binary files a/android/feature/home/src/test/screenshots/S-4-home-only-archived_sv_dark.png and b/android/feature/home/src/test/screenshots/S-4-home-only-archived_sv_dark.png differ
diff --git a/android/feature/home/src/test/screenshots/S-4-home-only-archived_sv_light.png b/android/feature/home/src/test/screenshots/S-4-home-only-archived_sv_light.png
index cf08cd0..009ba91 100644
Binary files a/android/feature/home/src/test/screenshots/S-4-home-only-archived_sv_light.png and b/android/feature/home/src/test/screenshots/S-4-home-only-archived_sv_light.png differ
diff --git a/android/feature/home/src/test/screenshots/S-4-home-owes-you_en_light.png b/android/feature/home/src/test/screenshots/S-4-home-owes-you_en_light.png
index c9ba554..446327a 100644
Binary files a/android/feature/home/src/test/screenshots/S-4-home-owes-you_en_light.png and b/android/feature/home/src/test/screenshots/S-4-home-owes-you_en_light.png differ
diff --git a/android/feature/home/src/test/screenshots/S-4-home-owes-you_sv_dark.png b/android/feature/home/src/test/screenshots/S-4-home-owes-you_sv_dark.png
index 6904709..4ed54fe 100644
Binary files a/android/feature/home/src/test/screenshots/S-4-home-owes-you_sv_dark.png and b/android/feature/home/src/test/screenshots/S-4-home-owes-you_sv_dark.png differ
diff --git a/android/feature/home/src/test/screenshots/S-4-home-owes-you_sv_light.png b/android/feature/home/src/test/screenshots/S-4-home-owes-you_sv_light.png
index 83f1c94..e05a66c 100644
Binary files a/android/feature/home/src/test/screenshots/S-4-home-owes-you_sv_light.png and b/android/feature/home/src/test/screenshots/S-4-home-owes-you_sv_light.png differ
diff --git a/android/feature/home/src/test/screenshots/S-4-home-pending_en_light.png b/android/feature/home/src/test/screenshots/S-4-home-pending_en_light.png
index c8b7753..46cb5cd 100644
Binary files a/android/feature/home/src/test/screenshots/S-4-home-pending_en_light.png and b/android/feature/home/src/test/screenshots/S-4-home-pending_en_light.png differ
diff --git a/android/feature/home/src/test/screenshots/S-4-home-pending_sv_dark.png b/android/feature/home/src/test/screenshots/S-4-home-pending_sv_dark.png
index 0fd71af..1d53c70 100644
Binary files a/android/feature/home/src/test/screenshots/S-4-home-pending_sv_dark.png and b/android/feature/home/src/test/screenshots/S-4-home-pending_sv_dark.png differ
diff --git a/android/feature/home/src/test/screenshots/S-4-home-pending_sv_light.png b/android/feature/home/src/test/screenshots/S-4-home-pending_sv_light.png
index a3abf32..872b2c7 100644
Binary files a/android/feature/home/src/test/screenshots/S-4-home-pending_sv_light.png and b/android/feature/home/src/test/screenshots/S-4-home-pending_sv_light.png differ
diff --git a/android/feature/home/src/test/screenshots/S-4-home-refreshing_en_light.png b/android/feature/home/src/test/screenshots/S-4-home-refreshing_en_light.png
index 0a3b966..cc16f9c 100644
Binary files a/android/feature/home/src/test/screenshots/S-4-home-refreshing_en_light.png and b/android/feature/home/src/test/screenshots/S-4-home-refreshing_en_light.png differ
diff --git a/android/feature/home/src/test/screenshots/S-4-home-refreshing_sv_dark.png b/android/feature/home/src/test/screenshots/S-4-home-refreshing_sv_dark.png
index 20e46a4..e96a066 100644
Binary files a/android/feature/home/src/test/screenshots/S-4-home-refreshing_sv_dark.png and b/android/feature/home/src/test/screenshots/S-4-home-refreshing_sv_dark.png differ
diff --git a/android/feature/home/src/test/screenshots/S-4-home-refreshing_sv_light.png b/android/feature/home/src/test/screenshots/S-4-home-refreshing_sv_light.png
index ad873a3..7dddcbd 100644
Binary files a/android/feature/home/src/test/screenshots/S-4-home-refreshing_sv_light.png and b/android/feature/home/src/test/screenshots/S-4-home-refreshing_sv_light.png differ
diff --git a/android/feature/home/src/test/screenshots/S-4-home-rejected_en_light.png b/android/feature/home/src/test/screenshots/S-4-home-rejected_en_light.png
index 56598f9..96b2aba 100644
Binary files a/android/feature/home/src/test/screenshots/S-4-home-rejected_en_light.png and b/android/feature/home/src/test/screenshots/S-4-home-rejected_en_light.png differ
diff --git a/android/feature/home/src/test/screenshots/S-4-home-rejected_sv_dark.png b/android/feature/home/src/test/screenshots/S-4-home-rejected_sv_dark.png
index bc10567..575f50a 100644
Binary files a/android/feature/home/src/test/screenshots/S-4-home-rejected_sv_dark.png and b/android/feature/home/src/test/screenshots/S-4-home-rejected_sv_dark.png differ
diff --git a/android/feature/home/src/test/screenshots/S-4-home-rejected_sv_light.png b/android/feature/home/src/test/screenshots/S-4-home-rejected_sv_light.png
index f58fcfd..2367916 100644
Binary files a/android/feature/home/src/test/screenshots/S-4-home-rejected_sv_light.png and b/android/feature/home/src/test/screenshots/S-4-home-rejected_sv_light.png differ
diff --git a/android/feature/home/src/test/screenshots/S-4-home-settled_en_light.png b/android/feature/home/src/test/screenshots/S-4-home-settled_en_light.png
index 6db03da..bb09fc1 100644
Binary files a/android/feature/home/src/test/screenshots/S-4-home-settled_en_light.png and b/android/feature/home/src/test/screenshots/S-4-home-settled_en_light.png differ
diff --git a/android/feature/home/src/test/screenshots/S-4-home-settled_sv_dark.png b/android/feature/home/src/test/screenshots/S-4-home-settled_sv_dark.png
index b7f1da6..a8ca45e 100644
Binary files a/android/feature/home/src/test/screenshots/S-4-home-settled_sv_dark.png and b/android/feature/home/src/test/screenshots/S-4-home-settled_sv_dark.png differ
diff --git a/android/feature/home/src/test/screenshots/S-4-home-settled_sv_light.png b/android/feature/home/src/test/screenshots/S-4-home-settled_sv_light.png
index 1c20cb7..671ab0f 100644
Binary files a/android/feature/home/src/test/screenshots/S-4-home-settled_sv_light.png and b/android/feature/home/src/test/screenshots/S-4-home-settled_sv_light.png differ
diff --git a/android/feature/home/src/test/screenshots/S-4-home-syncing_en_light.png b/android/feature/home/src/test/screenshots/S-4-home-syncing_en_light.png
index 9a90d11..91d0954 100644
Binary files a/android/feature/home/src/test/screenshots/S-4-home-syncing_en_light.png and b/android/feature/home/src/test/screenshots/S-4-home-syncing_en_light.png differ
diff --git a/android/feature/home/src/test/screenshots/S-4-home-syncing_sv_dark.png b/android/feature/home/src/test/screenshots/S-4-home-syncing_sv_dark.png
index ab4e0d1..8203571 100644
Binary files a/android/feature/home/src/test/screenshots/S-4-home-syncing_sv_dark.png and b/android/feature/home/src/test/screenshots/S-4-home-syncing_sv_dark.png differ
diff --git a/android/feature/home/src/test/screenshots/S-4-home-syncing_sv_light.png b/android/feature/home/src/test/screenshots/S-4-home-syncing_sv_light.png
index 9c506e4..3b56da8 100644
Binary files a/android/feature/home/src/test/screenshots/S-4-home-syncing_sv_light.png and b/android/feature/home/src/test/screenshots/S-4-home-syncing_sv_light.png differ
diff --git a/android/feature/home/src/test/screenshots/S-4-home_en_light.png b/android/feature/home/src/test/screenshots/S-4-home_en_light.png
index ec85807..d2fecfe 100644
Binary files a/android/feature/home/src/test/screenshots/S-4-home_en_light.png and b/android/feature/home/src/test/screenshots/S-4-home_en_light.png differ
diff --git a/android/feature/home/src/test/screenshots/S-4-home_sv_dark.png b/android/feature/home/src/test/screenshots/S-4-home_sv_dark.png
index c44e050..e78bf34 100644
Binary files a/android/feature/home/src/test/screenshots/S-4-home_sv_dark.png and b/android/feature/home/src/test/screenshots/S-4-home_sv_dark.png differ
diff --git a/android/feature/home/src/test/screenshots/S-4-home_sv_light.png b/android/feature/home/src/test/screenshots/S-4-home_sv_light.png
index 400268f..f8520ee 100644
Binary files a/android/feature/home/src/test/screenshots/S-4-home_sv_light.png and b/android/feature/home/src/test/screenshots/S-4-home_sv_light.png differ
diff --git a/android/feature/onboarding/src/test/screenshots/s3_waiting_en_light.png b/android/feature/onboarding/src/test/screenshots/s3_waiting_en_light.png
index ec0f7e6..32e290d 100644
Binary files a/android/feature/onboarding/src/test/screenshots/s3_waiting_en_light.png and b/android/feature/onboarding/src/test/screenshots/s3_waiting_en_light.png differ
diff --git a/android/feature/onboarding/src/test/screenshots/s3_waiting_error_en_light.png b/android/feature/onboarding/src/test/screenshots/s3_waiting_error_en_light.png
index c69b30e..42b2820 100644
Binary files a/android/feature/onboarding/src/test/screenshots/s3_waiting_error_en_light.png and b/android/feature/onboarding/src/test/screenshots/s3_waiting_error_en_light.png differ
diff --git a/android/feature/onboarding/src/test/screenshots/s3_waiting_error_sv_dark.png b/android/feature/onboarding/src/test/screenshots/s3_waiting_error_sv_dark.png
index 1772160..1937e75 100644
Binary files a/android/feature/onboarding/src/test/screenshots/s3_waiting_error_sv_dark.png and b/android/feature/onboarding/src/test/screenshots/s3_waiting_error_sv_dark.png differ
diff --git a/android/feature/onboarding/src/test/screenshots/s3_waiting_error_sv_light.png b/android/feature/onboarding/src/test/screenshots/s3_waiting_error_sv_light.png
index ae3d0da..237c3dc 100644
Binary files a/android/feature/onboarding/src/test/screenshots/s3_waiting_error_sv_light.png and b/android/feature/onboarding/src/test/screenshots/s3_waiting_error_sv_light.png differ
diff --git a/android/feature/onboarding/src/test/screenshots/s3_waiting_no_invite_en_light.png b/android/feature/onboarding/src/test/screenshots/s3_waiting_no_invite_en_light.png
index c18b4be..96334cd 100644
Binary files a/android/feature/onboarding/src/test/screenshots/s3_waiting_no_invite_en_light.png and b/android/feature/onboarding/src/test/screenshots/s3_waiting_no_invite_en_light.png differ
diff --git a/android/feature/onboarding/src/test/screenshots/s3_waiting_no_invite_sv_dark.png b/android/feature/onboarding/src/test/screenshots/s3_waiting_no_invite_sv_dark.png
index cbfa957..18a362f 100644
Binary files a/android/feature/onboarding/src/test/screenshots/s3_waiting_no_invite_sv_dark.png and b/android/feature/onboarding/src/test/screenshots/s3_waiting_no_invite_sv_dark.png differ
diff --git a/android/feature/onboarding/src/test/screenshots/s3_waiting_no_invite_sv_light.png b/android/feature/onboarding/src/test/screenshots/s3_waiting_no_invite_sv_light.png
index fb5447c..c74b54a 100644
Binary files a/android/feature/onboarding/src/test/screenshots/s3_waiting_no_invite_sv_light.png and b/android/feature/onboarding/src/test/screenshots/s3_waiting_no_invite_sv_light.png differ
diff --git a/android/feature/onboarding/src/test/screenshots/s3_waiting_offline_en_light.png b/android/feature/onboarding/src/test/screenshots/s3_waiting_offline_en_light.png
index 8c62dca..1d9c319 100644
Binary files a/android/feature/onboarding/src/test/screenshots/s3_waiting_offline_en_light.png and b/android/feature/onboarding/src/test/screenshots/s3_waiting_offline_en_light.png differ
diff --git a/android/feature/onboarding/src/test/screenshots/s3_waiting_offline_sv_dark.png b/android/feature/onboarding/src/test/screenshots/s3_waiting_offline_sv_dark.png
index 801eeb4..585cebd 100644
Binary files a/android/feature/onboarding/src/test/screenshots/s3_waiting_offline_sv_dark.png and b/android/feature/onboarding/src/test/screenshots/s3_waiting_offline_sv_dark.png differ
diff --git a/android/feature/onboarding/src/test/screenshots/s3_waiting_offline_sv_light.png b/android/feature/onboarding/src/test/screenshots/s3_waiting_offline_sv_light.png
index 4ffce2e..dd5c8ae 100644
Binary files a/android/feature/onboarding/src/test/screenshots/s3_waiting_offline_sv_light.png and b/android/feature/onboarding/src/test/screenshots/s3_waiting_offline_sv_light.png differ
diff --git a/android/feature/onboarding/src/test/screenshots/s3_waiting_sv_dark.png b/android/feature/onboarding/src/test/screenshots/s3_waiting_sv_dark.png
index 227d2de..2cf58d2 100644
Binary files a/android/feature/onboarding/src/test/screenshots/s3_waiting_sv_dark.png and b/android/feature/onboarding/src/test/screenshots/s3_waiting_sv_dark.png differ
diff --git a/android/feature/onboarding/src/test/screenshots/s3_waiting_sv_light.png b/android/feature/onboarding/src/test/screenshots/s3_waiting_sv_light.png
index 15d5514..596d2c1 100644
Binary files a/android/feature/onboarding/src/test/screenshots/s3_waiting_sv_light.png and b/android/feature/onboarding/src/test/screenshots/s3_waiting_sv_light.png differ
diff --git a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/hub/SettingsHubScreen.kt b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/hub/SettingsHubScreen.kt
index 98b7636..7e6c4ff 100644
--- a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/hub/SettingsHubScreen.kt
+++ b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/hub/SettingsHubScreen.kt
@@ -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 " (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)
diff --git a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/hub/SettingsHubViewModel.kt b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/hub/SettingsHubViewModel.kt
index fd9b3ce..b12216d 100644
--- a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/hub/SettingsHubViewModel.kt
+++ b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/hub/SettingsHubViewModel.kt
@@ -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 = 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 = 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)
diff --git a/android/feature/settings/src/main/res/values-sv/strings.xml b/android/feature/settings/src/main/res/values-sv/strings.xml
index 3418b1e..58d75c8 100644
--- a/android/feature/settings/src/main/res/values-sv/strings.xml
+++ b/android/feature/settings/src/main/res/values-sv/strings.xml
@@ -180,4 +180,12 @@
utfälld
hopfälld
Partner saknas
+
+ Anslut kort
+
+ Ansluten · giltig till %1$s
+
+ Läs in dina kortköp
+
+ Har upphört
diff --git a/android/feature/settings/src/main/res/values/strings.xml b/android/feature/settings/src/main/res/values/strings.xml
index 6717bca..e982cef 100644
--- a/android/feature/settings/src/main/res/values/strings.xml
+++ b/android/feature/settings/src/main/res/values/strings.xml
@@ -180,4 +180,12 @@
expanded
collapsed
Partner has not joined
+
+ Connect card
+
+ Connected · valid until %1$s
+
+ Read your card purchases
+
+ Ended
diff --git a/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/logic/StateHolderTest.kt b/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/logic/StateHolderTest.kt
index 03d2257..c97141a 100644
--- a/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/logic/StateHolderTest.kt
+++ b/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/logic/StateHolderTest.kt
@@ -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() {
diff --git a/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/HubScreenshotTest.kt b/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/HubScreenshotTest.kt
index d3bb0e2..e405e17 100644
--- a/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/HubScreenshotTest.kt
+++ b/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/HubScreenshotTest.kt
@@ -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
diff --git a/android/feature/settings/src/test/screenshots/s13_hub_admin_en_light.png b/android/feature/settings/src/test/screenshots/s13_hub_admin_en_light.png
index 6887a81..979724c 100644
Binary files a/android/feature/settings/src/test/screenshots/s13_hub_admin_en_light.png and b/android/feature/settings/src/test/screenshots/s13_hub_admin_en_light.png differ
diff --git a/android/feature/settings/src/test/screenshots/s13_hub_admin_sv_dark.png b/android/feature/settings/src/test/screenshots/s13_hub_admin_sv_dark.png
index f195db4..71c07eb 100644
Binary files a/android/feature/settings/src/test/screenshots/s13_hub_admin_sv_dark.png and b/android/feature/settings/src/test/screenshots/s13_hub_admin_sv_dark.png differ
diff --git a/android/feature/settings/src/test/screenshots/s13_hub_admin_sv_light.png b/android/feature/settings/src/test/screenshots/s13_hub_admin_sv_light.png
index e75d610..f005a73 100644
Binary files a/android/feature/settings/src/test/screenshots/s13_hub_admin_sv_light.png and b/android/feature/settings/src/test/screenshots/s13_hub_admin_sv_light.png differ
diff --git a/android/feature/settings/src/test/screenshots/s13_hub_card_connected_en_light.png b/android/feature/settings/src/test/screenshots/s13_hub_card_connected_en_light.png
new file mode 100644
index 0000000..affa259
Binary files /dev/null and b/android/feature/settings/src/test/screenshots/s13_hub_card_connected_en_light.png differ
diff --git a/android/feature/settings/src/test/screenshots/s13_hub_card_connected_sv_dark.png b/android/feature/settings/src/test/screenshots/s13_hub_card_connected_sv_dark.png
new file mode 100644
index 0000000..3330fc2
Binary files /dev/null and b/android/feature/settings/src/test/screenshots/s13_hub_card_connected_sv_dark.png differ
diff --git a/android/feature/settings/src/test/screenshots/s13_hub_card_connected_sv_light.png b/android/feature/settings/src/test/screenshots/s13_hub_card_connected_sv_light.png
new file mode 100644
index 0000000..07ae0e5
Binary files /dev/null and b/android/feature/settings/src/test/screenshots/s13_hub_card_connected_sv_light.png differ
diff --git a/android/feature/settings/src/test/screenshots/s13_hub_long_en_light.png b/android/feature/settings/src/test/screenshots/s13_hub_long_en_light.png
index fdf21af..5c06718 100644
Binary files a/android/feature/settings/src/test/screenshots/s13_hub_long_en_light.png and b/android/feature/settings/src/test/screenshots/s13_hub_long_en_light.png differ
diff --git a/android/feature/settings/src/test/screenshots/s13_hub_long_sv_dark.png b/android/feature/settings/src/test/screenshots/s13_hub_long_sv_dark.png
index e472b45..606131c 100644
Binary files a/android/feature/settings/src/test/screenshots/s13_hub_long_sv_dark.png and b/android/feature/settings/src/test/screenshots/s13_hub_long_sv_dark.png differ
diff --git a/android/feature/settings/src/test/screenshots/s13_hub_long_sv_light.png b/android/feature/settings/src/test/screenshots/s13_hub_long_sv_light.png
index 91595c9..f477892 100644
Binary files a/android/feature/settings/src/test/screenshots/s13_hub_long_sv_light.png and b/android/feature/settings/src/test/screenshots/s13_hub_long_sv_light.png differ
diff --git a/android/feature/settings/src/test/screenshots/s13_hub_member_en_light.png b/android/feature/settings/src/test/screenshots/s13_hub_member_en_light.png
index d5252eb..f06d2e9 100644
Binary files a/android/feature/settings/src/test/screenshots/s13_hub_member_en_light.png and b/android/feature/settings/src/test/screenshots/s13_hub_member_en_light.png differ
diff --git a/android/feature/settings/src/test/screenshots/s13_hub_member_sv_dark.png b/android/feature/settings/src/test/screenshots/s13_hub_member_sv_dark.png
index 635a5b9..5a6c170 100644
Binary files a/android/feature/settings/src/test/screenshots/s13_hub_member_sv_dark.png and b/android/feature/settings/src/test/screenshots/s13_hub_member_sv_dark.png differ
diff --git a/android/feature/settings/src/test/screenshots/s13_hub_member_sv_light.png b/android/feature/settings/src/test/screenshots/s13_hub_member_sv_light.png
index f52e257..c9bf266 100644
Binary files a/android/feature/settings/src/test/screenshots/s13_hub_member_sv_light.png and b/android/feature/settings/src/test/screenshots/s13_hub_member_sv_light.png differ
diff --git a/android/feature/settings/src/test/screenshots/s13_hub_offline_en_light.png b/android/feature/settings/src/test/screenshots/s13_hub_offline_en_light.png
index 96e7d3e..eeb252d 100644
Binary files a/android/feature/settings/src/test/screenshots/s13_hub_offline_en_light.png and b/android/feature/settings/src/test/screenshots/s13_hub_offline_en_light.png differ
diff --git a/android/feature/settings/src/test/screenshots/s13_hub_offline_sv_dark.png b/android/feature/settings/src/test/screenshots/s13_hub_offline_sv_dark.png
index b799779..87ee549 100644
Binary files a/android/feature/settings/src/test/screenshots/s13_hub_offline_sv_dark.png and b/android/feature/settings/src/test/screenshots/s13_hub_offline_sv_dark.png differ
diff --git a/android/feature/settings/src/test/screenshots/s13_hub_offline_sv_light.png b/android/feature/settings/src/test/screenshots/s13_hub_offline_sv_light.png
index 7878f7c..8a3ab61 100644
Binary files a/android/feature/settings/src/test/screenshots/s13_hub_offline_sv_light.png and b/android/feature/settings/src/test/screenshots/s13_hub_offline_sv_light.png differ
diff --git a/android/feature/settings/src/test/screenshots/s13_hub_partner_not_joined_en_light.png b/android/feature/settings/src/test/screenshots/s13_hub_partner_not_joined_en_light.png
index d16296b..944e990 100644
Binary files a/android/feature/settings/src/test/screenshots/s13_hub_partner_not_joined_en_light.png and b/android/feature/settings/src/test/screenshots/s13_hub_partner_not_joined_en_light.png differ
diff --git a/android/feature/settings/src/test/screenshots/s13_hub_partner_not_joined_sv_dark.png b/android/feature/settings/src/test/screenshots/s13_hub_partner_not_joined_sv_dark.png
index 6d677a5..5225782 100644
Binary files a/android/feature/settings/src/test/screenshots/s13_hub_partner_not_joined_sv_dark.png and b/android/feature/settings/src/test/screenshots/s13_hub_partner_not_joined_sv_dark.png differ
diff --git a/android/feature/settings/src/test/screenshots/s13_hub_partner_not_joined_sv_light.png b/android/feature/settings/src/test/screenshots/s13_hub_partner_not_joined_sv_light.png
index ac91442..ee126d1 100644
Binary files a/android/feature/settings/src/test/screenshots/s13_hub_partner_not_joined_sv_light.png and b/android/feature/settings/src/test/screenshots/s13_hub_partner_not_joined_sv_light.png differ
diff --git a/android/feature/settings/src/test/screenshots/s13_hub_update_available_en_light.png b/android/feature/settings/src/test/screenshots/s13_hub_update_available_en_light.png
index f88ae3f..ef38373 100644
Binary files a/android/feature/settings/src/test/screenshots/s13_hub_update_available_en_light.png and b/android/feature/settings/src/test/screenshots/s13_hub_update_available_en_light.png differ
diff --git a/android/feature/settings/src/test/screenshots/s13_hub_update_available_sv_dark.png b/android/feature/settings/src/test/screenshots/s13_hub_update_available_sv_dark.png
index 8f69ff7..dd4009c 100644
Binary files a/android/feature/settings/src/test/screenshots/s13_hub_update_available_sv_dark.png and b/android/feature/settings/src/test/screenshots/s13_hub_update_available_sv_dark.png differ
diff --git a/android/feature/settings/src/test/screenshots/s13_hub_update_available_sv_light.png b/android/feature/settings/src/test/screenshots/s13_hub_update_available_sv_light.png
index 3dfcf96..80de33b 100644
Binary files a/android/feature/settings/src/test/screenshots/s13_hub_update_available_sv_light.png and b/android/feature/settings/src/test/screenshots/s13_hub_update_available_sv_light.png differ
diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml
index a5a6ec4..2795382 100644
--- a/android/gradle/libs.versions.toml
+++ b/android/gradle/libs.versions.toml
@@ -24,6 +24,8 @@ kotlinxSerialization = "1.11.0"
kotlinxCoroutines = "1.11.0"
kotlinxDatetime = "0.8.0"
coil = "3.6.3"
+# Custom Tabs for the bank's BankID page (D-173, S-25): the latest stable on Google Maven on 2026-10-24.
+browser = "1.10.0"
junit = "4.13.2"
robolectric = "4.17"
roborazzi = "1.76.0"
@@ -50,6 +52,7 @@ androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-mani
androidx-compose-material3 = { module = "androidx.compose.material3:material3", version.ref = "material3" }
androidx-room3-runtime = { module = "androidx.room3:room3-runtime", version.ref = "room3" }
androidx-room3-compiler = { module = "androidx.room3:room3-compiler", version.ref = "room3" }
+androidx-browser = { module = "androidx.browser:browser", version.ref = "browser" }
androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "work" }
androidx-work-testing = { module = "androidx.work:work-testing", version.ref = "work" }
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts
index a1390d0..6575d6b 100644
--- a/android/settings.gradle.kts
+++ b/android/settings.gradle.kts
@@ -32,5 +32,6 @@ include(
":feature:settle",
":feature:analytics",
":feature:settings",
+ ":feature:cards",
":app",
)
diff --git a/docs/ux/strings.csv b/docs/ux/strings.csv
index 5151804..1c8c8db 100644
--- a/docs/ux/strings.csv
+++ b/docs/ux/strings.csv
@@ -37,6 +37,7 @@ common_partner,Partner,Partner,"module=core:design; role word for the member: S-
common_invite_partner,Invite partner,Bjud in partner,"module=core:design; S-3 button, S-13 row, S-21 title"
common_date_spoken,Date: %1$s,Datum: %1$s,module=core:design; TalkBack of the date chip in S-7 and S-9
nav_home,Home,Översikt,module=core:design
+nav_cards,Card purchases,Kortköp,"module=core:design; bottom destination (D-172), between nav_home and nav_analytics; K2"
nav_analytics,Analytics,Statistik,module=core:design
nav_settings,Settings,Inställningar,module=core:design
nav_label,Navigation,Navigering,module=core:design; TalkBack of the navigation bar
@@ -588,3 +589,79 @@ settings_recurring_summary_none,None,Inga,module=settings; S-13 recurring row su
settings_expanded,expanded,utfälld,module=settings; TalkBack state of the archived section (design-spec §3.11 CollapsibleRow); M7f
settings_collapsed,collapsed,hopfälld,module=settings; TalkBack state of the archived section; M7f
settings_partner_missing,Partner has not joined,Partner saknas,module=settings; S-13 partner row before the partner joins; M7f
+settings_cards,Connect card,Anslut kort,"module=settings; S-13 row (D-172), opens S-24; K2"
+settings_cards_connected,Connected · valid until %1$s,Ansluten · giltig till %1$s,module=settings; S-13 row subtitle; %1$s the consent's end date (§9.8 list form); K2
+settings_cards_none,Read your card purchases,Läs in dina kortköp,module=settings; S-13 row subtitle without a connection; K2
+settings_cards_expired,Ended,Har upphört,module=settings; S-13 row subtitle of an expired connection (CP-6); K2 addition; K2
+account_from_card_purchases,From card purchases,Från kortköp,"module=account; S-5 overflow item while the viewer has a connection (AC-22.9), opens S-27; K2"
+expense_detail_from_card,"From card purchase: %1$s, %2$s","Från kortköp: %1$s, %2$s","module=expense; S-8, the card holder only (AC-22.10); %1$s merchant, %2$s purchase date (list form); K2"
+expense_detail_unlink_card,Remove link,Ta bort koppling,module=expense; S-8 action on the card line (CP-9); K2
+cards_title,Card purchases,Kortköp,module=cards; S-23 app bar; K2
+cards_private_note,Only you see your card purchases,Bara du ser dina kortköp,module=cards; S-23 header (CP-11); K2
+cards_updated,Updated %1$s,Uppdaterad %1$s,module=cards; S-23 header; %1$s §9.8 relative time; K2
+cards_refresh,Refresh,Uppdatera,"module=cards; S-23 header button (E-42, online only); K2"
+cards_refreshing,Refreshing…,Uppdaterar…,module=cards; S-23 header button while E-42 runs; K2
+cards_filter_to_handle,To handle,Att hantera,module=cards; S-23 filter chip (default); K2
+cards_filter_linked,Linked,Kopplade,module=cards; S-23 filter chip; K2
+cards_filter_ignored,Ignored,Ignorerade,module=cards; S-23 filter chip; K2
+cards_filter_all,All,Alla,module=cards; S-23 filter chip; K2
+cards_pending,Pending,Preliminär,module=cards; S-23 row badge and sheet status of a pending purchase; K2
+cards_booked,Booked,Bokförd,module=cards; S-23 sheet status of a booked purchase; K2
+cards_credit,Deposit,Insättning,"module=cards; S-23 badge of a credit (only under Alla, CP-8); K2"
+cards_foreign,Foreign currency,Utländsk valuta,module=cards; S-23 badge of a foreign-currency purchase (CP-7); K2
+cards_foreign_amount,%1$s %2$s,%1$s %2$s,"module=cards; S-23 foreign amount; %1$s the bank's decimal amount, %2$s ISO 4217 currency; K2 addition; K2"
+cards_match_one,Link to %1$s,Koppla till %1$s,module=cards; S-23 row chip with exactly one match (CP-10); %1$s the expense's description; K2
+cards_match_many:one,%1$d possible expense,%1$d möjlig utgift,module=cards; plural one; S-23 row chip with two or three matches; K2
+cards_match_many:other,%1$d possible expenses,%1$d möjliga utgifter,module=cards; plural other; K2
+cards_linked_to,Linked to %1$s · %2$s,Kopplad till %1$s · %2$s,"module=cards; S-23 row under Kopplade; %1$s description, %2$s account; K2"
+cards_empty_title,Connect your card,Anslut ditt kort,module=cards; S-23 empty state (no connection); K2
+cards_empty_body,See your purchases here and turn them into expenses in a few taps. Only you see them.,Se dina köp här och gör om dem till utgifter med några tryck. Bara du ser dem.,module=cards; S-23 empty state; K2
+cards_connect,Connect card,Anslut kort,"module=cards; S-23 empty state button, opens S-24; K2"
+cards_first_fetch,Fetching your purchases…,Hämtar dina köp…,module=cards; S-23 while the first fetch runs; K2
+cards_nothing_to_handle,Nothing to handle,Inget att hantera,module=cards; S-23 empty Att hantera; K2
+cards_nothing_to_handle_body,New card purchases show up here.,Nya kortköp visas här.,module=cards; S-23 empty Att hantera; K2
+cards_filter_empty,No purchases here,Inga köp här,"module=cards; S-23 empty Kopplade, Ignorerade, Alla; K2"
+cards_expiring,Your card connection ends on %1$s,Kortanslutningen upphör %1$s,module=cards; S-23 banner from 14 days before valid_until (CP-6); %1$s list date; K2
+cards_expired,Your card connection has ended,Kortanslutningen har upphört,module=cards; S-23 banner (CP-6); K2
+cards_renew,Renew with BankID,Förnya med BankID,"module=cards; S-23 banners, S-25 renewal, S-26; K2"
+cards_fetch_failed,Could not fetch purchases. Last updated %1$s,Kunde inte hämta köp. Senast uppdaterad %1$s,module=cards; S-23 banner (CP-14); %1$s §9.8 relative time; K2
+cards_sheet_create,Create expense,Skapa utgift,"module=cards; S-23 sheet, opens S-7 prefilled (CP-9); K2"
+cards_sheet_matches,Link to an existing expense,Koppla till befintlig utgift,module=cards; S-23 sheet section (CP-10); K2
+cards_sheet_no_matches,No expense with the same amount around this date,Ingen utgift med samma belopp runt det här datumet,module=cards; S-23 sheet without matches; K2
+cards_sheet_other,Choose another expense,Välj en annan utgift,"module=cards; S-23 sheet, opens the second sheet (CP-10); K2"
+cards_sheet_ignore,Ignore,Ignorera,module=cards; S-23 sheet; K2
+cards_sheet_unignore,Stop ignoring,Sluta ignorera,module=cards; S-23 sheet of an ignored purchase; K2
+cards_sheet_unlink,Remove link,Ta bort koppling,module=cards; S-23 sheet of a linked purchase; K2
+cards_sheet_open_expense,Open expense,Öppna utgift,"module=cards; S-23 sheet of a linked purchase, opens S-8; K2"
+cards_sheet_credit_note,Deposits and refunds are not expenses. Change the original expense by hand if needed.,Insättningar och återbetalningar är inga utgifter. Ändra den ursprungliga utgiften för hand vid behov.,module=cards; S-23 sheet of a credit (CP-8); K2
+cards_sheet_foreign_note,The amount is in %1$s: type the amount in kronor.,Beloppet är i %1$s: skriv beloppet i kronor.,module=cards; S-23 sheet above Skapa utgift; %1$s ISO 4217 currency; K2
+cards_snackbar_linked,Linked,Kopplad,module=cards; S-23 snackbar with common_undo; K2
+cards_snackbar_ignored,Ignored,Ignorerad,module=cards; S-23 snackbar with common_undo; K2
+cards_connection_settings,Card connection,Kortanslutning,"module=cards; TalkBack click label of S-23's header card, which opens S-26; K2 addition; K2"
+cards_connect_title,Connect card,Anslut kort,module=cards; S-24 app bar; K2
+cards_connect_intro,Choose your card. Kvitt reads your purchases but can never pay or move money.,Välj ditt kort. Kvitt läser dina köp men kan aldrig betala eller flytta pengar.,module=cards; S-24 intro; K2
+cards_connect_offline_body,The list of cards comes from the server. Connect the phone to the internet.,Listan över kort hämtas från servern. Anslut telefonen till internet.,module=cards; S-24 offline banner body under common_needs_connection; K2 addition; K2
+cards_provider_connected,Connected · valid until %1$s,Ansluten · giltig till %1$s,module=cards; S-24 tile of a connected card; %1$s list date; K2
+cards_provider_unavailable,Not set up on the server,Inte konfigurerat på servern,module=cards; S-24 tile of a provider without an application (CP-13); K2
+cards_history_label,Fetch purchases from the last,Hämta köp från de senaste,module=cards; S-25 history choice (CP-3); K2
+cards_history_days:one,%1$d day,%1$d dag,module=cards; plural one; S-25 and S-26 options; K2
+cards_history_days:other,%1$d days,%1$d dagar,module=cards; plural other; K2
+cards_history_note,You can shorten this later. A longer period needs BankID again.,Du kan korta perioden senare. En längre period kräver BankID igen.,module=cards; S-25 under the choice; K2
+cards_connect_bankid,Connect with BankID,Anslut med BankID,module=cards; S-25 button (E-37); K2
+cards_waiting,Waiting for BankID…,Väntar på BankID…,module=cards; S-25 while polling E-38; K2
+cards_waiting_body,"Finish in the browser and in BankID, then come back here.",Slutför i webbläsaren och i BankID och kom sedan tillbaka hit.,module=cards; S-25 waiting; K2
+cards_connected,Card connected,Kortet är anslutet,"module=cards; S-25 connected, then S-23; K2"
+cards_connect_failed,The card could not be connected,Det gick inte att ansluta kortet,"module=cards; S-25 failed, with common_retry; K2"
+cards_connect_timeout,The connection attempt took too long,Anslutningsförsöket tog för lång tid,"module=cards; S-25 attempt expired, with common_retry; K2"
+cards_status_active,Connected,Ansluten,module=cards; S-26 status line; K2
+cards_status_expired,Ended,Har upphört,module=cards; S-26 status line; K2
+cards_valid_until,Valid until %1$s,Giltig till %1$s,module=cards; S-26; %1$s detail date; K2
+cards_last_updated,Last updated %1$s,Senast uppdaterad %1$s,module=cards; S-26; %1$s §9.8 relative time; K2
+cards_history_setting,History when connecting,Historik vid anslutning,"module=cards; S-26 (E-41, shorter only); K2"
+cards_history_longer_note,A longer period needs BankID again,En längre period kräver BankID igen,module=cards; S-26 under the history setting; K2
+cards_disconnect,Disconnect,Koppla från,module=cards; S-26 button and dialog confirm (CP-12); K2
+cards_disconnect_title,Disconnect %1$s?,Koppla från %1$s?,module=cards; S-26 dialog title; %1$s card name; K2
+cards_disconnect_body,Purchases you have not linked or ignored are deleted. Expenses you created stay.,Köp som du inte har kopplat eller ignorerat raderas. Utgifter du har skapat finns kvar.,module=cards; S-26 dialog body; K2
+cards_disconnect_erase_all,Also delete linked and ignored purchases,Radera även kopplade och ignorerade köp,module=cards; S-26 dialog checkbox (erase_handled); K2
+cards_pick_title,From card purchases,Från kortköp,"module=cards; S-27 app bar, the account's name as subtitle; K2"
+cards_pick_empty,No card purchases to handle,Inga kortköp att hantera,module=cards; S-27 empty; K2