Files
kvitt/android
jacobh2 a8f91ee6ea 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
2026-10-24 16:51:12 +02:00
..
…

Kvitt Android project

One Gradle project, modules as in SPEC §6.2 and §14.7. Build only through tools/gradle-serial.sh with PATH=/opt/homebrew/opt/openjdk@25/bin:$PATH (CLAUDE.md › Build and verify). The gate is tools/gradle-serial.sh check: compile, unit tests (including every vector of contracts/test-vectors/), lint (MissingTranslation and ExtraTranslation are errors), the architecture check and the screenshot tests.

Public host (D-121). The release server's host name is the build property kvitt.publicHost, never a literal in the code: :app exposes it as BuildConfig.PUBLIC_HOST, as the manifest placeholder publicHost (the host of the https://<host>/i/<CODE> invite links, AC-2.6) and as RELEASE_SERVER_URL = https://<host>, which S-1 prefills (AC-2.7). Gradle takes -Pkvitt.publicHost=… first (infra/publish-apk.sh passes it), else the key kvitt.publicHost in android/local.properties, where the owner sets it (git-ignored, never committed), else the placeholder kvitt.example.invalid, so a release built without it cannot talk to a real host. Debug builds pair with the address typed on S-1. Tests and goldens never depend on it: they use fixed hosts.

App key (D-126). Outside the home LAN the reverse proxy closes every request that does not carry the header X-Kvitt-App: <key>; the server never reads it, and the per-device bearer token stays the authentication. The key is the build property kvitt.appKey: :app exposes it as BuildConfig.APP_KEY, passed through AppBuildInfo.appKey to KvittApi and the picture client (PictureAuth), which send it on every request to the server, /health and the other unauthenticated calls included. Gradle takes -Pkvitt.appKey=… first, else the key kvitt.appKey in android/local.properties, where the owner sets it (git-ignored, never committed), else empty: no header, which is what debug builds against the dev server need. A key that is set must be 16 to 128 printable ASCII characters without whitespace, or the build fails (the message names its length, never the value). It is a secret: never logged, never in a test, golden or report; tests use a made-up key.

Modules

Module Plugin (build-logic) Notes
:core:model kvitt.jvm.library UI contract (§14.2.4), domain types model.money (§14.5.1), analytics types model.analytics (§14.5.4)
:domain kvitt.jvm.library §14.5 functions; VectorTest runs every vector file, copied by :domain:copyTestVectors
:core:design kvitt.android.library + kvitt.android.compose + kvitt.android.screenshots KvittTheme, tokens, shared components, Canvas charts, formatters, shared failure texts and limits, shared strings (SPEC §6.2); its test fixtures are the shared test support (below)
:core:fakes kvitt.android.library FakeRepositories(scenario), FakeScenarios.empty; scenarios of feature X go in fakes/<x>/
:data kvitt.android.library + kvitt.android.hilt + KSP + Room 3 Room entities and KvittDatabase, schema in data/schemas/, Ktor client, sync engine, repositories, updater
:adapter:fcm kvitt.android.library + kvitt.android.hilt Firebase Cloud Messaging: the service, token calls and the mapping to PushEvent
:feature:* kvitt.android.feature the screens; goldens in feature/<x>/src/test/screenshots/ (full screen 411 × 891 dp at 1x, D-098); top-level screens draw their own KvittNavigationBar (SPEC §13.2, D-071)
:app kvitt.android.application + compose + hilt + screenshots Application, MainActivity, NavHost, push and notifications; both variants bind :data (DataBindingsModule through di/AppDataModule.kt, D-081); goldens in app/src/test/screenshots/

Architecture check

architectureCheck runs in every module's check (build-logic/.../ArchitectureRules.kt holds the rules as data). It fails on a project dependency outside the §6.2 table, on :core:fakes in any configuration that is not a test or debug configuration, on a module's test fixtures outside a test configuration, and, in :core:model and :domain, on an Android plugin, on a library outside org.jetbrains.kotlin*, and on any import or fully qualified reference outside kotlin.*, kotlinx.* and se.hagstedt.kvitt.* in src/main. A new module must be added to ArchitectureRules.allowed.

Feature convention

  • Each feature module exposes one composable entry point per screen. By default it obtains its ViewModel with hiltViewModel() (from androidx.hilt:hilt-lifecycle-viewmodel-compose) and takes navigation as plain lambdas, for example:

    @Composable
    fun AccountDetailScreen(
        onOpenExpense: (ExpenseId) -> Unit,
        onAddExpense: (AccountId) -> Unit,
        onBack: () -> Unit,
        modifier: Modifier = Modifier,
        viewModel: AccountDetailViewModel = hiltViewModel(),
    )
    

    A stateless overload that takes the *State and event lambdas is what previews and screenshot tests render.

  • ViewModels are @HiltViewModel classes that receive repositories through the interfaces of :core:model only. :app binds those interfaces to :data with Hilt in both variants; tests and previews use :core:fakes.

  • Features never reference another feature, the NavHost or :data/:domain. :app owns the NavHost and wires the lambdas.

  • Strings live in the feature's own src/main/res/values/strings.xml and values-sv/strings.xml, keys prefixed with the feature name; shared strings in :core:design; :app holds only the app name.

  • Previews and screenshot tests use FakeRepositories(scenario) from :core:fakes (debugImplementation and testImplementation, added by the feature plugin). Fakes never reach a release build.

Screenshot tests

Roborazzi on Robolectric, wired by kvitt.android.screenshots (the plugin, outputDir src/test/screenshots and the test libraries; the feature plugin applies it). The harness is shared: :core:design's test fixtures (src/testFixtures, package se.hagstedt.kvitt.design.testing, testImplementation(testFixtures(project(":core:design"))), added to every feature by its plugin) hold Variant, TestClock, TestEnvironment, ScreenSnapshots (capture modes Root, Screen and ScreenWhenIdle), MainDispatcherRule and withMain. A module adds only its own frame around the content (AnalyticsSnapshots, SettleSnapshots, SettingsSnapshots, :core:design's Snapshots). roborazzi.test.verify=true in gradle.properties makes every unit-test run compare with the committed goldens. Re-record an intended change with tools/gradle-serial.sh recordRoborazziDebug and open every changed PNG before committing.

Golden layout (D-098, revising D-066): three variants per state, <name>_sv_light.png, <name>_sv_dark.png and <name>_en_light.png (English covers string length, dark covers the theme; there is no en_dark), plus <name>_sv_light_200.png at 200 % font scale for the long states. Every capture renders at 1x density: the Robolectric qualifier is w411dp-h<height>dp-mdpi, so the Galaxy S26 viewport of 411 × 891 dp is 411 × 891 px and a golden is a true 1x render, not a downscaled one. :app's empty_screen_<light|dark>.png are the only other names.