ad3523116f
D-124 serves Kvitt at https://<host> on 443. The README, the AppBuildInfo and AboutRepositoryImpl comments, the S-21/S-22 fake scenarios and the :data test fixtures drop the :8443 port. S-21's invite-created goldens and every S-22 golden re-recorded (link and server row now portless). The release-URL normaliser test checks a portless URL and keeps one explicit-port case (a typed port survives), on example.org instead of the owner's old host. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
80 lines
6.2 KiB
Markdown
80 lines
6.2 KiB
Markdown
# 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.
|
||
|
||
## 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:
|
||
|
||
```kotlin
|
||
@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.
|