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