K2: card purchases on Android (Room 2, card cycle, :feature:cards S-23 to S-27)

- :core:model model/Cards.kt per SPEC §14.2.4 "Changes in v0.35"; ContractTest covers the card wire values.
- :domain matchCardPurchase (§14.5.8), passing match_card_purchase.json (17 cases) in VectorTest.
- :data Room version 2 (card_connections, card_purchases, card_sync_state, MIGRATION_1_2 with its test,
  2.json exported); DTOs for E-36 to E-44 round-tripping K1's examples; the card cycle (E-44 push of
  pending link changes, E-40 pull) after each household cycle and after refresh; CardRepositoryImpl with
  CP-9 prefill, CP-10 matches, offline-first link/unlink/ignore; a local expense delete unlinks.
- :core:fakes FakeCardRepository and fakes/cards scenarios for every state of S-23 to S-27.
- :feature:cards S-23 (sheet and "Välj en annan utgift"), S-24, S-25 (Custom Tab, E-38 every 2 s while
  resumed, return link), S-26 (disconnect dialog), S-27, CardArt with the SJ Prio picture and the drawn
  fallback; 135 goldens and state-holder tests.
- :feature:expense S-7 cardPurchaseId prefill and link on save, S-8 "Från kortköp" line;
  :feature:account S-5 "Från kortköp"; :feature:settings S-13 "Anslut kort".
- :core:design KvittDestination.Cards with credit_card and nav_cards (four destinations, D-172); the
  bar's labels shrink to fit at large font scales. :app routes, kvitt://cards/connected, bindings.
- docs/ux/strings.csv: the card keys of §13.3 plus K2's additions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
2026-10-24 16:49:47 +02:00
parent 07df30d04d
commit a8f91ee6ea
391 changed files with 6889 additions and 54 deletions
@@ -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,
@@ -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),
@@ -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
}
@@ -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
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Material Symbols Rounded "credit_card", FILL 0, wght 500, GRAD 0, opsz 24 (Google, Apache License 2.0). Generated from the SVG; do not edit. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<group android:translateY="960">
<path
android:fillColor="@android:color/black"
android:pathData="M888.13-717.13v474.26q0 37.78-26.61 64.39t-64.39 26.61H162.87q-37.78 0-64.39-26.61t-26.61-64.39v-474.26q0-37.78 26.61-64.39t64.39-26.61h634.26q37.78 0 64.39 26.61t26.61 64.39Zm-725.26 76.41h634.26v-76.41H162.87v76.41Zm0 160v237.85h634.26v-237.85H162.87Zm0 237.85v-474.26 474.26Z" />
</group>
</vector>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Material Symbols Rounded "credit_card", FILL 1, wght 600, GRAD 0, opsz 24 (Google, Apache License 2.0). Generated from the SVG; do not edit. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<group android:translateY="960">
<path
android:fillColor="@android:color/black"
android:pathData="M166.78-140.78q-44.3 0-75.15-30.85-30.85-30.85-30.85-75.15v-466.44q0-44.3 30.85-75.15 30.85-30.85 75.15-30.85h626.44q44.3 0 75.15 30.85 30.85 30.85 30.85 75.15v466.44q0 44.3-30.85 75.15-30.85 30.85-75.15 30.85H166.78Zm0-340.92h626.44v-160H166.78v160Z" />
</group>
</vector>
@@ -61,6 +61,7 @@
<!-- TalkBack of the date chip in S-7 and S-9 -->
<string name="common_date_spoken">Datum: %1$s</string>
<string name="nav_home">Översikt</string>
<string name="nav_cards">Kortköp</string>
<string name="nav_analytics">Statistik</string>
<string name="nav_settings">Inställningar</string>
<!-- TalkBack of the navigation bar -->
@@ -61,6 +61,7 @@
<!-- TalkBack of the date chip in S-7 and S-9 -->
<string name="common_date_spoken">Date: %1$s</string>
<string name="nav_home">Home</string>
<string name="nav_cards">Card purchases</string>
<string name="nav_analytics">Analytics</string>
<string name="nav_settings">Settings</string>
<!-- TalkBack of the navigation bar -->
@@ -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)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

After

Width:  |  Height:  |  Size: 11 KiB