Skip to content

Release: develop -> main#840

Open
github-actions[bot] wants to merge 53 commits into
mainfrom
develop
Open

Release: develop -> main#840
github-actions[bot] wants to merge 53 commits into
mainfrom
develop

Conversation

@github-actions

Copy link
Copy Markdown
Contributor

Automatic Release PR

This PR was automatically created after changes were pushed to develop.

Commits: 32 new commit(s)

Checklist

  • Review all changes
  • Verify CI passes
  • Approve and merge when ready for production

… pin gate edges

- Docs: `emailConfirmed == null` means only a pre-rollout backend or no
  registration; grandfathered accounts report an explicit `true`. Corrects the
  DTO field doc, the KycCubit and KycConfirmEmailCubit comments, and the
  KycCubit test comments so they no longer equate `null` with grandfathered.
- Parse `confirmedDate` with `DateTime.parse` (fail loud) instead of
  `DateTime.tryParse`, matching the repo convention: absent/null still maps to
  null, a present but unparseable value now throws. Updates the DTO test to
  expect a `FormatException`.
- Add a KycCubit regression test pinning that `emailConfirmed == false` in the
  `AddWallet` state does not gate to the confirm step (the flag describes the
  other wallet's registration), and document why the gate is scoped to
  `AlreadyRegistered`.
- Add a KycConfirmEmailCubit regression test pinning that a late response from a
  superseded `recheck()` cannot overwrite the fresh state of a newer one.
- docs/screens.md: list the KycConfirmEmailPage KYC step.
Error, loaded open ticket (customer/support bubbles + input field), open sending (disabled field + spinner) and closed ticket (closed banner) states.
Filled form (type tag selected, send button enabled) and submitting (send button loading spinner) states.
Buy-flow entry point rendering the alternate description copy passed by the buy-confirm gate.
Loading (centered spinner), error (centered failure text) and loaded (open/closed ticket tiles with status dots) states.
…e runtime offset

`_formatTime` added `DateTime.now().timeZoneOffset` to the message's UTC
`created` instant, applying the *current* offset to a historic timestamp.
Across a DST boundary this shows the wrong time (a March message rendered
in July gained an extra hour) and, because the offset depends on when the
widget renders, the chat goldens would drift at every DST switch and turn
CI red. Use `date.toLocal()`, which converts the fixed UTC instant using
the zone rules of the message's own date — render-time-stable and
DST-correct.

Regenerated the three affected chat goldens (loaded/sending/closed): the
March 2024 fixture instants now render as 09:15/09:42 CET instead of the
previously captured 10:15/10:42 (the buggy summer offset).
test(goldens): PIN + onboarding state baselines (#816)
test(goldens): support flow state baselines (#816)
…followup

fix(kyc): follow-up to #808 — contract docs, fail-loud date parse, gate-edge tests
Part of #816 (state-coverage backlog). Adds 21 golden baselines across
the settings screens; 2 states deferred with reasons.

## Added — 21 baselines
- **settings_user_data** (8): editable (all edit buttons), pending
("change in review" badges), no-birthday, email-only, empty, loading,
bitbox-disconnected, failure.
- **settings_currencies / settings_languages** (2 each): loading, error
(error view + retry).
- **settings_network** (1): switching (spinner on the tapped mode).
- **settings_security** (4): biometrics-disabled, no-biometrics (toggle
hidden), busy (spinner instead of switch), error-snackbar.
- **settings_seed** (1): loading (spinner instead of seed card).
- **settings_tax_report** (2): failure-snackbar, date-picker (Material
overlay; clock pinned to 31.12.2025).
- **settings** (1): confirm-logout sheet with the checkbox ticked (reset
enabled).

## Deferred (2) — documented
- **settings release variant** (network tile hidden): gated on
`kDebugMode`, a compile-time `true` under `flutter test`, not forceable
from a test.
- **settings_security "PIN changed" success snackbar**: fired only from
the `_onPinChanged` navigation callback; no state-driven path,
hand-faking it would be a hack.

## Notes for review
- The **snackbar goldens** (security error, tax-report failure) render
the fully-visible snackbar via a real BlocListener state emission; the
full suite passed green on the CI-identical toolchain (no pending-timer
failure). The tax-report date-picker pins the clock to 31.12.2025 for
determinism.

## Verification
Generated on the CI-identical toolchain (Flutter 3.41.6), two
byte-identical `--update-goldens` runs. Full suite green (2807),
`flutter analyze` clean, no existing golden changed (+21 PNG, 8 new test
files following the #818 `*_states_golden_test.dart` convention). No
handbook touch, count-guard (61) unaffected.
… concurrency + kyc routing branches (test-only, no behaviour change) (#823)

Test-only. No `lib/**` change, no behaviour change. Follows the
now-merged #808 and closes coverage gaps surfaced by a post-merge audit
of the registration -> confirm-email flow.

The confirm-email feature itself was already fully covered; these tests
close the surrounding gaps (the pre-existing registration form-widget
interaction layer, the confirm-email concurrency guards, and a few KYC
routing branches).

## What is covered

- **Registration personal step** (`kyc_registration_personal_step.dart`)
- new widget-interaction test file. First/last-name validators (empty /
non-SwissPaymentText / valid), account-type dropdown `onChanged`, the
"next" button `onPressed` -> validate ->
`KycRegistrationStepCubit.next()`, and the tap-to-dismiss-keyboard
gesture. Line coverage 36/50 -> **50/50**.
- **KycPageManager** (`kyc_page_manager.dart`) - the DI wrapper
(`KycCubit` built from `getIt` + `checkKyc(context:)`), the
`KycUnsupportedStepFailure` message arm, and the
`LegalDisclaimerPage.onCompleted` callback
(`markLegalDisclaimerAccepted` + `checkKyc`). Line coverage 23/35 ->
**35/35**.
- **Confirm-email concurrency guards** (`kyc_confirm_email_cubit.dart`)
- `recheck()` after `close()` (isClosed guard), plus two overlapping
`recheck()` calls so a superseded continuation bails on the stale
generation (success path and catch path). Line coverage was already
100%; these pin the guard `return` branches that the happy-path tests
execute but never take.
- **`_mapStepName` routing arms** (`kyc_cubit.dart`) - per-arm input
tests for `contactData -> registration`, `nationalityData ->
nationality`, `financialData -> financialData`. The switch-expression
arms already show a line hit via coarse instrumentation, so these assert
the mapping by driving `_continueKyc` with each step name (input-based,
not just a line hit).

Items from the audit that were already covered by #810's tax-residence
tests (`_onSubmit`, the `initialUserData` constructor prefill, the
address-step validators) are already at 100% and needed no new tests.

## Verification (on m5me, Flutter 3.41.6)

- Affected suite + full `flutter test --coverage` green; existing
goldens pass byte-identical (no baseline change).
- Per-file line coverage re-measured before/after; each listed gap now
covered.
…ead of the dashboard (#827)

## Problem

Starting a KYC flow (e.g. from Buy/Sell) pushes `/kyc` imperatively and
builds a
page-scoped `KycCubit`. Leaving the app and coming back then dropped the
user on
the **dashboard** instead of the KYC step they were on. The most visible
case is
the new confirm-email step: the user opens the confirmation mail, taps
the
website's "Back to the app" button (`realunit-wallet://open`), and finds
the
dashboard rather than the flow continuing.

## Root cause — three independent paths, one shared blind spot

`routerConfig.routerDelegate.currentConfiguration.uri` does **not**
reflect
imperatively pushed routes (go_router 14.x): after `pushNamed('/kyc')`
it still
reports the base route underneath (`/dashboard`). Every consumer of that
value
judged "where the user is" wrong for pushed flows:

1. **Warm scheme open (any background duration).** The scheme redirect's
"stay where you were" contract returned that location as a no-op.
go_router
applies a redirect result as a `go`, which **replaces the whole match
list**
— the pushed `/kyc` route was dropped together with its page-scoped
cubit,
   its `extra`, and the back stack. Pinned red-first by the new
   `app_link_entry_test.dart` case before the fix landed.
2. **PIN re-lock (>= 5 min background).** `_navigate()` is a boot state
machine
with no notion of the in-flight route; after re-lock + PIN entry it
landed on
`goNamed(dashboard)` unconditionally. Fixed by the capture/restore
machinery
(`resolveBootNavigation`), but the capture initially read the same blind
   source — a pushed `/kyc` was captured as `/dashboard`.
3. **Warm balance emission.** Any `HomeBloc` emission on resume re-ran
`_navigate()` into the unconditional dashboard branch. Fixed by
`BootNavStay`
   (an active non-gate route is never clobbered).

## Fix

- **New pure function** `resolveBootNavigation` + sealed `BootNavAction`
(`lib/setup/routing/boot_navigation.dart`): the whole gate ladder
extracted as
  a `getIt`/`go_router`-free decision, exhaustively unit-tested.
- **Restore allowlist** `restorableLocations` (fail-closed): only routes
that
rebuild from a bare path and sit behind no secondary gate are ever
restored;
everything else falls back to the dashboard. A restore can only be
returned by
the final ladder branch, after the PIN gate has already diverted — **the
PIN
  gate is never bypassed.**
- **Push-aware location source** `effectiveLocation(RouteMatchList)`:
resolves
the last `ImperativeRouteMatch` when present. Used by the boot machine's
`currentLocation`, the background capture in `lifecycle_initializer`,
and the
  scheme redirect wiring — a pushed flow is no longer invisible.
- **True no-op for warm scheme opens** — canonical path-less open only:
the
redirect returns `null` for a warm `realunit-wallet://open` so the URL
stays
unmatched, and a new `onException` handler keeps the current
configuration
  untouched (go_router skips the delegate update when `onException` is
installed). This is the only variant that survives pushed routes —
returning
*any* location string would replace the match list. Scheme URLs
**carrying a
path** are rewritten to the canonical path-less open: go_router matches
on
`uri.path` alone, so a crafted `realunit-wallet://open/settings/seed`
would
otherwise match and navigate straight past flow-level gates — and
pinning the
  current location instead would rebuild a pushed `extra`-required route
(`/buyPaymentDetails`, …) with a null `extra` and crash its builder
cast. The
rewritten URL resolves to an unmatched (error) match list and go_router
short-circuits before any further redirect pass, ending in the same
no-op.
  Cold start keeps the existing `/home` handoff. Non-scheme match
failures now `assert` in debug instead of showing go_router's error
screen;
  in release the user stays on the current screen.
- **Restores rebuild the real entry shape**: dashboard as base + the
flow
pushed on top (a bare `go` would strand the restored route as the only
match
— pop-based exits like the KYC "Close" button or the AppBar auto-back
would
  be dead). Restoring `/dashboard` itself stays a plain `go`.
- **Capture hygiene**: `PinAuthCubit.onAppHidden(location)` arms the
timeout
once per background episode (`??=`) while the resume location takes the
freshest non-null value; gate locations are captured as `null` so a
nested
re-lock keeps the earlier in-flight capture; the capture is cleared once
  spent or once a final landing is reached; and an episode that ends
**without** a re-lock drops its capture eagerly — a much later unrelated
  re-lock can never restore a route from a long-finished episode.

## Known limitation

Restores replay the **path** only, not `state.extra` — for KYC that
means the
`kycContext` from Buy/Sell is not restored. Irrelevant for the
confirm-email
resume: `checkKyc()` without a context yields the same "email already
confirmed
-> advance" path. Extra-requiring routes are excluded from the allowlist
entirely (they would crash on a bare-path rebuild — covered by a premise
test).

## Tests

- `test/setup/routing/app_link_entry_test.dart` — the pushed-route
scheme-open
case (red before the fix): pushed page survives the open **and** can
still
pop back to its base route (guards against match-list-replacing
"fixes");
  warm crafted-scheme URLs in both forms — host form
  (`realunit-wallet://dashboard`) and path form
(`realunit-wallet://open/settings`) — do not navigate, including over a
pushed `extra`-required route (no navigation, no builder-cast crash);
all
  existing warm/cold cases unchanged and green.
- `test/setup/routing/boot_navigation_test.dart` — exhaustive table for
the
gate ladder, allowlist, and restore/stay/fallback semantics, plus a
drift pin
asserting every gate/restorable location is a real `router_config` path.
- `test/setup/routing/boot_navigation_apply_test.dart` — the real-router
seam:
re-lock -> PIN -> restore lands on `/kyc` **with the dashboard
underneath**
  (canPop + pop back works; `/dashboard` restore stays a plain go); an
`extra`-required route falls back to the dashboard without throwing
(plus the
premise guard that it really throws); `effectiveLocation` reports the
pushed
  route where the raw uri stays on the base.
- `test/screens/pin/pin_auth_cubit_test.dart` — capture semantics with a
fake
clock: freshest-non-null capture, gate-capture keeps the in-flight
route,
eager clear when an episode ends without a re-lock vs. kept while the
PIN
  gate is showing, peek/clear/reset.
- `test/setup/lifecycle_initializer_test.dart` — lock/idempotency
behaviour
  with the new capture signature.

`flutter analyze` -> no issues in changed code. kyc/pin/home golden
suites
unchanged (no UI change).
Part of #816 (state-coverage backlog) — KYC email + financial-data
screens. 14 baselines, 1 documented skip.

## Added — 14 baselines
- **kyc_email** (2): error snackbar `does_not_match` (localized
`registerEmailDoesNotMatch`), error snackbar `unknown` (backend
`state.message`).
- **kyc_email_verification** (3): loading (software, no hint),
loading-bitbox (`isLoading && isBitbox` hint text), error snackbar.
- **kyc_financial_data** (2): submit-failure (questions retained + red
snackbar), fallback (empty scaffold for initial/submit-success).
- **kyc_financial_data_questions** (7): checkbox, single-choice,
multiple-choice, link-description (tnc → blue/underlined),
no-description, answered (button enabled), not-last (button "Weiter" +
"Frage 1 von 3").

## Deferred (1)
- **kyc_email inline validation**: `TextFormField` with no
autovalidateMode; `Form.validate()` only runs on the Next-button tap
(interaction-driven), no state/autovalidate seam.

## Notes
- The existing handbook-mapped
`kyc_email_page_{loading,does_not_match,unknown_error}.png`
(snackbar-less, part of the 61-count guard) are untouched; the new
snackbar goldens carry distinct names (`…_error_snackbar_…`).
- Snackbars via the #822 pump/pumpAndSettle pattern; loading/SVG via
fixed frame-pumps (the activity indicator never settles); question
fixtures are top-level consts (no now()/random).
- #823 (merged) is test-only, touches no target page or its goldens — no
conflict.

## Verification
Generated on the CI-identical toolchain (Flutter 3.41.6), two
byte-identical `--update-goldens` runs. Golden suite green (+204
compare), unit suite green (+2663), `flutter analyze` clean, no existing
golden changed (+14 PNG, 4 new test files). No handbook touch,
count-guard (61) unaffected.
Part of #816 (state-coverage backlog) — KYC registration wizard. 11
baselines, 1 documented skip.

## Added — 11 baselines
- **kyc_registration_page** (7): address-step active, tax-step active,
prefilled form, submit-loading overlay, submit-failure snackbar
(signingCancelled), forwarding-failed snackbar, bitbox-required bottom
sheet.
- **kyc_registration_personal_step** (3): validation-error (red
borders), account-type dropdown open, phone-prefix dropdown open.
- **kyc_registration_address_step** (1): validation-error.

## Deferred (1)
- **personal-step birthday dropdown open**: the year list is generated
from `DateTime.now().year` (birthday_field.dart), so an open year
dropdown is non-deterministic; the day/month sub-dropdowns are static
but redundant with the already-covered dropdown-menu pattern.

## Notes
- #823 (merged) added only behaviour tests under `test/screens/kyc/` (no
goldens) — complementary, no overlap/duplication.
- Step-active via `jumpToPage(state.index)` (the widget-test seam);
overlays/sheets via state + `pumpBeforeTest`; snackbars via the #822
pump/pumpAndSettle pattern; validation via a deterministic
`pumpBeforeTest` tap on "Weiter".
- The prefilled fixture uses birth year 1815 (Ada Lovelace), which falls
outside the selectable year range → the year sub-field renders empty.
Deterministic, but flagged for review (a within-range year would show a
filled year).

## Verification
Generated on the CI-identical toolchain (Flutter 3.41.6), two
byte-identical `--update-goldens` runs. Full suite green (2864),
`flutter analyze` clean, no existing golden changed (+11 PNG, 3 new test
files). No handbook touch, count-guard (61) unaffected.
Part of #816 (state-coverage backlog) — KYC status/verification screens.
17 baselines, 1 documented skip.

## Added — 17 baselines
- **kyc_2fa** (4): verify-loading, resend-loading ("sending…",
disabled), verify-failure snackbar (`twoFaWrongCode`), send-code-failure
snackbar (`twoFaSendCodeFailed`).
- **kyc_ident** (3): loading, finally-rejected (button permanently
disabled + `identityCheckFinallyFailed` snackbar), error
(`identityCheckFailed` snackbar, idle body).
- **kyc_link_wallet** (4): submitting, success (centered spinner),
failure (spinner + `registrationFailed` snackbar), missing-user-data
(`_LinkWalletMissingUserDataPage`).
- **kyc_nationality** (6): submit-loading, submit-failure snackbar,
CountryField loading, CountryField error (`countriesLoadFailed` +
retry), dropdown-open (CH/DE/IT/FR prioritised), empty-selection
validation (red border only, matching the existing
`kyc_registration_tax_step_country_error` precedent).

## Deferred (1)
- **kyc_2fa code-field validation error**: interaction-driven
`Form.validate()` with no autovalidate/state seam.

## Notes
- Snackbars rendered via a real `BlocListener` state transition +
`pump()`/`pumpAndSettle()` (or a fixed pump past the 250ms entrance
where a spinner co-exists) — the #822 technique; the pending
auto-dismiss timer doesn't fail the suite.
- Country data flows through `country_fixture`
(`fixtureCountryService`/`failingCountryService`/a `Completer`-gated
MockClient for the field spinner), never a mocktail stub.
- #823 (merged) touches only routing/cubit unit tests for these screens,
no goldens — no conflict.

## Verification
Generated on the CI-identical toolchain (Flutter 3.41.6), two
byte-identical `--update-goldens` runs. Full suite green (2870),
`flutter analyze` clean, no existing golden changed (+17 PNG, 4 new test
files). No handbook touch, count-guard (61) unaffected.
## What

Two related commits:

1. **`docs(store)`** — populate the previously-empty
`ios/fastlane/metadata/de-DE/promotional_text.txt`:
> Kaufe, halte und verkaufe RealUnit Tokens sicher mit der RealUnit App

   (69 chars — within the 170-char App Store limit)
2. **`docs(handbook)`** — mirror that Promotional Text in the handbook
store-listing (generator ctx + template + regenerated
`docs/handbook/de/index.html`), between subtitle and description to
match App Store Connect ordering.

## Why

`promotional_text.txt` has been 0 bytes since #644, so `fastlane
deliver` loaded it on every run and pushed an **empty** Promotional Text
to App Store Connect — and with `force: true` it also overwrote any
value entered manually in ASC (same overwrite mechanism previously seen
with the release notes). Commit 1 fixes the source; commit 2 keeps the
handbook a faithful, complete mirror of what ships to the stores (it
previously omitted this one field).

## Notes

- Promotional Text is not version-locked in App Store Connect, so
`store-metadata.yaml` (main push, metadata paths) transmits it without
needing a new app version.
- Aside found during audit:
`android/fastlane/metadata/android/de-DE/video.txt` is also empty (Play
promo-video URL) — most likely intentional (no video); left untouched.
… the misleading hint (#825)

## Problem

The shared KYC country picker (`lib/widgets/form/country_field.dart` +
`lib/widgets/form/dropdown_field.dart`, used in 5 places: nationality,
registration personal step, address step, tax-residence step, and
Settings -> Address) showed three independent, long-standing defects:

1. **Misleading hint (H1).** The placeholder text was itself a country
name (`"Schweiz"` / `"Switzerland"`). Because it is only a hint (initial
value is `null`), it looked like a pre-selected country when in fact
nothing was chosen.
2. **English item labels (H2).** The list items render `country.name`,
the English API name (`"Switzerland"`, `"Italy"`, ...).
3. **Stale validation error (H3) — the actual blocker.** After the user
pressed "Next" once (`Form.validate()`), an empty selection produced an
(invisible) error string and a red border. Even after the user then
picked a valid country, the red border stayed, because
`DropdownButtonFormField` re-validates on change only when an
`autovalidateMode` is set.

## Root cause (per observation)

- **H1:** `countryHint` in the ARB files was literally a country name,
so a pure placeholder reads as a selection.
- **H2:** items map to `country.name` (English). Note:
`country.foreignName` is **not** a German localization — it is the
*endonym* (CH = Schweiz, but IT = Italia, FR = France, US = United
States). Swapping in `foreignName` would be wrong for every non-DE
country.
- **H3:** `DropdownButtonFormField` in `dropdown_field.dart` had no
`autovalidateMode` (default: disabled). The validator returns an empty
error string for an empty selection -> red border; without
`onUserInteraction` re-validation, `didChange` on a later valid pick
never clears it.

## What this fixes

- **H3:** `DropdownField` now forwards an optional `autovalidateMode`
(default `null`, so every other `DropdownField` consumer is unchanged).
`CountryField` opts in with `AutovalidateMode.onUserInteraction`, so
selecting a country clears the stale error immediately.
- **H1:** `countryHint` is neutralized to `"Land auswählen"` / `"Select
country"`.

## Deliberately NOT fixed

- **H2 stays as-is.** Because `foreignName` is the endonym, not a German
label, it is not a correct display source. Real country i18n is a
separate concern and is out of scope here.

## Affected surfaces (all 5 usage sites)

nationality, registration personal step, address step, tax-residence
step, Settings -> Address.

## Tests & goldens

- New regression group in `test/widgets/form/country_field_test.dart`
pinning H3: an untouched field reports an error once the Form is
validated, and picking `Switzerland` afterwards clears `hasError` (value
`symbol == 'CH'`) without a second `Form.validate()`. Verified the test
fails if the `autovalidateMode` line is removed.
- Regenerated only the 7 goldens that render the empty country field
(Flutter 3.41.6, byte-identical to CI). Each diff is the same small
~0.27% / 896px region = the hint text only (the tax-step country-error
golden also just reflects the changed hint inside the red field). No
unexpected image changes.
- `flutter analyze`: 0 issues. `flutter test
test/widgets/form/country_field_test.dart`: all pass.
… hint (#825) (#832)

## Problem

`staging` Visual Regression is **red**: #825 ("neutralize the misleading
hint") changed the country-field `countryHint` from
"Schweiz"/"Switzerland" to "Land auswählen"/"Select country" and
regenerated the **default** country goldens, but the **state** goldens
added by #828 (nationality) and #829 (registration wizard) — which
render the empty country field with the old hint — were merged around
the same time and were not regenerated for #825's change. On current
staging they still show "Schweiz", so they no longer match the rendered
UI.

## Fix

Regenerated exactly the 11 stale state goldens against current `staging`
(with #825's new hint). No test/lib code changed — only the PNG
baselines:

- `kyc_nationality_page_{submit_loading, submit_failure,
validation_error}`
- `kyc_registration_page_{address_step, tax_step,
forwarding_failed_snackbar, submit_failure_snackbar}`
- `kyc_registration_personal_step_{account_type_open, phone_prefix_open,
validation_error}`
- `kyc_registration_address_step_validation_error`

Each now renders the neutral "Land auswählen" placeholder. The other
state goldens in those files (dropdown-open, country-loading,
country-error) don't show the hint and are unchanged.

## Verification

Regenerated on the CI-identical toolchain (Flutter 3.41.6), two
`--update-goldens` runs byte-identical; `git diff` shows exactly these
11 PNGs and nothing else; `flutter analyze` clean; the four affected
golden test files pass against the new baselines. This restores
`staging` to green.
## What

Makes the KYC legal-disclaimer gate server-driven via the new DFX API
`GET`/`PUT /v1/realunit/legal` capability, replacing the per-session
in-memory flag `_legalDisclaimerAccepted` that reset on every KYC entry.

## Why

The disclaimer was gated on a local `KycCubit` field that is `false` on
every fresh cubit, so it re-appeared on every KYC (re)entry (the
reported bug). The API is now the single source of truth for whether the
user still has outstanding agreements to accept — per CONTRIBUTING "API
as Decision Authority".

## How

- `RealUnitLegalService` (`getLegalInfo` / `acceptLegal`) + DTOs + a
`RealUnitLegalAgreement` enum mirror
- `KycCubit` gate: shows the disclaimer only when
`getLegalInfo().allAccepted` is false; disclaimer completion records
acceptance via `PUT` (the outstanding agreements) and re-checks so the
API drives the next routing
- Fail-closed: a **404** means the endpoint is not deployed yet
(pre-rollout) and falls back to the local per-session flag; **any other
error** surfaces as `KycFailure` — no silent fallback on a compliance
gate
- version fields are strings (`YYYYMMDD`)

## ⛔ Blocked — pair PR

Depends on the API PR **DFXswiss/api#4183** (the `/v1/realunit/legal`
endpoint + versioned acceptance store). Keep this as a **draft** until
that merges to `develop` and reaches DEV; opening it ready before then
would have the app hit a 404 and fall through to the local flag.

## Verification (m5me, Flutter 3.41.6)

- `flutter analyze` — No issues found
- `flutter test` (kyc_cubit + kyc_page_manager) — all pass, incl. the
new fail-closed test (non-404 → `KycFailure`), the 404 pre-rollout
fallback test, and the accept/re-check path
- `dart format` clean
Fixes the **Coverage Floor Gate** that went red on the `staging →
develop` promotion (#824) after #831 merged.

The 100% line-coverage floor dropped to 99.9% — exactly two uncovered
lines, both from #831:
- the generic (non-`ApiException`) `catch` in
`KycCubit.acceptLegalDisclaimer` (kyc_cubit.dart:295-296)
- the `fromValue` `ArgumentError` default in `RealUnitLegalAgreement`
(real_unit_legal_agreement.dart:48)

Test-only, no production change:
- cubit test: `acceptLegal` throwing a non-`ApiException` error →
`KycFailure`
- `RealUnitLegalAgreement` round-trip (`value`↔`fromValue` over all six)
+ unknown-value (`ArgumentError`) test

Verified on the build host: `flutter analyze` clean, the affected suites
pass, and coverage of both files is back to 100% (the enum file: LF:15 /
LH:15).
Part of #816 (state-coverage backlog) — buy flow. 11 baselines, no
skips.

**buy_page (7):** confirm-loading, confirm-failed (3 text variants:
aktionariat / amount-too-low / unknown), bitbox-disconnected,
currency-picker-open, currency-load-failed snackbar.
**buy_payment_details (4):** QR available + Details tab, QR tab
(QrImageView), QR tab (SvgPicture.string), purpose-hidden.

All 11 verified visually; snackbars via the #822 pump pattern;
currency-picker via a deterministic pumpBeforeTest tap; QR from fixed
payload/SVG strings. Double-run byte-identical, analyze clean, no
existing golden changed.

> **⚠️ Coverage Floor Gate:** This PR (and every current PR off
`staging`) fails the Coverage Floor Gate at 99.9% vs the 100% floor.
That gap is inherited from recently-merged lib code (e.g. #831 —
boot_navigation/app_link_entry/legal-service), **not** from these
goldens: this PR is test-only (PNG baselines + golden test files) and
can only add coverage. **Visual Regression** and **Analyze & Test** are
the meaningful gates here and are expected green. Not merge-ready until
the coverage floor is restored separately.
…nes (#816) (#835)

Part of #816 (state-coverage backlog) — dashboard / transaction-history
/ receive. 11 baselines, no skips.

**dashboard (5):** price+chart loaded, portfolio-chart header,
pending-transactions section, recent-transactions section,
hidden-amounts (masked).
**transaction_history (5):** list with transactions, per-row receipt
loading, multi-receipt PDF loading, receipt-failure snackbar,
date-picker dialog.
**receive (1):** full-page variant (the actually-routed one).

Determinism handled explicitly: charts use TimePeriod.all (no now());
transaction dates use DateFormat without toLocal()/now() (no TZ risk —
the #820 lesson); the date-picker clock is pinned via package:clock.
Double-run byte-identical, analyze clean, full suite green (2864), no
existing golden changed.

> **⚠️ Coverage Floor Gate:** This PR (and every current PR off
`staging`) fails the Coverage Floor Gate at 99.9% vs the 100% floor.
That gap is inherited from recently-merged lib code (e.g. #831 —
boot_navigation/app_link_entry/legal-service), **not** from these
goldens: this PR is test-only (PNG baselines + golden test files) and
can only add coverage. **Visual Regression** and **Analyze & Test** are
the meaningful gates here and are expected green. Not merge-ready until
the coverage floor is restored separately.
…elines (#816) (#836)

Part of #816 (state-coverage backlog) — connect-bitbox / debug-auth /
legal-document. 13 baselines, no skips.

**connect_bitbox (6):** iOS text variant, pairing, not-initialized,
capturing-signature, connected, connect-failed snackbar.
**debug_auth (5):** sign-message set, error-message set, isLoading
(authenticate), isLoading (sign-message fetch), clipboard snackbar.
**legal_document (2):** loaded with PDF footer, load-error view.

Seam decisions: ConnectBitboxView rendered with a mocked cubit (the #815
pattern, cubit timer never started); iOS variant via
debugDefaultTargetPlatformOverride; clipboard stub for the copy
snackbar. Double-run byte-identical, analyze clean, full suite green
(2866), no existing golden changed.

> **⚠️ Coverage Floor Gate:** This PR (and every current PR off
`staging`) fails the Coverage Floor Gate at 99.9% vs the 100% floor.
That gap is inherited from recently-merged lib code (e.g. #831 —
boot_navigation/app_link_entry/legal-service), **not** from these
goldens: this PR is test-only (PNG baselines + golden test files) and
can only add coverage. **Visual Regression** and **Analyze & Test** are
the meaningful gates here and are expected green. Not merge-ready until
the coverage floor is restored separately.
… is stuck (#833)

## What

When a RealUnit wallet's registration is stuck in manual review — the
Aktionariat forward failed and staff must re-forward it — the API now
(DFXswiss/api#4182, merged) reports `manualReview: true` on
`getRegistrationInfo`, while `state` stays `AlreadyRegistered` and
`emailConfirmed` becomes `true`. Until now the app treated such a wallet
as a completed registration and let the user fall through, so a stuck
onboarding was invisible in the app.

This PR consumes the new flag and renders a dedicated "registration
under review" waiting screen for the stuck case.

## How

- **DTO** (`RealUnitRegistrationInfoDto`): additive, nullable
`manualReview`. `null` (a pre-rollout backend) and `false` proceed
exactly as before; only an explicit `true` routes — the same
legacy-tolerance shape as `emailConfirmed`.
- **Routing** (`KycCubit`): in the `alreadyRegistered` case,
`manualReview == true` emits the new terminal `KycManualReview` state,
checked **before** the e-mail-confirm gate (a stuck registration takes
precedence). No local business inference — the app renders what the API
decides (CONTRIBUTING.md "API as Decision Authority").
- **UI**: new `KycManualReviewPage` mirroring the account-merge waiting
screen (title, description, a Refresh that re-runs `checkKyc()`), wired
into `KycPageManager`. New `kycManualReviewTitle` /
`kycManualReviewDescription` strings (en + de).

## Pairs with

- API: DFXswiss/api#4182 (merged to `develop`) — adds the `manualReview`
field and opens a support ticket on forward failure.

## Test plan

- `flutter analyze`: 0 issues; targeted `flutter test` green on the
build host (Flutter 3.41.6): cubit routing (`true` / precedence over
`emailConfirmed == false` / `false` / `null`), page render + Refresh →
`checkKyc`, page-manager mapping, and DTO parsing (`true` / `false` /
absent → null).
- Golden: `kyc_manual_review_golden_test.dart` was added; its baseline
is regenerated on the self-hosted runner via `golden-regenerate.yaml`
(per docs/visual-regression-tests.md — locally generated baselines drift
on non-runner hardware).

## Notes

- No backend gate (buy/sell, KYC level, mail) reads `manualReview` /
`emailConfirmed` / `isRegistered`; older app builds simply ignore the
additive flag (they still see `AlreadyRegistered`), so the change is
backward-compatible.
TaprootFreak and others added 23 commits July 13, 2026 01:35
## What

Bump the marketing-version floor in `pubspec.yaml` from `1.1.0` to
`1.2.0` (`1.1.0+0` → `1.2.0+0`).

## Why

Starts a new **MINOR** release train (1.1 → 1.2). Per [`CONTRIBUTING.md`
→ *Release Versioning*](../blob/develop/CONTRIBUTING.md), the `X.Y.Z`
part of `pubspec.yaml`'s `version:` is the floor that
[`auto-tag.yaml`](../blob/develop/.github/workflows/auto-tag.yaml)
consults for MAJOR/MINOR jumps. Once this reaches `develop`, the next
auto-tag cuts `v1.2.0` instead of another `v1.1.x` patch.

## Scope

- **One line changed.** The `+0` build sentinel is deliberately
**untouched** — CI always overrides `--build-name`/`--build-number` from
the release tag (`tool/generate_release_info.dart`), and local builds
keep the `dev` footer.
- No code changes; no root `CHANGELOG` exists (per-release store
changelogs under `android/fastlane/**` are maintained separately, out of
scope here).

## How auto-tag resolves it

`auto-tag.yaml` reads `pubspec.yaml` via `cut -d+ -f1` → `1.2.0`, then
`sort -V | tail -1` picks the higher of the patch-bumped latest tag vs.
this floor → resolves the next tag to `v1.2.0`.

## Verification

```
$ grep '^version:' pubspec.yaml
version: 1.2.0+0
```
Promote: staging -> develop
…date (#838)

## Problem

The registration date is a signed EIP-712 field the backend validates
against its own **UTC** clock. The app derived it from the **device's
local date** (`DateFormat('yyyy-MM-dd').format(DateTime.now())`), so a
device in a timezone ahead of UTC — e.g. Europe/Zurich just after local
midnight — signed **tomorrow's** date relative to the server and every
registration failed:

> RealUnitApiException: Registration date must be today (code: UNKNOWN,
statusCode: 400)

(Reproduced on the tax-residence "Abschliessen" step around 00:36
local.)

## Fix

- Fetch the date from the new **`GET /v1/realunit/register/date`**
immediately before signing, in both `_completeRegistration` and
`_registerWallet`.
- Drop the now-unused `intl` `DateFormat` import.

The server is now the single source of truth for the signed date, so the
device timezone can no longer push it out of the accepted window.

## Tests

- `getRegistrationDate`: GETs the endpoint and returns the date; non-200
and a missing `date` field both throw (no silent fallback).
- Registration/​add-wallet happy paths assert the **server-provided**
date lands in the signed body; error and BitBox-disconnect paths updated
for the extra fetch.

## Pair PR

Consumes DFXswiss/api#4186 (`GET /realunit/register/date` + one-day UTC
tolerance). Merge the API PR first.
## What

Expands the handbook from **61 to 268 screenshots** so it documents
**every** visual-regression golden baseline under `test/goldens/`, not
just the onboarding + settings journey. Coverage goes from 22.8% of
golden baselines to 100%.

## Why

A gap audit found 7 whole screen areas with zero handbook presence
despite having committed golden baselines — including live user-facing
features: **Support** (chat/tickets), **User-Data** (edit
name/address/phone), **Security** (change PIN / biometric toggle),
**Receive**, the **BitBox** hardware connect + sell flow, the full set
of **KYC** detail states, and the `debug_auth` dev tool. The other ~150
unmapped baselines were state variants (loading/error/validation) of
already-present screens. All are now documented.

## Changes

- **`scripts/assemble-handbook-screenshots.sh`** — MAPPING table
extended to 268 rows. `GOLDENS_ROOT` moved from `test/goldens/screens`
to `test/goldens` so the one `widgets/` baseline (`phone_number_field`)
resolves alongside the `screens/` ones; the original 61 rows were
re-prefixed with `screens/`.
- **`docs/handbook/de/index.html`** — 61 new thematic `<details
class="spec">` sections (`spec-18`…`spec-78`) inserted before
`spec-web`, plus matching TOC entries. One `<div class="test">` per
baseline with a German description derived from the golden's actual
state. The original 61 entries are untouched. Swiss orthography
(umlauts, no ß).
- **`docs/screens.md`** — Handbook column filled per screen row; scope
prose + counts rewritten (previously stale at "52 slots"); the missing
`SettingsSecurityPage` row added.
- **`.github/workflows/handbook-build-check.yaml`** — count guard `61` →
`268`; the container-serve HTTP smoke sample extended into the `62-268`
range.

## Verification

- `bash scripts/assemble-handbook-screenshots.sh <dir>` → assembles
**268** PNGs, **0 missing**.
- `<img>` refs ↔ MAPPING rows ↔ golden PNGs form an exact **268-way
bijection**; no unreferenced golden, no broken path.
- HTML parses clean: 0 tag errors, `<details>` balanced, all 268 slot
ids unique with agreeing `id`/`href`/`data-target`/`src`.
- Rendered locally (headless Chrome): all 268 screenshot images load;
new sections render in the existing 2-column layout; descriptions read
correctly.
- Reviewed by 3 independent passes (content accuracy vs. golden states,
data integrity, `screens.md` correctness); all findings addressed
(umlaut normalization, one UI-string quote fix, comment/guard
freshness).

## Notes

- The `docs/handbook/screenshots/` dir stays gitignored — images are
assembled from goldens at build time (`Dockerfile.handbook`), never
committed.
- Golden ↔ handbook stays a hard link: a UI drift reds the golden test
before the handbook image can go stale.
#844)

## Problem

When `register/complete` rejects a submit (e.g. a 400), the failure
snackbar showed the exception's raw `toString()`: `RealUnitApiException:
… (code: UNKNOWN, statusCode: 400)`. During the registration-date
incident (fixed in #838 + DFXswiss/api#4187) users tapped **Complete**
repeatedly without understanding that nothing had been saved — and the
wizard re-appearing on the next entry read like a routing bug.

## Fix

- **New typed `RegistrationRejectedException`** (extends `ApiException`,
enumerated in `exception_surface_test.dart`): thrown by
`RealUnitRegistrationService` exclusively for a **non-auth 4xx of
`register/complete` itself** — 401/403/429, 5xx, transport errors, and
pre-submit failures (`getUser`, `register/date`) stay plain, and the
code-specific subclasses (`KYC_LEVEL_REQUIRED`, `REGISTRATION_REQUIRED`)
are preserved. This scopes the attribution: only a genuine content-level
rejection of this submit gets typed.
- **The submit-failure listener** surfaces that exception's server
reason together with explicit context: *"Your data has not been saved —
please check your entries and submit again."* (new
`registrationRejected` key, localized in both ARBs, alphabetically
placed). Signing-cancelled keeps its message; everything else keeps the
generic `registrationFailed`, where a "check your entries" instruction
would be wrong.

## Goldens

The existing `kyc_registration_page_submit_failure_snackbar` golden
drives the **signing-cancelled** path, which this PR does not touch —
its baseline is unchanged (its misleading description was corrected to
say so). The new rejected-submit snackbar has its **own golden**
(`kyc_registration_page_submit_rejected_snackbar`), matching the
precedent of the forwarding-failed variant; the baseline was generated
and committed by the self-hosted runner via `golden-regenerate` (exactly
one new PNG, no existing baseline drifted). The later fixture-type swap
(`ApiException` → `RegistrationRejectedException`) renders the identical
text, so the baseline stays valid.

## Tests

- `real_unit_registration_service_test.dart` — the error mapping of
`register/complete`: a non-auth 4xx becomes
`RegistrationRejectedException` (with the server message), a 401 stays a
plain `ApiException` (exercised through the real one-shot token-refresh
retry), a 5xx stays plain, and `KYC_LEVEL_REQUIRED` keeps its
code-specific subclass.
- `kyc_registration_page_test.dart` — the listener: a rejection shows
the server reason and the nothing-was-saved hint without the raw
exception string; a 401 and a 5xx keep the generic failure message; none
of the failure paths re-arm the wallet services.
- `exception_surface_test.dart` — the new exception is enumerated
(mandatory per CONTRIBUTING).

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
…archy latency (#842)

## Problem

The `01-welcome` Maestro handbook flow intermittently fails on CI with
`Assert that "Start" is visible ... FAILED`, even though the welcome
screen renders correctly and **"Start" is present in the accessibility
tree** (verified from the failure artifact — the captured view-hierarchy
contains `accessibilityText: "Start"`).

Root cause (from a #824 run's driver post-mortem): `01-welcome` runs
first, against a **cold** XCUITest driver on a freshly `simctl erase`-d
**iOS 26** simulator. Retrieving the deep Flutter semantics tree's view
hierarchy is pathologically slow when cold — a single snapshot was
observed taking **64s** on a loaded `macos-latest` runner. The assert's
30s timeout elapsed while still inside that first fetch, so it failed
before it could ever evaluate a hierarchy that *did* contain "Start".
The app is not slow; the XCUITest snapshot is.

Every later flow reuses the warmed driver (`--reinstall-driver=false`)
and retrieves the hierarchy fast enough, so only this cold first fetch
is affected — which is why the suite-wide 30s works everywhere else.

## Fix

Raise **only** the `01-welcome` assert timeout to **120s** (with an
inline rationale), comfortably absorbing the worst observed cold
single-fetch. A genuinely broken welcome screen still fails well inside
the flow budget. No app/behaviour change — this is a test-infra
robustness fix.

## Verification

Labelled `tier3:full` so the Tier-3 Maestro workflow runs on this PR;
watching `01-welcome` pass.
#845)

## Why

The repo enforces **100 % line coverage on the activated surface**, and
the README even promises it for "every file in the activated surface".
Three gaps let untested code pass the gate as if covered:

1. **A never-loaded in-scope file is invisible, not failed.** `flutter
test --coverage` only records libraries a test actually imports.
`lib/packages/utils/fuck_firebase.dart` was in scope but imported by no
test, so it never appeared in `lcov.info` — it sat in neither the
numerator nor the denominator, and the scoped % stayed 100 % while the
file was wholly untested.
2. **`coverage:ignore` hid real, testable crypto.** The EIP-155
chainId-truncation loop in `BitboxCredentials.signToSignature` (`while
(truncChainId.bitLength > 32) …`) was behind `coverage:ignore`, but it
is plain arithmetic reachable with a `> 2^32` chainId — not an
unreachable platform path.
3. **`coverage:ignore` hid trivially-testable stubs.** The two
synchronous web3dart entry points on `BitboxCredentials` were ignored,
even though they are exactly the kind of throw-stub that the sibling
`_DebugCredentials` in `wallet.dart` covers with a `throwsA` test.

## What

- **`fuck_firebase.dart`**: route its `path_provider` access through
`DocumentsDirectoryPort` (it was the last direct
`getApplicationDocumentsDirectory()` caller outside
`PathProviderAdapter`) and factor the file write into a testable
`writeFirebaseTelemetryStub` helper. The Android-only platform seam
keeps a `coverage:ignore` + `@no-integration-test`, matching the
adapter-forwarder convention. New test
`test/packages/utils/fuck_firebase_test.dart`.
- **EIP-155 loop**: removed the `coverage:ignore`; added a `> 2^32`
chainId test that drives one truncation iteration and asserts the
parity-corrected `v`.
- **Sync stubs**: removed the `coverage:ignore` on `signToEcSignature` /
`signPersonalMessageToUint8List`; added two
`throwsA(isA<UnimplementedError>())` tests — same convention as
`_DebugCredentials`.
- **Visibility gate**: the `Coverage Floor Gate` job now also runs
`scripts/check-coverage-visibility.sh`. Every in-scope `.dart` file must
appear in the scoped tracefile, or be listed in
`.coverage-visibility-allowlist` (files with genuinely no coverable
lines: interfaces/ports, barrels). A new blind spot fails the build
until it is tested (preferred) or, only if truly lineless, allow-listed
in the same PR with a reason.
- **Docs**: `README.md` and `docs/testing.md` updated to document the
gate and the ignore-list changes.

## Verification

- `flutter analyze`: clean.
- `flutter test --coverage --exclude-tags golden`: all pass; scoped line
coverage stays at 100 %.
- Visibility gate smoke-tested both ways: passes with the committed
allowlist, fails against an empty allowlist (proving it actually catches
blind spots).

No production behaviour changes: `fuckFirebase()` keeps its single call
site and its Android-only side effect; the BitBox stubs still throw
`UnimplementedError`; the EIP-155 arithmetic is unchanged.
## Why

`setupEssentials` calls `_migrateSecurityFlags` on every boot to move
security-sensitive flags out of `SharedPreferences` into
`SecureStorage`:

- `isBiometricEnabled`
- the PIN lockout attempt counter (`pinFailedAttempts`)
- the PIN lock-until timestamp (`pinLockedUntil`)
- and it drops the legacy `isPinEnabled` flag.

This ran untested: `_migrateSecurityFlags` is a private top-level
function only reachable by booting the real `getIt` locator. A
regression here would silently mishandle PIN-lockout state (reset the
counter, keep a stale lock, or fail to migrate) — exactly the kind of
security state you don't want drifting untested.

## What

- Promote `_migrateSecurityFlags` → `@visibleForTesting
migrateSecurityFlags(prefs, secureStorage)`. Behaviour is unchanged;
only the visibility of the seam.
- `test/setup/di_test.dart` drives it with
`SharedPreferences.setMockInitialValues` +
`SecureStorage.withStorage(mock)` (the storage's existing test
constructor), covering each of the three migrations, the
unparseable-timestamp branch (drops the value but still clears the
pref), the `isPinEnabled` cleanup, and the clean-install no-op (no
secure writes).
- `docs/testing.md`: the remaining boot code — `setupEssentials`'
SQLCipher encryption-key lifecycle — is added to the "Surface that needs
infra work" table, because testing it needs an injection-point change to
`setupEssentials` (which touches the wallet encryption key) and warrants
its own reviewed PR.

## Scope

`di.dart` is outside the line-coverage activated surface, so this does
not move the coverage gate — it closes a real test gap in
security-critical boot code.

## Verification

- `flutter analyze`: clean.
- `flutter test`: full suite green, including the six new
`migrateSecurityFlags` cases.
)

## Summary

Hard-wires the address-step residence country into the tax-residence
step as a locked primary entry, allows additional tax countries, and
requires TINs for every non-CH entry. Derives `swissTaxResidence` and
`countryAndTINs` for the registration submit contract.

- Locked primary tax row = address country (not removable/changeable)
- Optional additional tax countries via “Add another tax residence”
- TIN required for non-CH only
- Full S1–S5 logic tests, page wiring, and golden baselines (+ handbook
mapping)

Depends on / pairs with the matching DFXswiss/api change that enforces
the same rule server-side and always persists TINs.

## Test plan

- [x] Widget logic S1–S5 (28 tests)
- [x] Page wiring S1–S5
- [x] Golden baselines S1–S5 (incl. error states)
- [ ] m5me analyze + test
- [ ] CI green after ready (draft skips CI)
…xt (#847)

## Summary

- Fix BitBox pairing sheet where **Bestätigen** was untappable on iOS:
long DE copy + real multi-token channel hash + sheet clip pushed CTAs
outside the hit-test region (`Column` + `Spacer` overflow).
- Introduce `ScrollableActionsLayout` (scrollable body, sticky actions)
and use it in `ConnectContent`; cap sheet height at 90% of screen
height.
- Add a **device × text-scale matrix** gate (iPhone SE → Pro Max,
Android compact → large; text scales 0.85–3.0) with real `tapAt`
assertions, a surface catalog, and responsive-layout policy in
CONTRIBUTING / docs.

## Test plan

- [x] `flutter test` on m5me: scrollable_actions_layout + responsive
catalog + connect_bitbox matrix + connect_content + connect_bitbox_view
(**195 passed**)
- [x] Focused regression: iPhone 15 DE + real channel hash → tap calls
`confirmPairing`; Abbrechen tappable
- [x] Focused regression: iPhone SE + textScale 3.0 still tappable
- [ ] CI Analyze & Test / Visual Regression on this draft
- [ ] Manual smoke on device optional (small phone + large accessibility
text + BitBox pairing)

## Notes

- First catalogued sticky-CTA surface: `bitbox_connect_sheet`. Further
sheets should migrate to `ScrollableActionsLayout` + matrix entry in the
same PR as layout changes.
- Public repo: PR text in English per CONTRIBUTING.
)

## What

Follow-up to #848. Hides the row-level remove button on tax residence
row 0 when it has no locked address country, and adds regression
coverage for the tax residence add/remove/collision flows.

## Why

`_removeRow` hard-blocks index 0:

```dart
if (index <= 0 || ...) return;
```

But the render guard only checked `!row.lockedToResidence`. In the
fallback case (no address country available), row 0 has
`lockedToResidence == false`, so the remove button was rendered on it
anyway — visible, but a no-op when tapped. This is visible in the
already-committed golden `kyc_registration_tax_step_default.png`.

Fix: `if (!row.lockedToResidence && index > 0)`.

## Tests

6 new `testWidgets` in `kyc_registration_tax_step_test.dart`:
- removing an extra row makes it disappear, frees its country for
  re-selection, and produces the correct submit payload
- the locked address-residence row cannot be removed
- regression guard: no remove button on the unlocked primary row when
  it's the only row
- regression guard: with a second unlocked row added, row 0 still has
  no remove button while row 1's remove button works
- `didUpdateWidget`: an extra row is kept when the address country
  changes, or dropped on a country collision

## Golden baselines

The render change removed the button from three goldens without an
address country. They've been regenerated on the self-hosted runner
via `golden-regenerate.yaml` and are included in this PR:
- `kyc_registration_tax_step_default.png`
- `kyc_registration_tax_step_country_error.png`
- `kyc_registration_page_tax_step.png`

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
#849)

## Problem

The self-hosted runner (Mac Studio) keeps the Flutter SDK persistently
under `_work/_tool/flutter` across runs, so
`subosito/flutter-action@v2`'s `cache: true` has nothing useful to
restore there. In practice:

- Cache restore stalls at 0.5-0.8 MB/s and hits the 10 minute segment
timeout, then falls back to `Cache not found` anyway.
- Cache save afterwards costs another ~8 minutes.
- Net: ~18 minutes of overhead per job, on top of the ~30 seconds the
actual golden tests take to run.
- Result: 20-30 minute job duration on a runner that has exactly one
slot for this repo.

## Solution

Set `cache: false` on the two jobs that run on the self-hosted runner:

- `.github/workflows/pull-request.yaml` — `golden-tests` job ("Visual
Regression")
- `.github/workflows/golden-regenerate.yaml` — `regenerate` job

Both changes are accompanied by an inline comment explaining the
rationale, matching the existing comment style in these files.
`flutter-version`, `channel`, and `timeout-minutes` are unchanged.

GitHub-hosted jobs (`build` in `pull-request.yaml`,
`tier3-handbook.yaml`, `release.yaml`) keep `cache: true` — they run on
ephemeral runners where the cache is actually useful.

## Expected effect

Self-hosted job duration should drop from 20-30 min to roughly 2 min,
freeing up the single self-hosted runner slot much sooner for the next
queued job.

## Risk

Low. `cache: false` only removes a cache-restore/save step; it does not
change the Flutter SDK version, channel, or any test behavior. Worst
case if the assumption about the persistent `_work/_tool` SDK turns out
wrong: `flutter-action` falls back to a fresh SDK install on that job,
which costs time but does not break correctness.
## Problem

Both jobs that run on the self-hosted Mac Studio carry `timeout-minutes:
30`:

| Workflow | Job | |
|---|---|---|
| `pull-request.yaml` | `golden-tests` (Visual Regression) | 30 |
| `golden-regenerate.yaml` | `regenerate` (Regenerate golden baselines)
| 30 |

That 30 was sized for the cache era: `subosito/flutter-action@v2` pulled
the SDK through the Actions cache, where the restore stalls at 0.5–0.8
MB/s, times out, misses anyway, and then burns ~8 more minutes on a
cache save — roughly 18 minutes of pure overhead. A 20–30 minute job
needed a 30 minute ceiling.

That premise is gone with #849.

## Measurement

With the Actions cache disabled on the self-hosted runner (#849), the
same `golden-tests` job ran in **54 seconds** on the runner — `12:02:24Z
→ 12:03:18Z`, measured in a real CI run, not estimated.

## Change

`30 → 15` on both self-hosted jobs. Nothing else is touched — no
`cache:` change, no reformatting.

Explicitly **unchanged**: `build` / Analyze & Test (30, GitHub-hosted
`macos-latest`, legitimately slower), `coverage-floor` (5),
`bitbox-audit` (10).

## Why 15, and not 5 or 30

The job runs in ~1 minute, so 15 is a ~15x margin — it will never fire
in normal operation. It still absorbs the worst *legitimate* case: a
runner whose persistent tool cache (`_work/_tool`) has been wiped, so
the Flutter SDK has to be downloaded from scratch. 15 covers that
comfortably while still being a real ceiling.

The ceiling is the point. The self-hosted runner has exactly **one**
slot for this repo. A wedged job (hung `flutter test`, stuck simulator,
dead network mid-download) does not just fail slowly — it holds the only
slot and blocks every other PR behind it. At 30 that is a half-hour
outage of the visual-regression lane; at 15 the slot comes back twice as
fast. `golden-regenerate` is the same workload plus a commit+push on the
same single slot, so it moves in lockstep.

## Risk

Low, and one-directional: the only way this bites is a job that
legitimately needs more than 15 minutes. Post-#849 the job needs ~1. If
the tool cache is ever cold *and* the download is pathologically slow,
the job fails with a clear timeout rather than silently occupying the
slot — a better failure mode than the status quo.

> [!IMPORTANT]
> **Do not let this PR's CI run before #849 is in its base.** The
constraint is about CI order, not just merge order.
>
> This branch carries `timeout-minutes: 15` while `staging` still has
`cache: true` in both self-hosted jobs (#849 is what removes it).
Flipping this PR out of draft is what first exercises the new cap —
against the cache era it exists to retire. Of the 14 completed `Visual
Regression` runs of the last two days, **7 exceeded 15 minutes**, three
of them as *green* runs (19.5 / 19.6 / 20.9 min): the stalled cache
restore and the trailing cache save vary independently, so a run over
the cap is roughly a coin flip. The job would be killed at 15 minutes,
red-lining this PR's own gating check and holding the single self-hosted
slot for the full 15 minutes.
>
> Correct order: merge #849 into `staging` first, **then** mark this
ready. No rebase is needed — the two PRs touch different lines, so once
#849 lands the recomputed merge ref carries `cache: false` on its own.
Until then this stays a draft, and `golden-regenerate` must not be
dispatched on this branch.
…and text scale (#854)

## Summary

PR #847 fixed the BitBox pairing sheet. An audit then pumped every
remaining candidate screen through the responsive matrix — **17 of 17
failed**. This closes them.

**Two were broken at the default text scale:**
- **Dashboard** — `RealUnit kaufen`, the only buy entry point on the
home screen, received **no pointer events** on an iPhone SE as soon as a
pending transaction was shown (`reachesCTA=false, navigated=false`). No
red stripe in release: the user just sees a dead button.
- **Create wallet** — the seed-backup confirm button rendered *below*
the viewport, so the mandatory backup step looked like a dead end.

The rest break from text scale 1.3–3.0 (accessibility users).

## What changed

- All **17 surfaces** migrated to `ScrollableActionsLayout` (scrollable
body, sticky actions). Every one gated by a device × text-scale matrix
test (7 phones × 5 scales) that asserts a **real tap reaches the CTA** —
each new test was verified to fail against the pre-fix code.
- `ScrollableActionsLayout` gained `centerBody` so screens that centred
their content keep their look, and its sticky action block is now
bounded by the viewport — otherwise it could itself outgrow the
available height at scale 3.0 and clip the CTA (the same bug at the
other end of the scale).
- Three **horizontal** `RenderFlex` overflows of a different class fixed
(Rows that cannot shrink, clipping content off the right edge from scale
1.3 — on large phones too): dashboard price widget, pending transaction
row, tag selection, file picker field.
- All 18 surfaces registered in the catalog; its self-test now verifies
the production file really constructs the layout. Docs/CONTRIBUTING
state the contract honestly (bounded height required and throws
otherwise; `Spacer()` illegal in the body; the catalog is a review
responsibility, not a completeness proof).

## Test plan

- [x] `flutter analyze` clean
- [x] `flutter test` — **3711 passed** (≈450 new matrix cells)
- [x] Goldens regenerated (25) and re-verified green; visually inspected
— the status pages intentionally pin the button to the bottom instead of
centring it with the copy, which is what makes it reachable
- [ ] CI on this PR

## Not covered

Welcome page, sell confirm/executed sheets, pin setup / biometric sheets
are **not** migrated (no sticky-CTA overflow found there yet) — named
explicitly in the docs rather than silently implied.
…to the API limits (#855)

Follow-up to #848. A full review of #848 together with its backend
counterpart (DFXswiss/api#4198) surfaced two defects plus two
housekeeping items.

## 1. The prefilled tax residences were parsed and then dropped (major)

`GET /realunit/registration-info` returns both `countryAndTINs` and
`swissTaxResidence` in the prefill, and `RealUnitUserDataDto` parses
both — but `KycRegistrationTaxStep` only ever received
`residenceCountry`. The additional rows were never seeded.

Since #4198 the backend overwrites `user_data.tin` with **exactly** what
this form submits (previously it only wrote it for a first-time
customer). A returning user therefore never saw the tax residences they
had declared earlier, and submitting the form silently erased them from
the stored declaration.

The form is now seeded from the prefill: every declared country is
resolved and rendered as a row, with its TIN prefilled. A stored
`swissTaxResidence: true` comes back as an explicit CH row (Swiss tax
residence is declared via the flag and carries no TIN).

## 2. No upper bounds (major)

The step allowed an unbounded number of rows and an unbounded TIN. The
backend now caps `countryAndTINs` at 10 entries and each TIN at 64
characters — without client-side bounds the user would run into a raw
server-side 400 (and, before the backend fix, into a 500). Both limits
are mirrored here: the "add another tax residence" button disappears at
10 rows, and the TIN field is length-limited via
`LengthLimitingTextInputFormatter` (no visible counter, so the goldens
stay valid).

## 3. ARB key order (minor)

`tool/alphabetize_localization.dart` sorts case-insensitively; both ARB
files had been re-sorted case-sensitively, so the next mandated codegen
run would have reshuffled the whole file. Re-ran the tool to restore its
canonical order.

## 4. `docs/screens.md` (minor)

The `KycRegistrationTaxStep` row still listed only slots `56`–`61`; the
ten new slots `61b`–`61k` from
`scripts/assemble-handbook-screenshots.sh` were missing, although the
file itself requires keeping them in sync.

## Test plan

- [x] 8 new tests (prefill seeding for CH- and DE-residents, no-prefill,
row cap, TIN truncation, seed truncation above the cap, residence
re-lock, page wiring)
- [x] `flutter analyze` clean, full suite green (2988 passed) on the
build host
- [x] No golden baseline touched — the default rendering is unchanged
- [ ] CI green after ready (draft skips CI)
…spatch sha (#856)

## Problem

The `regenerate` job in `golden-regenerate.yaml` checks out the default
`github.sha`, which GitHub pins at dispatch time. The self-hosted runner
this workflow uses only has a single execution slot, so the job can sit
queued for many minutes after `workflow_dispatch` before it actually
starts. By the time it runs, the dispatched branch has frequently moved
on past the pinned sha.

The job then regenerates golden baselines against that stale tree and
tries to push the result back onto the branch. Since the branch tip has
advanced, the push is rejected with `! [rejected] (fetch first)`, and
the whole run — including the ~costly golden regeneration on the
self-hosted runner — is wasted. This is a documented, reproduced failure
mode (run 29259187838).

The comment that previously sat above `git push` attributed this to
protected-branch behavior ("On protected branches (develop/main) this
fails by design"). That's a misdiagnosis for this failure: the observed
rejections were on a feature branch, caused by the branch moving during
the queue wait, not by branch protection.

## Fix

Add `ref: ${{ github.ref_name }}` to the `actions/checkout@v4` step so
it checks out the live head of the dispatched branch at checkout time,
instead of the sha pinned at dispatch time. This way the goldens are
always rendered against the exact tree the commit will land on,
regardless of how long the job sat in the runner queue.

The push-step comment is reworded to separate the two distinct cases
clearly:
- Protected branch (develop/main): push fails by design, no force-push
or bypass — unchanged.
- Feature branch: after this fix, the checkout/push window is a matter
of seconds rather than minutes, so this should normally succeed. If the
branch still moves in that narrow window, the push still fails loudly,
and the existing fallback artifact upload still recovers the regenerated
PNGs. The fix for that case is simply to re-dispatch the workflow.

## Why not a rebase/retry instead

An alternative would have been to `git pull --rebase` (or force-push)
before the push and retry. That was deliberately not done: rebasing the
commit would let baselines rendered against one tree get published
against a different, newer tree they were never actually validated
against — a correctness hazard, not just a convenience issue. Keeping
the push fail-loud, with re-dispatch as the recovery path, guarantees
committed baselines always match the tree they were rendered from.

## Risk

The remaining race window (checkout to push) shrinks from potentially
many minutes to roughly the job's own runtime (about a minute), so a
residual failure is unlikely but not impossible. If it still happens,
the push fails loudly as before and the fallback artifact upload
(`if-no-files-found: error`) still preserves the regenerated baselines
for manual recovery.
Promote: staging -> develop
## Problem

Every push to `staging` currently runs the "RealUnit Build" workflow
twice on the same SHA:

1. once from the `push: staging` trigger, and
2. once from the permanently-open auto-PR "Promote: staging → develop"
(#841) via `pull_request`.

Those two runs use different concurrency groups (PR number vs.
`github.ref`), so `cancel-in-progress` never cancels one against the
other. Both compete for the single self-hosted runner slot used by
Visual Regression / `golden-tests`.

## Fix

Map that same-repo staging-head PR (`pull_request` + `head_ref ==
'staging'` + head repo matches this repository) into the same constant
concurrency group as `push: staging` (`refs/heads/staging`).
`cancel-in-progress` then cancels the duplicate, and exactly one real
run survives per staging-lane commit.

## Fail-closed (not skip-as-success)

This is deliberately **not** the skip-as-success approach previously
proposed and rejected in #850. A step/job that is merely skipped and
treated as green can mask a red `push: staging` result. Here there is no
`if:` skip and no success-without-running path: both triggers still
schedule real work; the concurrency group collapses the pair so one run
is cancelled. A red stays red. Worst case is a plain `cancelled` check —
fail-closed and fixable by re-running — never a masked green.

## Fork-identity guard

The group remapping requires
`github.event.pull_request.head.repo.full_name == github.repository`, so
a fork branch that happens to be named `staging` cannot cancel the real
staging lane.

## Feature PRs

Unaffected. They keep the existing PR-number concurrency group (with
`github.ref` fallback for push / workflow_dispatch).
…858)

## Summary

Follow-up to #854 (which fixed 25 sticky-CTA surfaces). An **empirical**
audit of the surfaces the original grep missed — they use
`Column(mainAxisSize: .min)`, not `Spacer()`, so no text search finds
them — proved **4 more bottom sheets** clip their CTA out of the
hit-test region at large system text scale (dead button, no red stripe
in release):

| Sheet | breaks at | impact |
|---|---|---|
| `sell_confirm_sheet` | text scale 2.0 | cannot confirm a sale |
| `sell_executed_sheet` | **~1.2** | success sheet not closable — it was
shown **without** `isScrollControlled`, so clamped to 9/16 of screen
height |
| `forgot_pin_bottom_sheet` | 2.0 | cannot reset the PIN |
| `enable_biometric_bottom_sheet` | 2.0 | cannot enable/skip biometrics
|

## What changed

- New `shrinkWrap` mode on `ScrollableActionsLayout`: sizes to content
up to the available height, scrolls the body past it, keeps the actions
pinned — so a short sheet is not forced to full height but never clips
its CTA. With `shrinkWrap:false` (default) the 25 screens from #854 are
**byte-identical** (their whole test suite re-run unchanged).
- The 4 sheets migrated to `ScrollableActionsLayout(shrinkWrap: true)`
bounded at 0.9× screen; `sell_executed`'s call site fixed to
`isScrollControlled: true`; `sell_confirm`'s info-row horizontal
overflow fixed (both label and value shrink-safe).
- Each sheet gated by a matrix test pumped through a **real**
`showModalBottomSheet` (so the screen-height bound is faithful); every
test verified to fail against the pre-fix code.
- Catalog now lists all **29** surfaces; `welcome_page` audited → safe
(scrolls). Docs state honestly that no further sticky-CTA surface of
this shape is known.

## Test plan

- [x] `flutter analyze` clean
- [x] `flutter test` — **4432 passed** incl. goldens (rebased on current
staging with #855/#856)
- [x] 3 sell-sheet goldens regenerated & visually inspected; pin sheets
render pixel-identical
- [ ] CI on this PR
…rmed-email quote gate (#860)

## Problem

A buy confirm that the share register rejects with the
`PrimaryEmailRequired` error code (HTTP 400) currently falls into
`BuyConfirmError.unknown` and shows the generic technical-problem
snackbar — the buyer gets no hint that an email confirmation is
outstanding. This is the error path a real buyer hit repeatedly last
week.

## Changes

- **Confirm path:** `BuyConfirmError.primaryEmailRequired` — mapped from
the API error code (never from message text), 503 precedence unchanged
and pinned by a test. The snackbar reuses the existing
`buyPaymentConfirmFailedAktionariat` copy, which describes exactly the
required action (check your inbox for the pending confirmation); no new
ARB keys.
- **Quote path (forward-compatible):** handles the upcoming
`PrimaryEmailNotConfirmed` quote error (`isValid:false`). The action
button routes to the KYC page — which auto-resolves to its confirm-email
step — instead of the email-capture flow, then re-fetches the quote.
Unreachable until the API starts emitting the code, so this PR is fully
backward-compatible.
- Tests: cubit mappings (incl. 503-precedence pin), state props,
snackbar copy, action-button routing (pushes KYC, not email capture),
plus golden coverage for the new gate state and snackbar.

## Merge order

This app change must be released **before** the API starts emitting
`PrimaryEmailNotConfirmed` — older app versions would otherwise degrade
to the generic retry dead-end on the quote screen. The confirm-path fix
is effective immediately with the current API.

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
## Summary

- isolate staging-push routing from canonical required checks
- let the ready `staging → develop` PR own normal CI
- dispatch the full `RealUnit Build` as a fail-safe when the promotion
PR is missing, draft, stale, or cannot be queried
- preserve per-lane cancellation for obsolete SHAs without any
cross-event cancellation
- upgrade first-party Actions to Node.js 24-compatible majors and
disable the inapplicable Go module cache
- document trigger ownership and fallback guarantees

## Root cause

`push: staging` and the promotion PR previously shared one concurrency
group. When the PR event arrived after the push event,
`cancel-in-progress: true` cancelled the push run on the same head SHA.
GitHub attached the cancelled `Analyze & Test`, `Coverage Floor Gate`,
`Visual Regression`, and `BitBox quirks audit` checks to the current
promotion PR, leaving it visibly red even though the replacement PR run
succeeded.

## Design

Canonical jobs remain in `pull-request.yaml` with their stable
required-check names. That workflow now handles PRs, `push: develop`,
and manual dispatches only.

The new `staging-ci-fallback.yaml` workflow handles `push: staging`:

1. Query open `staging → develop` PRs using an exact base, head
owner/branch, repository, draft-state, and head-SHA match.
2. If a ready same-repository PR already points at the pushed SHA, do
nothing: its `synchronize` event owns canonical CI.
3. If the PR is missing, draft, stale, or the API lookup fails, dispatch
`pull-request.yaml` on `staging` via `workflow_dispatch`.
4. Keep router concurrency isolated from `RealUnit Build`, so routing
can never cancel required PR checks.

This removes duplicate heavy macOS/self-hosted work in the normal
promotion path while failing safe to a full build whenever PR ownership
cannot be proven.

## Required-check safety

The initial same-workflow approach was deliberately replaced after live
verification showed that GitHub renders expressions in skipped job names
literally. Keeping routing in a separate workflow means:

- canonical required-check names are stable
- the router never emits skipped checks with canonical names
- a skipped job cannot accidentally satisfy a required check
- no raw expression-based check names appear in the staging-push path

## Additional CI cleanup

- `actions/checkout@v6`
- `actions/setup-go@v6` with `cache: false` because this repository has
no `go.mod`/`go.sum`
- `actions/upload-artifact@v6`
- `actions/download-artifact@v7`

These versions use the Node.js 24 runtime. The self-hosted golden runner
reported version `2.335.1`, above the Actions minimum of `2.327.1`.

## Validation

- `actionlint -ignore 'label ".+" is unknown'
.github/workflows/pull-request.yaml
.github/workflows/staging-ci-fallback.yaml`
- Ruby YAML parse for both workflows
- `git diff --check`
- exact embedded router script exercised with mocked GitHub responses:
  - ready PR at current SHA → no fallback dispatch
  - draft PR → full fallback dispatch
  - stale PR SHA → full fallback dispatch
  - GitHub API failure → full fallback dispatch
  - manual invocation → full dispatch

The ignored `actionlint` diagnostics are the repository's existing
custom self-hosted labels (`m3-ultra`, `realunit-app`).

### End-to-end result

[Manual `RealUnit Build` run
29418489339](https://github.com/RealUnitCH/app/actions/runs/29418489339)
completed successfully on commit `69e86893`:

- `Analyze & Test` — success
- `Coverage Floor Gate` — success, including artifact upload/download on
the upgraded Actions
- `Visual Regression` — success on the self-hosted runner
- `BitBox quirks audit` — success without the previous `go.sum` cache
warning
- no Node.js 20 deprecation annotation
#862)

## Why

RealUnitCH/web#23 adds a dedicated confirm-page end state
(`no-registration`) with four new Playwright baselines (18 → 22 PNGs).
The handbook deploy stages those baselines behind the
`EXPECTED_WEB_BASELINE_COUNT` guard, which requires the count bump and
the matching gallery cards in the same change.

## Changes

- `handbook.yaml`: `EXPECTED_WEB_BASELINE_COUNT` 18 → 22.
- `docs/handbook/de/index.html` (`#spec-web`): four cards for the new
views (desktop de/en, tablet, mobile), mirroring the confirm-invalid
card group.

## Coupling

Merge together with RealUnitCH/web#23 — the guard fails the next
handbook deploy if either side lands alone. The PR-level `Handbook Build
Check` does not stage web baselines, so this PR's CI is unaffected
either way.
Promote: staging -> develop
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant