Skip to content

Release: develop -> main - #840

Merged
TaprootFreak merged 83 commits into
mainfrom
develop
Aug 7, 2026
Merged

Release: develop -> main#840
TaprootFreak merged 83 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 29 commits July 16, 2026 20:47
## Why

`setupEssentials` resolves the **SQLCipher database encryption key** on
every boot:

- return the stored key if one exists,
- on a clean first boot (no key AND no database) mint a fresh key,
persist it, and drop any stale current-wallet id,
- and if a database is present **without** its key, fail loud rather
than silently minting a new one — which would strand the still-encrypted
data behind an unusable key.

This is the wallet's most safety-critical boot logic and was the last
untested piece of `di.dart` (the migration half landed in #846). It was
unreachable in a host test because it inlined `const SecureStorage()`
and the path_provider-backed database-file check, wiring everything into
the global `getIt`.

## What

- `setupEssentials` gains two injection seams with production defaults:
`SecureStorage secureStorage = const SecureStorage()` and `Future<bool>
Function() databaseFileExists = _existsDatabaseFile`. This is the repo's
default-injection pattern (mirrors `const PathProviderAdapter()`),
**not** a silent `?? default` — the production call `setupEssentials()`
in `main.dart` is byte-for-byte unchanged.
- `test/setup/di_test.dart` covers all three branches against
`SecureStorage.withStorage(mock)` +
`SharedPreferences.setMockInitialValues`, with a per-test
`getIt.reset()` so the boot registrations never leak:
existing-key-returned (mints nothing), clean-boot-mint (fresh 64-hex key
persisted once + stale `currentWalletId` dropped), and the fail-loud
db-present-without-key guard (throws, mints nothing).
- `docs/testing.md`: removes the now-obsolete "needs infra work" row for
`setupEssentials` and documents that `di.dart`'s boot code is fully
covered.

## Scope

`di.dart` is outside the line-coverage activated surface, so this does
not move the coverage gate — it closes the last real test gap in
wallet-key boot code. Finding #4 from the coverage audit is now fully
addressed.

## Verification

- `flutter analyze`: clean.
- `flutter test`: full suite green, including the three new
`setupEssentials` cases.
Adds baselines for the two transient SnackBars the **create-ticket**
flow surfaces (part of the #816 / #820 Support gap).

## New baselines
| Golden | State |
|---|---|
| `support_create_ticket_page_error_snackbar` | submit failure — red
banner with the raw `ApiException` string the cubit stores in
`state.error` |
| `support_create_ticket_page_success_snackbar` | ticket created — green
confirmation, shown over the Support host after the form pops |

## Approach
- Both fire the **real** `BlocConsumer` listener via `whenListen`
(initial → target state), then `pump()` + `pumpAndSettle()` settles the
SnackBar entrance. The 4 s auto-dismiss is a `Timer`, not a frame, so
the banner stays visible.
- **Success is special:** the listener calls go_router's `context.pop`,
so the form is gone the moment the SnackBar shows. Rendering it over the
create-ticket form would be a fiction. Instead the golden rebuilds the
real `/support → create` route stack with a `GoRouter`; on success it
pops to a Support-titled host and the **app-level** SnackBar persists on
top — exactly the production outcome. (The host body is a deliberate,
documented stand-in for the Support landing.)
- No data mocks: the error text is a genuine
`ApiException(...).toString()`.

## Determinism
Baselines generated twice on the self-hosted-equivalent runner (Flutter
3.41.6) — byte-identical across runs; `flutter analyze` clean; no other
baseline drifted.

## Note
The email-capture merge SnackBar was intentionally **excluded**: the
state it depicted (a red "email already linked — pick another address"
error) is wrong behaviour — the API sends an account-merge confirmation
email and expects the KYC verification flow. That is fixed in a separate
PR; this one stays a clean test-only change.
## The bug
When a user enters an email that **already belongs to an existing DFX
account** in the support/buy primary-email capture page, the API (`POST
/v1/realunit/register/email`) returns `merge_requested` (HTTP **201**)
*after having already sent an account-merge confirmation email* — see
`realunit.service.ts` / `user-data.service.ts#checkMail` (a true
dead-end is a **409**, not `merge_requested`).

The capture page treated `merge_requested` as a failure and showed a red
SnackBar:

> „Diese E-Mail-Adresse ist bereits einer anderen Wallet zugeordnet.
Bitte wählen Sie eine andere Adresse oder kontaktieren Sie den Support
per E-Mail."

That is wrong: the user's own email **is** the right one, a confirmation
mail was already sent, and telling them to pick a different address (or
email support) is a dead end that **blocks existing customers** from
onboarding via support or buy. The main KYC email step already handles
this correctly.

## The fix
Mirror the canonical KYC email step:
- New `SupportEmailCaptureMergeRequested` state; the
`SupportEmailCaptureError` enum is dropped (`Failure` now carries just
its `message`).
- On `merge_requested`, route to the **shared
`KycEmailVerificationPage`** ("we've emailed you — confirm to continue
with your existing account"). On confirmation the wallet is linked to
the existing account, so its primary email is now set — signal the
caller with `pop(true)` exactly like a direct registration. On back-out,
stay on the form so the user can retry.
- Remove the now-unused `supportEmailMergeRequiresVerification` string
(de/en).

Both callers (`settings_contact`, buy `payment_action_button`) already
treat the `true` pop / re-fetch as "proceed", so no caller change is
needed.

## Tests
Cubit/state/page tests updated to the merge-verification behaviour,
using an auto-pop `NavigatorObserver` (mirrors `kyc_email_page_test`) to
simulate confirm / back-out without driving the verification page.
Verified on the self-hosted-equivalent runner: `flutter analyze` clean,
all support email-capture tests pass, and the page file stays at **100%
line coverage** (63/63).
Promote: staging -> develop
Two small product-copy/display bugs surfaced during the support & PIN
golden audit (noted against #816). Two atomic commits.

## 1. `fix(pin)` — dangling 'Forgot PIN?' reference in gate-flow lockout
copy
The permanent-lock message `pinVerifyLocked` ("…use 'Forgot PIN?' to
reset") points at a button that **only exists in the app-lock entry
point** (`VerifyPinPage.appLock`, `bottom != null`). In feature-gate
flows the button isn't rendered, so the copy referenced nothing — the
exact trap already solved for `VerifyPinUnverifiable`.

Fix: branch `VerifyPinLocked`'s copy on `widget.bottom`, mirroring
`Unverifiable`:
- gate flow → new **`pinVerifyLockedGate`**: "…Lock the app and reset
the wallet from the lock screen."
- app-lock flow → unchanged button-referencing text.

The existing `verify_pin_page_locked` golden was the gate variant and
**showed the bug** — it's regenerated with the corrected copy, and a new
`verify_pin_page_locked_app_lock` golden covers the button-present
branch (mirroring the `unverifiable` / `unverifiable_app_lock` pair).
Page-test updated + app-lock case added.

## 2. `fix(support)` — ticket list date shown in UTC
`ticket.created` is parsed as UTC (`DateTime.parse` of the API's
ISO-8601), so the list rendered the UTC calendar date — wrong across
midnight for non-UTC users. Converted with `.toLocal()` before
formatting (same fix as the chat-bubble timestamp in #820). New
regression test fails on the raw-UTC path. The
`support_tickets_page_loaded` golden is **unchanged** (its midnight-UTC
fixture stays on the same day in the runner timezone — verified).

## Verification (m5me, Flutter 3.41.6, CI-identical)
`flutter analyze` clean; `verify_pin_page_test` +
`support_tickets_page_test` pass; goldens double-run deterministic; only
the two intended pin baselines changed, no other golden drifted.
Follow-up to the ticket-date fix (#870): the same UTC-display bug class
in the transaction rows.

## The bug
The DFX history API returns transaction timestamps as UTC (`Z`-suffixed
→ parsed to a UTC `DateTime`). The dashboard and history rows formatted
`transaction.timestamp` directly, so they showed **UTC** — off by the
device's offset (e.g. +02:00), and the date could be wrong across
midnight.

## The fix
`.toLocal()` at the display sites — `transaction_row.dart` (×2),
`transaction_history_row.dart`, and `pending_transaction_row.dart` — so
every transaction date/time renders in the device's local time.
Consistent with the chat-bubble and ticket-date fixes.

Five goldens shifted from UTC to local and were regenerated (dashboard
recent/hidden-amounts, history list + two receipt-row states); verified
they show the +02:00-converted times, consistent across dashboard and
history. The pending-row golden is unchanged (its fixture doesn't cross
midnight).

## Not changed (learned during review)
- The **date-range filter** was left as-is:
`DateTime.isBefore`/`isAfter` compare the absolute instant regardless of
the UTC/local flag, so a `.toLocal()` there is a no-op and changes no
filtering. (A separate, pre-existing local-date-boundary question is out
of scope.)
- The stale determinism comment in
`transaction_history_states_golden_test.dart` was updated — it wrongly
claimed the row was timezone-independent.

## Verification (self-hosted-equivalent runner, Flutter 3.41.6, TZ
+0200)
`flutter analyze` clean; dashboard + transaction_history tests pass
(incl. the unchanged filter cubit); goldens double-run deterministic;
only the five transaction-showing goldens changed.
…cher.onError (#867)

## What

`FlutterError.onError` only sees errors raised inside a Flutter callback
— build, layout, paint. Unhandled errors from a `Future`, `Stream` or
`Timer` callback in the root isolate reach **no handler at all** today:
`lib/main.dart` installs `FlutterError.onError` and
`ErrorWidget.builder`, and nothing else. There is no
`PlatformDispatcher.onError` and no `runZonedGuarded`.

This installs `PlatformDispatcher.onError` alongside the existing
handlers and extracts the whole installation out of `main.dart` into
`lib/setup/error_handling/error_handlers.dart`, next to the
`RealUnitErrorView` it already uses — which is what makes the contract
testable at all.

## Scope — please read before assuming this fixes field diagnostics

This does **not** deliver release-mode evidence, and the PR should not
be merged under that belief.

`developer.log` writes to the VM service stream, so these calls surface
in the DevTools Logging view under the `WalletApp` tag in **debug and
profile builds only** — the VM service is absent from a release build.
What this PR actually delivers:

- async errors become **reachable where a developer is already
attached**, where today they reach nothing;
- the handler returns **`false`**, so the engine's own fallback
reporting keeps running in release instead of being suppressed.
Returning `true` would claim the error as handled while our log is a
no-op in release — reported by nobody at all;
- the handler body is now **the hook a real sink plugs into**.

Getting evidence off a customer's device still needs a crash reporter or
a persisted log sink. That is a separate change and the one that would
actually close a silent-field-crash report.

## Why `false` and not `true`

This is the one design decision in the diff, so it is pinned by an
assertion in the test rather than left to prose. `true` = "handled, stop
reporting"; `false` = "log it, then let the engine report as it always
did". Since the log is compiled out of release, `true` would convert a
reported crash into total silence — the opposite of the intent.

## Test

`test/setup/error_handling/error_handlers_test.dart` — 4 cases: the
async handler is installed and returns `false`; it tolerates an empty
stack trace; the Flutter handler still delegates to the previously
installed one (the default reporting path stays intact); the on-brand
error widget builder is installed. Process-wide statics are captured in
`setUp` and restored in `tearDown` so nothing leaks into other suites.

`// @no-integration-test:` annotation added per CONTRIBUTING:205 — the
engine-side fallback reporting that runs after the handler returns
`false` is embedder behaviour and is not observable from a Dart test.

## Verification

Draft PRs skip CI in this repo, so both runs are local and this is the
only gate:

- `flutter analyze` → 1 issue, pre-existing, in generated/gitignored
code (`lib/generated/i18n.dart:55` `override_on_non_overriding_member`).
- `flutter test` → `+3019 -4`. The 4 failures are **pre-existing golden
failures** in `settings_user_data`, baseline-proven at `+3015 -4`
without this change. Isolated: `flutter test test/setup/error_handling/`
→ `+6: All tests passed!`

## Context

Came out of a customer case where an Android BitBox setup failed with a
repeated crash and we had **zero** diagnostic evidence to work from. The
transport half of that case is #866. The missing crash reporting is the
other half — this PR is the groundwork for it, not the fix.
## Summary

Phase 2 **Open CryptoPay (OCP) pay flow** client: scan a POS payment QR,
swap REALU → ZCHF (proceeds stay in the user wallet), then pay that ZCHF
to the OCP recipient — all orchestrated through `api.dfx.swiss`.

Flow (Page + Cubit per step, separate state files):

1. **Scan** — `mobile_scanner` QR scan → decode the `lightning=LNURL1…`
param (LUD-01 bech32) / `app.dfx.swiss`→`api.dfx.swiss` host fallback →
extract the `pl_…` id.
2. **Quote** — `GET /v1/lnurlp/:id` shows the requested CHF amount + the
exact ZCHF needed (read from the API `transferAmounts` Ethereum/ZCHF
entry; never computed locally). The **mainnet-only environment gate is
checked up-front here** so the irreversible swap can never start where
the pay leg cannot settle. Expired quote / no-ZCHF-method /
unsupported-environment are typed states.
3. **Process** (on confirm) — **assert the environment can settle
(before any on-chain action)** → check ETH gas (faucet + poll) → `PUT
/swap` (targetAmount = ZCHF + headroom buffer) →
`/swap/:id/unsigned-transaction` → sign → `/swap/:id/broadcast` →
**re-fetch the OCP quote** (fresh `quoteId`, guards expiry between swap
and pay) → `/pay/unsigned-transaction` → sign → `/pay/submit` → poll
`/pay/:id/status` until terminal.

Signing uses the unified raw-payload path (`signToSignature` → r/s/v)
for **both** software and BitBox wallets — the flow is not branched on
`walletType`; only the genuine non-signing capability gap (debug wallet)
is gated and surfaced as a dedicated failure state. Typed failures are
rendered as states — no error-string parsing drives control flow.

## Fund-safety semantics (two irreversible legs)

The REALU→ZCHF swap is irreversible, so the flow is hardened so the user
can never be stranded and a failed pay never double-converts REALU:

- **Environment gate before the swap.** The mainnet-only capability is
environment-static (`ApiConfig.networkMode`) and is now evaluated at the
very start of both the quote and process steps. The swap is never
signed/broadcast on an environment where `pay/*` cannot settle. The
service keeps `assertPaySupported()` on the `pay/*` calls as
defense-in-depth.
- **Pay-only retry after a successful swap.** Once the swap is broadcast
the cubit records that ZCHF was acquired; any subsequent pay-leg failure
surfaces the `PayProcessPayRetry` state whose recovery (`retryPay()`)
re-quotes + signs + submits **without ever re-swapping**. A failed pay
no longer forces a re-scan → re-swap. Mirrors the sell flow's two-leg
`SellBitboxDepositRetry`.
- **Genuine expiry vs. transient errors are distinct.** Only an explicit
`expiration.isBefore(now)` is treated as expiry; transient
fetch/submit/settlement errors route to the pay-only retry, not to a
re-scan.
- **Slippage boundary.** The swap target uses a documented 3% headroom
(was 1%). If the freshly re-fetched settlement amount still exceeds the
acquired ZCHF, a typed `PayRetryReason.insufficientZchf` retry state is
surfaced (re-quote may land within the held ZCHF; the leftover ZCHF
stays in the wallet) instead of an opaque server-side failure.

## API / decision authority

Consumes **DFXswiss/api#3819** (`feat/realunit-ocp-pay`) — pair-PR,
backend lands first. App renders API-signaled fields (`isValid`/`error`,
`requestedAmount`, `transferAmounts`, quote expiration, payment status)
and does not duplicate backend limit/eligibility logic.

**Mainnet-only limitation:** the OCP payment-link engine settles on
mainnet only; on `dev.api.dfx.swiss` (Sepolia) `pay/*` fails fast. The
client mirrors this as a typed `PayUnsupportedEnvironmentException`
keyed off `ApiConfig.networkMode` (a local environment capability gate,
not error-string parsing), surfaced before the swap as a dedicated
state.

## Parsing robustness

- `lnurlp` DTO: optional transfer-asset `amount` is parsed as nullable
(the non-priced display path emits amount-less entries), and the dead
`recipient` field is removed (a backend object, never read, that threw a
`TypeError` when populated).
- The dead `RealUnitSwapDto.fromAmount` constructor (and its
coverage-ignore) is removed; the flow only uses `fromTargetAmount`.

## Tests

- LNURL bech32 + `app`→`api` decode (unit), all pay DTOs
`fromJson`/`toJson` (incl. nullable amount + object-recipient), the pay
service (mocked `http` client, incl. `isPaySupportedEnvironment`), every
step Cubit (success + each typed failure, `fake_async` for the
ETH/status polling timers).
- New fund-safety cases: env-unsupported fails before any swap, pay-only
retry after a successful swap, transient-fetch error → retry (not
re-scan), insufficient-ZCHF-after-swap typed state, and `retryPay` never
re-swaps.
- Typed exceptions enumerated in `exception_surface_test.dart`; i18n
`payRetry*` keys in both ARB files.
- Dashboard golden (third **Pay** action button) unchanged and green.

Issue: #666
## Summary

Phase 2 **Baustein 3 — RealUnit wallet-to-wallet (W2W) transfer**: send
REALU to another wallet, recipient picked via QR scan or manual entry.
Implements #684 (umbrella #666).

The transfer is **gasless via EIP-7702** — DFX pays gas from a dedicated
W2W gas wallet — so the app signs an **EIP-712 delegation + an EIP-7702
authorization**, exactly like the existing SOFTWARE gasless sell confirm
(`real_unit_sell_payment_info_service.dart`). It reuses
`eip712_signer.dart` / `eip7702_signer.dart` and the wallet unlock/lock
boundary; it is **not** the bitbox raw-tx path.

Flow (Page + Cubit per step, separate state files):

1. **Recipient** — scan a wallet QR or paste/type an EVM address;
client-side checksum validation for UX only (the API is the final
authority). An `ethereum:` EIP-681 URI is normalized to the bare
address.
2. **Amount** — whole REALU shares (REALU `decimals = 0`); the available
balance is read via the shared balance watcher and the over-balance
guard is local UX only.
3. **Confirm** — review recipient + amount.
4. **Process** — capability gate (software-only signing) → `PUT
/transfer` → sign EIP-712 delegation + EIP-7702 authorization → `PUT
/transfer/:id/confirm` → success (`txHash`) / typed failure.

Typed failures rendered as states (no error-string parsing): unsupported
wallet (debug/BitBox), signature cancelled, invalid request (API 400/404
— invalid recipient / self-transfer / token-contract recipient /
insufficient REALU), and gas-funding-unavailable (API
`ServiceUnavailable` 503 → friendly "temporarily unavailable", REALU
untouched).

## Scanner reuse (no duplication)

The scanner from #674 was an inline `MobileScanner` in `PayScanPage`.
Extracted a shared `lib/widgets/scanner/qr_scanner_view.dart` (the
camera/MethodChannel wrapper) and refactored both the pay scan page and
the new send recipient page onto it — each flow keeps its own decode
logic (LNURL vs EVM address). No scanner code is duplicated.

## API / decision authority

Consumes **DFXswiss/api#3820** (pair-PR, backend lands first): `PUT
/v1/realunit/transfer` + `PUT /v1/realunit/transfer/:id/confirm`. The
app renders API-signaled outcomes and does not duplicate backend
KYC/registration/limit/eligibility logic.

## Branch / stacking

Branched **from `feature/ocp-pay-flow` (#674)** to reuse the scanner
without duplication; PR base is **`staging`**. **Stacked on #674
(scanner) — review/merge #674 first; this diff will shrink once #674
merges to staging.**

## Tests / gates

- `flutter analyze`: 0 issues.
- `flutter test --coverage --exclude-tags golden`: all pass; **100%
scoped coverage** on every new file (no `coverage:ignore`).
- `flutter test --tags golden`: golden tests + baselines for the new
screens.
- `dart format` (repo config: page_width 100, trailing_commas preserve):
clean.

**Goldens regenerate pending on the runner:** baselines here were
rendered locally on macOS and will mismatch the CI runner; the Golden
Regenerate workflow is being dispatched so the runner pushes
authoritative baselines.

Stays **Draft** (no ready-for-review, no merge).
…rt (#875)

## Problem
`mobile_scanner 5.2.3` depends on GoogleMLKit, whose frameworks ship
**no arm64-iphonesimulator slice**. On GitHub's arm64 macOS runners the
iOS 26 simulator is arm-only (Apple removed Rosetta for the iOS 26
simulator), so the app could neither build nor run there — breaking the
`tier3-handbook` CI with `Framework 'Pods_Runner' not found`. This is
what red-flags the staging→develop promote.

> Note: `mobile_scanner` was reintroduced in #674 at `^5.2.3` in pubspec
but without a matching `pod install` / `ios/Podfile.lock` commit,
leaving the lockfile out of sync. This PR both bumps the version (5.2.3
→ 7.4.0) and restores lockfile consistency.

## Fix
Upgrade `mobile_scanner` to **7.4.0**, which drops GoogleMLKit for
Apple's native **Vision** API → the app builds and installs/launches
**natively on arm64** iOS 26.5 simulators.

- Migrate the two `errorBuilder` call sites to 7.x's 2-arg signature
(dropped the unused `Widget? child`).
- `QrScannerView` now supplies a compact, textScale-safe **default**
error placeholder (icon-only) so 7.x's taller default no longer
overflows `send_recipient_page`'s bounded `Expanded` at large
accessibility text sizes.
- Add a no-op stub for mobile_scanner 7.x's new `deviceOrientation`
event channel in the golden-test helper, plus a focused widget test
asserting the default placeholder stays overflow-safe at
`TextScaler.linear(3.0)`.

## Verification
- `flutter build ios --simulator --debug` → succeeds; `simctl install` +
`launch` on iPhone 17 / iOS 26.5 → succeeds (no arch error, app
renders).
- `flutter analyze` clean; `flutter test --exclude-tags golden` → all
pass.
- Golden baselines regenerated on the self-hosted runner (one changed:
the send scanner error icon).
- Tier 3 handbook flows (iOS build + 26 Maestro flows) and RealUnit
Build (analyze/test, visual regression, coverage, BitBox) both green on
the branch head.

## Note on the scanner backend
The iOS scan backend changes GoogleMLKit → Apple Vision. The app only
consumes `Barcode.rawValue`, so Dart-side handling is unchanged.
QR-decode parity between the two backends is architecturally expected
(both fully support the QR symbology) but has **not** been verified
against a live camera — the scanner is `@no-integration-test` and cannot
run in CI, so a device smoke-test (bare address, checksummed address,
`ethereum:` URI, LNURL) is worth doing before release.

---------

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

Adds a payment-deeplink entry point so an incoming
`realunit-wallet:lightning:<LNURL>` URL is routed straight into the
existing OpenCryptoPay settlement flow (`PayScanCubit.onCodeDetected` →
`LnurlDecoder` → REALU→ZCHF swap → ZCHF transfer).

Until now that flow was reachable **only** by manually scanning a QR
code from the dashboard "Pay" button; the `realunit-wallet://` scheme
only foregrounded the app with no payload. This wires the missing
external entry point.

## Why

So DFX payment-link pages (e.g. `/pl`) can list RealUnit as a wallet and
open it directly with a pre-filled payment. Companion to the backend
listing in **DFXswiss/api#4339** (adds the `wallet_app` row with
`deepLink='realunit-wallet:'`, from which the frontend builds
`realunit-wallet:lightning:<LNURL>`).

## How

- `extractPaymentDeeplinkPayload` (string-level, so it handles both the
opaque `realunit-wallet:lightning:<LNURL>` and a `//`-normalized
variant) strips the scheme and feeds `lightning:<LNURL>` (or a bare
`LNURL…`/https lnurlp URL) verbatim into `onCodeDetected`. No
pre-validation — a malformed payload is left for `LnurlDecoder` to
reject (no silent fallback).
- **Warm resume:** the redirect itself still returns `null` (a true
no-op — match list / back-stack / `extra` untouched); the actual
navigation is a deferred imperative `pushNamed('/pay')`, so it lands on
top without clobbering a pushed route.
- **Cold start:** the payload is stashed and replayed as a `/pay` push
**only** once the boot/unlock ladder lands on the dashboard — i.e.
strictly after the same PIN/biometric gate a normal `/pay` navigation
passes. It can never bypass the lock.
- `/pay` accepts an optional `initialPayload` via a **guarded**
`state.extra` cast (never an unchecked cast); when present it skips the
live camera and feeds the cubit once.
- Preserves the documented `app_link_entry.dart` hardening for all
non-payment scheme URLs. **No** iOS/Android manifest or entitlement
changes — the `realunit-wallet` scheme is already registered; no new
deeplink package.

## Tests

16 new widget/unit tests: payload-extraction forms (single-colon, `//`,
bare LNURL, https lnurlp, canonical open→null, path-carrying→null,
non-scheme→null); warm-resume push without back-stack clobber; canonical
open never pushes `/pay`; cold-start stash +
replay-only-after-dashboard; guarded non-String extra → null;
`initialPayload` feeds the cubit once and skips the camera. `flutter
analyze` clean; full suite green.

## Notes

- **Staging-only feature.** The OpenCryptoPay pay flow
(`LnurlDecoder`/`PayScanCubit`/`/pay`) currently lives only on
`staging`; this builds on it.
- **Device smoke test recommended before release:** OS-level
custom-scheme delivery + a real LNURL round-trip are
`@no-integration-test` (cannot run in CI).
- The two-line diff in `assets/languages/strings_{de,en}.arb` is an
incidental normalization produced by the mandatory
`generate_localization.dart` codegen step (it split two entries that
were on one line); no localization key or value was changed.
## Summary

- inject the repository `SENTRY_DSN` into Android and iOS store builds
with an explicit `production`/`internal` environment
- keep compile-time values out of Fastlane process arguments via a
mode-0600 temporary define file and fail release builds when the DSN is
missing
- upload Android split debug info, Dart obfuscation maps, and iOS
archive symbols to the self-hosted Sentry project
- identify uploads with the native release name
`swiss.realunit.app@<version>+<build>` and matching distribution

The Sentry project `realunitchapp` and the repository secrets
`SENTRY_DSN` and `SENTRY_AUTH_TOKEN` have been provisioned. Runtime
reporting activates together with #878. Related to DFXServer/server#958.

## Validation

- Ruby syntax checks for both Fastfiles
- workflow YAML parse and `git diff --check`
- `flutter analyze --no-fatal-infos`
- non-golden Flutter suite: 4,687 tests passed; the 16 subprocess cases
initially failed only because local `dart` was absent from PATH and all
16 passed when rerun with the Flutter SDK path
- DFX PR precheck: 3 rounds, final conformity and logic reviews both at
0 findings

## Follow-up verification

The first tagged native release should confirm the uploaded debug files
in Sentry and symbolicate one controlled test event.

---------

Co-authored-by: Daniel Padrino <danswarrior1@gmail.com>
…main (#877)

## Problem

BitBox users cannot complete the RealUnit registration: on submit, the
BitBox02 (Nova) rejects the signing request and shows **"typed data has
no chain ID"** on the device (first affected customer: userData 412822,
23.07.). The firmware requires a `chainId` member in the EIP712Domain;
our registration payload signs the chainId-less domain `{ name:
'RealUnitUser', version: '1' }`. Software wallets sign it regardless,
which is why this never surfaced.

## Change

- `Eip712Signer.signRegistration` includes `chainId` (value:
`apiConfig.asset.chainId`, already plumbed through) in the EIP712Domain
**for BitboxCredentials only**.
- Software wallets keep the legacy domain — pinned by the existing
golden-signature test, so this path provably does not change.

## Pair PR

**DFXswiss/api#4542 — merged and live on production since 2026-07-31
16:47Z.** Verification there accepts both domain variants, trying the
legacy one first, so software wallets are untouched. The API side is
already deployed, so this PR no longer has a merge-order prerequisite.

(The original pair PR #4354 was closed unmerged and superseded by
#4542.)

## Validated end-to-end on production

Run on 2026-07-31 with a real BitBox02 Nova (`bb02p-multi`, main
firmware v9.26.4 — the build measured to refuse the chainId-less
envelope) on an iPhone 17e, against `api.dfx.swiss`:

| hop | evidence |
|---|---|
| device signs over BLE | no "typed data has no chain ID" screen, no
NACK |
| DFX accepts the extended domain | `[RealUnitService] RealUnit
registration signature matched chainId 1 domain / …` at 16:54:28Z |
| forward to Aktionariat | `POST /v1/realunit/register/complete → 201`,
no `Failed to forward RealUnit registration` |
| persisted | `aktionariat_registration` row → `status = Completed`,
`active = true` |

This is the scenario the earlier review asked for real-firmware data on:
the same device and firmware that produced the NACK now completes a
registration end to end.

Note on scope of that evidence: `Completed` is written after
Aktionariat's `/registerUser` returns 2xx. It proves their registration
endpoint accepted the forwarded payload; it does not by itself prove
which step re-verifies the EIP-712 signature on their side, so a later
step exercising the signature is still worth watching.

## Tests

- new: BitBox path signs with `domain.chainId` + `chainId` member in
EIP712Domain types
- existing golden signature (software wallet) unchanged → legacy path
frozen
- wallet package + registration service suites: 83/83 green (62 + 21);
`flutter analyze`: no issues

---------

Co-authored-by: Daniel Padrino <danswarrior1@gmail.com>
Promote: staging -> develop
#881)

## Problem

The v1.2.4 release job failed in `ios-deploy` at the `Upload to
TestFlight` step ([run
30658246443](https://github.com/RealUnitCH/app/actions/runs/30658246443/job/91248015830)).
Android shipped, iOS did not, and `github-release` was skipped.

```
Warning: CocoaPods is installed but broken. Skipping pod install.
CocoaPods not installed or not in valid state.
Exit status of command 'flutter build ios --config-only --release ...' was 1
```

## Root cause

The Sentry DSN injection added a `flutter build ios --config-only` call
inside the `beta` lane. Fastlane runs under `bundle exec`, and Bundler
exports its environment (`RUBYOPT=-rbundler/setup`) to every child
process. CocoaPods is not part of `ios/Gemfile`, so the `pod` that
Flutter shells out to aborts with `cocoapods is not currently included
in the bundle`, and Flutter fails the build.

The workflow's own `Setup Pods` step is unaffected — it runs outside
`bundle exec`, which is why it succeeds 19 seconds earlier in the same
job on the same machine.

## Fix

Wrap only the Flutter call in `Bundler.with_unbundled_env`. `gym` keeps
running inside the bundle: the `xcodebuild` it runs never invokes `pod`,
and that path has been shipping releases unchanged.

Also ignores `android/{.bundle,vendor/bundle}/` and
`ios/{.bundle,vendor/bundle}/` — the gem bundle a local lane repro
installs with the configuration CI uses (`ruby/setup-ruby` with
`bundler-cache: true` → `path vendor/bundle`, `deployment true`), which
otherwise leaves the working tree dirty.

## Verification

Reproduced and fixed locally against the failing commit (`2d0d454`),
Flutter 3.41.6, with the bundle installed using CI's own configuration:

| Case | Result |
| --- | --- |
| `flutter build ios --config-only` outside bundler (control) | passes —
`Running pod install... 952ms` |
| `bundle exec pod --version` | fails — `cocoapods is not currently
included in the bundle` |
| the same wrapped in `Bundler.with_unbundled_env` | passes — `1.17.0` |
| real `bundle exec fastlane` running the shipped code | fails exactly
as CI does |
| the same with this patch | passes — `fastlane.tools finished
successfully` |

The reproduction requires CI's step order: after a standalone `pod
install`, Flutter still considers the Pods stale and runs its CocoaPods
check, which is where it aborts.

`Generated.xcconfig` was checked after the patched run —
`FLUTTER_BUILD_NAME`, `FLUTTER_BUILD_NUMBER` and the base64
`DART_DEFINES` for `SENTRY_DSN` / `SENTRY_ENVIRONMENT` are all present,
so the DSN injection still does what it was added for.

Not covered locally: `gym`, the archive and the TestFlight upload need
signing material. The next tagged release is the real confirmation.
Promote: staging -> develop
## Summary

Soft launch for the two new phase-2 features: the **Pay**
(OpenCryptoPay, #674) and **Send** (W2W transfer, #687) dashboard
actions are hidden behind an invisible wall so only insiders can reach
them.

- The two dashboard buttons render only when a persisted
`insiderFeaturesUnlocked` flag is set; Buy and Sell stay visible
unconditionally.
- Unlock: tap the version number in Settings **seven times**
(developer-options pattern). A snackbar confirms the unlock; the flag
persists across restarts (SharedPreferences, seeded into
`SettingsBloc`).
- The OpenCryptoPay payment deeplink intentionally keeps working
regardless of the unlock state, so payment links handed to insiders
resolve as before.

## Deliberate deviation from the API-authority rule (reviewed,
intentional)

CONTRIBUTING lists "feature visibility based on local state" as not OK
and prefers an API capability flag. This gate deviates from that on
purpose, as a product decision made with the API-capability alternative
on the table:

- The point of the soft launch is that outsiders must not even *see* the
features, and the unlock must work offline/instantly for anyone told the
gesture — an account-bound API capability would change the product
(server-side insider bookkeeping, no gesture unlock).
- No API truth is duplicated or contradicted: there is no server-side
notion of this soft launch, and the API remains the sole decision
authority for every actual transfer/payment the flows perform. The
unlocked app renders exactly what the API-authorized app rendered before
this PR; the locked app renders a subset.
- Being a public repo, the mechanism is readable in source — the wall is
a discoverability hurdle, not a security boundary.

## Implementation notes

- The Settings version row keeps its exact visuals; it moves into a new
`SettingsVersionUnlock` widget that follows the page-local `getIt`
bloc-access pattern (its test harness deliberately mirrors the settings
golden harness structure).
- `DashboardActions` gates Pay/Send with collection-`if`s on
`context.watch<SettingsBloc>()`.
- `ActionButton` now scales its icon/label column down (`FittedBox`)
instead of overflowing its fixed 110x50 box — the new actions matrix
exposed real overflows under Expanded width squeeze (German labels, 2px
at 1.0x on narrow devices) and at large text scales (up to 258px at
3.0x). The tap area stays the full box (the `InkWell` wraps it, not the
scaled content). Layouts that fit are visually unchanged; the four
positive-balance dashboard goldens picked up sub-pixel antialiasing
deltas (77-107 bytes each) from the new render path and were regenerated
by the runner.
- `expectFullyTappable` maps both rect corners through the render
transform, so scaled targets measure their visual rect
(transform-neutral for every existing call site).
- The repository setter follows the established fire-and-forget
persistence idiom; the repo-wide hardening idea is tracked in #886.
Pre-existing positive-balance dashboard overflows (CashHoldingBox and
siblings) are tracked in #887 and deliberately not part of this PR.

## Handbook

Section 79 of the handbook (/de/#insider-unlock) explains the unlock
step by step in German with three screenshots (settings version row,
dashboard before, dashboard after) so the link can be shared directly
with the people who should know. The new dashboard_insider_unlocked
golden is mapped as handbook screenshot slot 269; the three updated
dashboard baselines were already mapped and refresh automatically on the
next handbook deploy.

## Tests

- New widget tests for the 7-tap unlock (6 taps inert, 7th dispatches
exactly one event + snackbar, 9 rapid taps still dispatch exactly once,
taps ignored once unlocked, version text still rendered)
- `DashboardActions` locked/unlocked cases incl. the existing navigation
assertions, plus a locked→unlocked transition test that pins the
`context.watch` rebuild behaviour
- New golden case `dashboard_insider_unlocked` (renders the same
four-button dashboard the pre-PR baseline showed); existing dashboard
goldens change to the 2-button locked default
- New responsive-matrix group renders `DashboardActions` standalone
(insider unlocked, all four buttons) across the full device/text-scale
grid with overflow + tappability gates — scoped to the actions row this
PR owns; the zero-balance page matrix is unchanged
- Repository getter/setter covered against a real SharedPreferences
backend (100% lines floor on `lib/packages/*`)
- Full suite on the verification host: 4755 tests green, analyzer clean

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Removes the KYC onboarding dead ends: adds the missing `PersonalData`
step page, and replaces the generic "cannot be completed in this app"
failure screen with an actionable handoff. Covers items 4a and 5 of
DFXswiss/api#4556, and the dead-end class first flagged as K1/K5 in
#613.

## Problem

Registration satisfies `PersonalData` without the user ever seeing it,
so the app has never had a page for it. But the step **re-opens** when
identification rejects the submitted data as not matching the document:
the API fails the completed step and opens a fresh one *specifically so
the account can correct it*.

The app could not render that step. Instead of a correction form the
user got the unsupported-step failure screen — "The current KYC step
(PersonalData) cannot be completed in this app" — and onboarding
dead-ended with no way forward and no way back. It cannot self-heal: the
open step blocks the API from opening any later one.

This is not a corner case. It is the standard identification
data-mismatch flow, and it recurs every time a submitted name or address
does not match the document.

## Change

- `KycStep.personalData` + the `_mapStepName` arm, so the step routes to
a page instead of the failure screen.
- `KycPersonalDataPage` — first/last name, phone, street, house number,
postcode, city, country. Same field widgets, validators and layout as
the registration address/personal steps.
- `KycPersonalDataCubit` submits through the generic `setData` PUT
already used by the nationality and settings address/name flows,
building the body from the existing `KycPersonalData`/`KycAddress`
models.

## Guards, and why each exists

- **Never offered to a non-personal account.** Submitting this form sets
`accountType`, and the API explicitly nulls all six organization columns
whenever that value is `Personal` (`user-data.service.ts`, the
`isPersonalAccount` branch) and drops five org-only steps from
`requiredKycSteps`. The page reads the account type from the
registration payload and refuses to render for anything but personal;
the cubit sends exactly the value the page gated on, so the two cannot
drift.
- **Seeded, not blank.** The copy asks the user to check their details
and every submit rewrites all eight fields, so an empty form would force
a from-memory re-entry in which a typo silently overwrites data that was
already correct.
- **A missing payload gets a retry, not a dead end** — mirrors
`KycLinkWalletPage`'s defensive refresh surface, and has its own golden.
- **A late country lookup never overwrites a country the user already
picked.** The page and `CountryField` issue independent `GET
/v1/country`s and the service does not de-dupe in-flight calls, so
either can win.

## The generic dead end

`_mapStepName` renders 6 of the 24 step names the API can return. Every
other one produced `KycUnsupportedStepFailure`, which rendered the
generic failure page — `actions: const []`, a true dead end — with the
step's raw wire identifier printed into the message. The user had
nothing to do and nothing useful to tell support.

`KycUnsupportedStepPage` replaces it with a retry and a route to
support, and names no step. The retry is not decorative: for a step
under internal review or one the API advances by itself, re-reading is
the only way the user finds out. For a genuinely unrenderable step it
re-emits the same state, which is honest — the copy and the support CTA
are what move that case forward.

The identifier is gone deliberately. It is an internal enum value, and
DFX support reads the same step server-side, so nothing diagnosable was
lost. The personal-data organization refusal renders this same page, so
there is one answer to "this step cannot be shown here" instead of two.

This covers `Recommendation`, `ResidencePermit` and every future step
name at once. Prod data shows no RealUnit account currently behind
`ResidencePermit`, and only a very small latent population behind
`Recommendation`, so dedicated forms for them would have been the more
expensive way to fix less.

## Shared-widget fix

`PhoneNumberField` left `prefix` null whenever a seeded value did not
start with a dial code it offers. Its prefix dropdown carries no
validator, so `Form.validate()` returned true while
`updatePhoneNumber()` silently refused to write — the stale number was
submitted instead of what the user typed. It now falls back to the first
prefix; the number field starts empty, so the validator still blocks
submit until it is re-entered.

This also fixes the registration prefill (`kyc_registration_page.dart`),
which seeds `dto.phoneNumber` unconditionally and is the path that
provably carries arbitrary dial codes.

## Verification

- `flutter analyze` — no issues. `flutter test --exclude-tags golden` —
**4723 passed**.
- 4 cubit tests, 17 widget tests, 3 goldens, a `kyc_page_manager` case
pinning both hops of the payload plumbing, and both new sticky-CTA
surfaces registered in the responsive catalog with full device ×
text-scale matrix coverage.
- Every guard mutation-checked: dropping the account-type gate, the
retry branch, the prefill, the url plumbing, either plumbing hop, the
country-lookup catch, the racing-pick guard, the `PhoneNumberField`
fallback, the handoff page, its retry, its support route, or moving the
support CTA out of the sticky block each turns the suite red.
- Toolchain matched the CI pin (Flutter 3.41.6); golden baselines
produced by `golden-regenerate.yaml` on the self-hosted runner, never
locally. The last regeneration after the shared-widget change committed
**no** baseline, confirming it is behaviour-neutral for every state
under test.

## Two existing tests were updated, not worked around

`kyc_cubit_test.dart` used `personalData` as its stand-in for "a step
name with no UI mapping" — that premise is now false, so it uses
`statutes`. `kyc_bitbox_create_wallet_states_test.dart` pinned the
`KycStep` enum at ten variants; it now pins eleven.

## Not in scope

Reporting an unmapped step to telemetry. The app has no runtime SDK —
`sentry_dart_plugin` is a dev-dependency that only uploads symbols, and
there is no `Sentry.capture*` anywhere in `lib/`. Adding one is a
product decision, not something to fold in here. Worth doing: this class
of failure is invisible in monitoring today, because every response
involved is a 200.

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
…TRY_DSN gate (#878)

## Problem

Two customer cases this week reached support with **zero diagnostic
evidence**: a BitBox02 USB connect crash/hang on Android and a
registration-signing blocker (both 23.07.). Server-side request tracing
covers everything that reaches the API — but a client-side crash before
any request is invisible. The app has no crash reporter; every error
thrown from a `Future`, `Stream` or `Timer` callback vanishes without
trace (#866 documents this gap explicitly, and `installErrorHandlers()`
was designed as the hook a reporter plugs into).

## Change

- **`sentry_flutter` behind a compile-time gate.**
`initCrashReporting()` (new,
`lib/setup/error_handling/crash_reporting.dart`) reads
`--dart-define=SENTRY_DSN`. Without an injected DSN — i.e. in every
local, test and current CI build — the SDK never starts and produces no
network traffic. The app behaves exactly as before this PR.
- **Best-effort by contract:** a malformed DSN or a failing native
binding is logged and swallowed inside `initCrashReporting()` —
reporting infrastructure can never keep the wallet from starting.
Covered by a dedicated test.
- **Wiring order is load-bearing:** `main()` calls
`initCrashReporting()` *after* `installErrorHandlers()`, because
`installErrorHandlers()` overwrites `PlatformDispatcher.onError` without
chaining — the reverse order would silently drop the SDK's async-error
hook. The SDK itself chains both handlers it wraps
(`FlutterError.onError`: capture, then delegate;
`PlatformDispatcher.onError`: delegate, then capture). In release mode
the native splash is now preserved *before* this first await, so the
added async gap cannot flash the splash to blank.
- **Pinned option surface** — the guarantee is exactly this list:
`sendDefaultPii=false`, `attachScreenshot=false`,
`enableAutoSessionTracking=false`, `tracesSampleRate=null` — error
events only, no session telemetry. Native crash handling and ANR
detection deliberately stay on their SDK defaults: they produce
precisely the error events this reporter exists for. View-hierarchy
attachment stays off by SDK default; its option is experimental and
deliberately not referenced. Environment defaults to `production`,
overridable via `--dart-define=SENTRY_ENVIRONMENT`.
- **CONTRIBUTING § API Access:** adds the one scoped exception for
first-party crash reporting, and declares any widening of the reported
data (breadcrumbs with request URLs, user context, attachments) a
review-blocking change.

## Tests

- `test/setup/error_handling/crash_reporting_test.dart`: DSN gate (no-op
without DSN, exactly one init with DSN), the full pinned option set, and
the swallow-on-failure contract, via the injectable `CrashReporterInit`
— no platform channels involved.
- `// @no-integration-test` annotation on `initCrashReporting` per
CONTRIBUTING: the native SDK only starts in builds that inject a DSN,
which no test build does.

## Open points before this becomes effective

1. **Release pipeline:** the release workflow must inject `SENTRY_DSN`
(repo/environment secret + `--dart-define`) — deliberately a separate
PR, so this one stays inert and fully reviewable on its own.
2. **Native release build check:** PR CI runs analyze + tests; the first
tagged build after the pipeline change should be smoke-checked once
(Android Gradle / iOS pods pull in the native SDK parts).
3. **Follow-up (separate PR):** attach BitBox device context (product,
firmware version) and connect-flow breadcrumbs after connect, so
hardware-related crashes carry the device facts that today require
asking the customer.
## Summary
Follow-up to [#746](#746). That PR
relabelled the Settings wallet action to **"Reset wallet" / "Wallet
zurücksetzen"** and switched the confirm button to the existing `reset`
key. The generic `logout` key — whose only consumer was that button — is
now unreferenced.

## Change
- Remove `"logout"` from `assets/languages/strings_de.arb` ("Abmelden")
and `strings_en.arb` ("Logout").

## Verification
- `S.of(context).logout` has **0 call sites** across `lib/` and `test/`
(`isLogout` in `settings_page.dart` is an unrelated local bool, not the
key).
- ARB stays valid; DE/EN key parity preserved (358 = 358).
- No widget renders the string, so **no goldens change** and `Visual
Regression` is unaffected.

## Test plan
- [ ] `Analyze & Test` green
- [ ] `Visual Regression` green (no baseline change expected)
- [ ] `Coverage Floor Gate` green
Promote: staging -> develop
## Summary

Follow-up to #885: section 79 (insider unlock explainer) inherited the
standard `cols-2` test grid, which squeezes the three-image walkthrough
into a half-width column — the images stack vertically and the card
grows very tall (see the deployed page). Switching the section to the
base `.tests` class (an existing single-column grid) lets the explainer
run full width with its three images side by side, and the
`269-dashboard-insider-unlocked` catalog entry follows below.

One attribute change plus an HTML comment explaining the deliberate
deviation from the otherwise uniform `cols-2` sections.
…890)

## Was fehlte

Support-Tickets aus der RealUnit-App konnten keinen Anhang tragen,
während die DFX-App das für beide
Wege längst anbietet. Die API kann es ebenfalls seit Langem:
`CreateSupportIssueBaseDto` erbt `file`
(Base64-Data-URI) und `fileName` von `CreateSupportMessageDto`, und
`createMessageInternal` erlaubt
ausdrücklich eine Nachricht, die **nur** eine Datei trägt. Gefehlt hat
allein die App-Seite.

## Was dieser PR macht

- `DfxSupportService` schickt `file`/`fileName` an `POST
/v1/support/issue` und
`POST /v1/support/issue/{uid}/message` und lässt beide Felder weg, wenn
kein Anhang gewählt ist.
- Das Ticket-Formular bekommt ein Anhang-Feld, das der vorhandenen
Formular-Optik folgt
(Abschnittslabel wie darüber, Radius und Rahmen wie das Nachrichtenfeld)
und den Anhang wieder
  entfernbar macht.
- Die Chat-Eingabe bekommt einen Anhang-Button samt Vorschauzeile. Ein
Bild **ohne** Text zu senden
  ist erlaubt — genau das, was die API zulässt.
- Nachrichten mit Anhang zeigen den Dateinamen; ohne das wäre eine
Nachricht ohne Text eine leere
  Sprechblase.
- Bilder kommen über den vorhandenen `ImagePickerSheet` und
`XFile.toBase64DataUri()`, also denselben
  Weg wie die KYC-Uploads. Keine neue Abhängigkeit.

## Verifikation

Gegen `dev.api.dfx.swiss`, aus der laufenden App im iOS-Simulator:

- Ticket mit Bildanhang → `201`, die Nachricht trägt `fileName`.
- Chat-Nachricht **nur mit Datei, ohne Text** → `201`, `message: null`
plus `fileName`.

Dazu drei Gegenproben direkt an der API, die belegen, dass beide
Implementierungsentscheidungen
tragen: `file` ohne `fileName` → 400, rohes Base64 statt Data-URI → 500,
weder Text noch Datei → 400.

Lokal: `flutter analyze` ohne Befund, volle Suite **4754** grün,
gescopte Zeilen-Coverage **100,0 %**
(6430/6430) gegen einen Floor von 100.

## Bewusst nicht enthalten

- **Empfangene Anhänge werden nicht angezeigt oder heruntergeladen.**
Sichtbar ist nur der Dateiname.
`SupportMessageDto` liefert keine URL; der Download-Endpunkt existiert,
das gehört aber in einen
  eigenen Schritt.
- **Nur Bilder, kein PDF.** `image_picker` deckt Bilder ab; PDF bräuchte
ein zusätzliches Paket.
  Die DFX-App erlaubt PDF, die API auch.
- **Anhang ohne Text** ist im Chat erlaubt, beim Erstellen eines Tickets
weiterhin nicht — dort bleibt
ein Text Pflicht. Die API erlaubt beides; das ist eine
Produktentscheidung, keine technische Grenze.
- Scheitert nach erfolgreichem Senden das Nachladen des Tickets, gilt
das Senden als erfolgreich
(die Nachricht liegt beim Support), die neue Nachricht erscheint aber
erst beim nächsten Laden.
- `FilePickerField` (KYC) und das neue `SupportAttachmentField` teilen
sich Struktur, wurden aber
bewusst nicht zusammengeführt: Die KYC-Seiten sollen ihre Optik
behalten.

## Offen

Golden-Baselines der zwei geänderten Support-Screens werden über
`golden-regenerate.yaml` auf dem
Runner erzeugt; bis dahin ist `Visual Regression` erwartungsgemäß rot.

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Promote: staging -> develop
…od deploys (#892)

## Summary

Two handbook changes following up on #885/#889:

### Walkthrough layout (user feedback on the live page)

The three images of the insider-unlock explainer rendered neither
cleanly side by side nor stacked: the flex items are caption-driven
wrapper divs, so two images filled the row and the third wrapped below.
The block now shows the **instructions first**, then the three images in
an equal-thirds grid with captions wrapping under their image; below
700px the walkthrough stacks as full-width rows (matching the page's
existing narrow-viewport behaviour, verified against a neighbouring
section). Desktop and mobile renders were verified in a local browser
before pushing; the final CSS-cascade cleanup is render-identical
(byte-equal screenshots).

### Deploys: staging → production, DEV retired

Every push to `staging` now deploys directly to handbook.realunit.app
(`:latest`, PRD secrets). The `deploy-dev` job, the `develop` trigger
and the `:beta` tag are removed, and the `paths:` filter is dropped
deliberately: every staging merge ships, which also re-stages the
api-/web-sourced handbook content the filter could never observe. All
prose that described the old staging→DEV/develop→PRD split (workflow
comments, both READMEs, three spots inside the handbook page itself) now
describes the single production lane, and the build-check comments state
precisely what that check gates (screenshot-assembly, store-listing
sync, legal-sync, image build + container smoke) versus what only the
deploy run exercises (SSH/secrets/rollout).

The retired dev-handbook instance keeps serving its last state until it
is decommissioned on the infrastructure side (container + DNS) —
intentionally out of scope for this repo. The four `DEPLOY_DEV_*` repo
secrets become unreferenced with this PR and can be deleted afterwards.

## Erratum

Commit `b571848f` references issue #893 in its message; the intended
reference is **#894** (pre-existing: the build check's asset smoke
accepts 401 as existence proof, which the auth gate makes meaningless —
found during the review passes here, tracked separately). Amending a
pushed commit is out per repo policy, hence this note.
Folge-PR zu #890. Behebt die fünf nicht-blockierenden Findings aus der
Review dort

(#890 (review)).

**Not symptom-driven:** Kein Nutzerfall, keine Störung in Produktion.
Auslöser ist die Review auf
#890 — ein Approve mit fünf ausdrücklich nicht-blockierenden Findings,
die dort nicht mehr
liegenbleiben sollten. Jedes Finding wurde vor dem Bauen am gemergten
Stand nachgeprüft; keines
beruht auf einer Weitergabe der Review-Formulierung.

**Scale:** Finding 1 trifft die Alt-Texte von fünf Handbuch-Bildern, die
beim nächsten Deploy
ausgetauscht werden — die Fläche für alle, die die Screenshots nicht
sehen können. Finding 2:
der befüllte Anhang-Zustand war in **0** der 35 Matrix-Zellen abgedeckt.
Findings 3+4: zwei
Aufrufstellen von `Image.file`, beide ohne Fehlerbehandlung. Finding 5:
eine Zeile.

**Smaller fix considered:** Zu 1 nur die `alt`-Attribute statt Alt und
Beschreibung — verworfen,
die Beschreibungen zählen die Formularfelder auf und wären danach weiter
falsch. Zu 2 eine
einzelne Zelle statt der vollen Matrix — verworfen, die bestehende
Gruppe fährt die volle Matrix
und die Kosten sind identisch. Zu 3+4 `errorBuilder`/`cacheWidth` an
beiden Stellen duplizieren
statt zusammenzuführen — verworfen, das ist genau das
Auseinanderdriften, das den doppelten Fix
erst nötig gemacht hat. Zu 5 ist die eine Zeile der Fix.

## Was drin ist

- **`FilePreviewField`** (`refactor`): `SupportAttachmentField` und
`FilePickerField` trugen einen
fast zeilengleichen Rumpf. Beide sind jetzt dünne Hüllen um ein
gemeinsames Widget und behalten
ihr eigenes Label und ihre eigene Optik. `Image.file` existiert dadurch
**einmal** statt zweimal
und bekommt dort einen `errorBuilder` (sichtbarer Platzhalter plus
Logzeile statt grauer Box) und
`cacheWidth`, damit ein 48-px-Thumbnail nicht in voller Auflösung
dekodiert wird.
- **Matrix mit befülltem Anhang** (`test`): zweite Gruppe über dieselbe
Geräte-×-Textskalen-Matrix,
mit langem Dateinamen, plus Prüfung, dass das 48×48-Schliessziel tappbar
bleibt.
- **Getrimmten Text senden** (`fix`): `sendMessage` entschied auf
`trimmed`, verschickte aber den
rohen `message` — führende und nachlaufende Leerzeichen landeten beim
Support. `submit()` im
Ticket-Pfad zieht mit: dort prüfte `canSubmit` längst
`message.trim().isNotEmpty`, gesendet wurde
trotzdem der rohe Text. Genau dieses Gleichverhalten war TaprootFreaks
Grund, den Chat-Befund
als Lesbarkeitsnotiz statt als Defekt zu führen — es wäre
kaputtgegangen, hätte nur der Chat
  getrimmt.
- **Handbuch** (`docs`): Beschreibungen und Alt-Texte für
250/251/252/258/259 kennen jetzt das
  Anhang-Feld und die Büroklammer.

Finding 3 hatte TaprootFreak ausdrücklich als „später" markiert; es ist
auf Wunsch mit drin, weil
es Finding 4 auf eine einzige Stelle reduziert.

## Verifikation

`flutter analyze` ohne Befund. **596** Tests grün über
`test/widgets/form`, `test/screens/support`,
`test/screens/settings_user_data` und die Goldens beider Flächen, dazu
**19** Golden-Tests der
Support-Fläche inklusive des neuen.

Gegenproben, alle nachgefahren:

| Mutation | Ergebnis |
|---|---|
| `trimmed` → `message` im Chat zurückgedreht | **1 von 20** Cubit-Tests
rot |
| `.trim()` im Ticket-Pfad entfernt | **1 von 80** Support-Cubit-Tests
rot |
| `Expanded` um den Dateinamen entfernt (analysiert weiter sauber) |
**35 von 35** Zellen der neuen Gruppe rot |
| `cacheWidth` auf `4` | **1 von 4** Widget-Tests rot |
| `errorBuilder` entfernt | Corrupt-Test rot |

Die Aussage zum Absende-Zustand im Handbuch ist am Golden geprüft, nicht
angenommen: die
Anhang-Region ist pixelgleich zum Default-Zustand, geändert sind nur
Tags, Nachrichtenfeld und
Schaltfläche.

## Baselines

Die **bestehenden** Goldens sind bewusst nicht regeneriert: der Umbau
soll die Optik nicht
verändern, und dass sie unverändert grün sind, ist genau der Beleg
dafür.

Sie deckten allerdings nur den **leeren** Zustand ab — kein Golden im
Repo rendert je eine
ausgewählte Datei, und genau dort ändert `cacheWidth` die
Dekodier-Auflösung. Deshalb kommt ein
neues Golden dazu: das Ticket-Formular mit gesetztem Anhang
(`support_create_ticket_page_attached`).

Der Weg dorthin ist nicht trivial: `Image.file` dekodiert auf der echten
Async-Zeitachse, und der
gemeinsame `precacheImages`-Helper endet in `pumpAndSettle`, das hier
nicht zurückkehrt. Eine
feste Warteschleife wäre die schlechteste Lösung gewesen — sie hätte auf
einem langsameren Runner
ein leeres Thumbnail als Baseline eingefroren, **grün**. Der Test pollt
daher auf das dekodierte
`RawImage` und assertiert es; mit Poll-Budget 0 wird er rot. Ein
langsamer Runner scheitert damit
laut, statt ein falsches Bild festzuschreiben.

Die Baseline selbst erzeugt `golden-regenerate.yaml` auf dem self-hosted
Runner, nicht mein Mac —
sonst rendert sie unter einer anderen Toolchain als der, die sie auf PRs
validiert. Bis dieser
Lauf durch ist, ist `Visual Regression` erwartbar rot.

Finding 3 ist damit erledigt, Finding 4 an der verbliebenen Stelle
behoben.
Promote: staging -> develop
Closes #898.

App Store Connect flags every upload with **ITMS-90068** — from Spring
2027 a `MinimumOSVersion` below 15.0 is rejected outright. Nothing is
blocked today; this removes the warning well ahead of the deadline.

## Change

The floor is declared in three files that Xcode, CocoaPods and the App
Store validator each read separately — all raised together:

- `ios/Podfile` — `platform :ios, '15.0'`
- `ios/Runner.xcodeproj/project.pbxproj` — `IPHONEOS_DEPLOYMENT_TARGET =
15.0` in all three project-level build configurations (Debug / Release /
Profile); the per-target configurations (Runner, RunnerTests) inherit it
and carry no value of their own
- `ios/Flutter/AppFrameworkInfo.plist` — `MinimumOSVersion` `15.0`

`ios/Podfile.lock` changes in exactly one line: `PODFILE CHECKSUM`,
which is the SHA-1 of `ios/Podfile` and therefore moves with any Podfile
edit. No pod versions or spec checksums change — the platform bump
touches no version constraint, and `flutter_idensic_mobile_sdk_plugin`
pins `IdensicMobileSDK` with an exact `=` requirement, so the resolution
is identical under either floor.

## User impact: no device is dropped

iOS 15 supports exactly the same hardware as iOS 13 and 14 — iPhone 6s
and newer. Apple raised the hardware floor only with iOS 16 (iPhone 8
and newer). The only people affected are those who have chosen not to
update, and they can.

## Test

`test/tool/ios_deployment_target_test.dart` reads the three files back
and fails when

- they disagree with each other,
- any of them falls below the 15.0 floor, or
- the number of `IPHONEOS_DEPLOYMENT_TARGET` entries in the Xcode
project no longer matches `projectBuildConfigurations` — so a fourth
build configuration added without a deployment target trips the guard
instead of silently shipping an unset floor.

Without this a partial bump is invisible until an upload produces
another ITMS warning — which is how this issue was found in the first
place.

## Verification notes for the reviewer

- No Dart-side change; this is build configuration only. Nothing in
`lib/` or `ios/Runner/` branches on the iOS version, so the bump leaves
no dead fallback behind.
- The real check is an iOS build: `pod install` against the 15.0
platform, with `IdensicMobileSDK` (1.42.0, via
`flutter_idensic_mobile_sdk_plugin`) as the dependency most likely to
have an opinion about the floor. That needs a macOS host and is not
covered by this repository's PR checks.
Promote: staging -> develop
@TaprootFreak
TaprootFreak merged commit ec46259 into main Aug 7, 2026
19 of 20 checks passed
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.

4 participants