Release: develop -> main - #840
Open
github-actions[bot] wants to merge 70 commits into
Open
Conversation
… 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.
) ## What Follow-up to #848. Hides the row-level remove button on tax residence row 0 when it has no locked address country, and adds regression coverage for the tax residence add/remove/collision flows. ## Why `_removeRow` hard-blocks index 0: ```dart if (index <= 0 || ...) return; ``` But the render guard only checked `!row.lockedToResidence`. In the fallback case (no address country available), row 0 has `lockedToResidence == false`, so the remove button was rendered on it anyway — visible, but a no-op when tapped. This is visible in the already-committed golden `kyc_registration_tax_step_default.png`. Fix: `if (!row.lockedToResidence && index > 0)`. ## Tests 6 new `testWidgets` in `kyc_registration_tax_step_test.dart`: - removing an extra row makes it disappear, frees its country for re-selection, and produces the correct submit payload - the locked address-residence row cannot be removed - regression guard: no remove button on the unlocked primary row when it's the only row - regression guard: with a second unlocked row added, row 0 still has no remove button while row 1's remove button works - `didUpdateWidget`: an extra row is kept when the address country changes, or dropped on a country collision ## Golden baselines The render change removed the button from three goldens without an address country. They've been regenerated on the self-hosted runner via `golden-regenerate.yaml` and are included in this PR: - `kyc_registration_tax_step_default.png` - `kyc_registration_tax_step_country_error.png` - `kyc_registration_page_tax_step.png` --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
#849) ## Problem The self-hosted runner (Mac Studio) keeps the Flutter SDK persistently under `_work/_tool/flutter` across runs, so `subosito/flutter-action@v2`'s `cache: true` has nothing useful to restore there. In practice: - Cache restore stalls at 0.5-0.8 MB/s and hits the 10 minute segment timeout, then falls back to `Cache not found` anyway. - Cache save afterwards costs another ~8 minutes. - Net: ~18 minutes of overhead per job, on top of the ~30 seconds the actual golden tests take to run. - Result: 20-30 minute job duration on a runner that has exactly one slot for this repo. ## Solution Set `cache: false` on the two jobs that run on the self-hosted runner: - `.github/workflows/pull-request.yaml` — `golden-tests` job ("Visual Regression") - `.github/workflows/golden-regenerate.yaml` — `regenerate` job Both changes are accompanied by an inline comment explaining the rationale, matching the existing comment style in these files. `flutter-version`, `channel`, and `timeout-minutes` are unchanged. GitHub-hosted jobs (`build` in `pull-request.yaml`, `tier3-handbook.yaml`, `release.yaml`) keep `cache: true` — they run on ephemeral runners where the cache is actually useful. ## Expected effect Self-hosted job duration should drop from 20-30 min to roughly 2 min, freeing up the single self-hosted runner slot much sooner for the next queued job. ## Risk Low. `cache: false` only removes a cache-restore/save step; it does not change the Flutter SDK version, channel, or any test behavior. Worst case if the assumption about the persistent `_work/_tool` SDK turns out wrong: `flutter-action` falls back to a fresh SDK install on that job, which costs time but does not break correctness.
## Problem Both jobs that run on the self-hosted Mac Studio carry `timeout-minutes: 30`: | Workflow | Job | | |---|---|---| | `pull-request.yaml` | `golden-tests` (Visual Regression) | 30 | | `golden-regenerate.yaml` | `regenerate` (Regenerate golden baselines) | 30 | That 30 was sized for the cache era: `subosito/flutter-action@v2` pulled the SDK through the Actions cache, where the restore stalls at 0.5–0.8 MB/s, times out, misses anyway, and then burns ~8 more minutes on a cache save — roughly 18 minutes of pure overhead. A 20–30 minute job needed a 30 minute ceiling. That premise is gone with #849. ## Measurement With the Actions cache disabled on the self-hosted runner (#849), the same `golden-tests` job ran in **54 seconds** on the runner — `12:02:24Z → 12:03:18Z`, measured in a real CI run, not estimated. ## Change `30 → 15` on both self-hosted jobs. Nothing else is touched — no `cache:` change, no reformatting. Explicitly **unchanged**: `build` / Analyze & Test (30, GitHub-hosted `macos-latest`, legitimately slower), `coverage-floor` (5), `bitbox-audit` (10). ## Why 15, and not 5 or 30 The job runs in ~1 minute, so 15 is a ~15x margin — it will never fire in normal operation. It still absorbs the worst *legitimate* case: a runner whose persistent tool cache (`_work/_tool`) has been wiped, so the Flutter SDK has to be downloaded from scratch. 15 covers that comfortably while still being a real ceiling. The ceiling is the point. The self-hosted runner has exactly **one** slot for this repo. A wedged job (hung `flutter test`, stuck simulator, dead network mid-download) does not just fail slowly — it holds the only slot and blocks every other PR behind it. At 30 that is a half-hour outage of the visual-regression lane; at 15 the slot comes back twice as fast. `golden-regenerate` is the same workload plus a commit+push on the same single slot, so it moves in lockstep. ## Risk Low, and one-directional: the only way this bites is a job that legitimately needs more than 15 minutes. Post-#849 the job needs ~1. If the tool cache is ever cold *and* the download is pathologically slow, the job fails with a clear timeout rather than silently occupying the slot — a better failure mode than the status quo. > [!IMPORTANT] > **Do not let this PR's CI run before #849 is in its base.** The constraint is about CI order, not just merge order. > > This branch carries `timeout-minutes: 15` while `staging` still has `cache: true` in both self-hosted jobs (#849 is what removes it). Flipping this PR out of draft is what first exercises the new cap — against the cache era it exists to retire. Of the 14 completed `Visual Regression` runs of the last two days, **7 exceeded 15 minutes**, three of them as *green* runs (19.5 / 19.6 / 20.9 min): the stalled cache restore and the trailing cache save vary independently, so a run over the cap is roughly a coin flip. The job would be killed at 15 minutes, red-lining this PR's own gating check and holding the single self-hosted slot for the full 15 minutes. > > Correct order: merge #849 into `staging` first, **then** mark this ready. No rebase is needed — the two PRs touch different lines, so once #849 lands the recomputed merge ref carries `cache: false` on its own. Until then this stays a draft, and `golden-regenerate` must not be dispatched on this branch.
…and text scale (#854) ## Summary PR #847 fixed the BitBox pairing sheet. An audit then pumped every remaining candidate screen through the responsive matrix — **17 of 17 failed**. This closes them. **Two were broken at the default text scale:** - **Dashboard** — `RealUnit kaufen`, the only buy entry point on the home screen, received **no pointer events** on an iPhone SE as soon as a pending transaction was shown (`reachesCTA=false, navigated=false`). No red stripe in release: the user just sees a dead button. - **Create wallet** — the seed-backup confirm button rendered *below* the viewport, so the mandatory backup step looked like a dead end. The rest break from text scale 1.3–3.0 (accessibility users). ## What changed - All **17 surfaces** migrated to `ScrollableActionsLayout` (scrollable body, sticky actions). Every one gated by a device × text-scale matrix test (7 phones × 5 scales) that asserts a **real tap reaches the CTA** — each new test was verified to fail against the pre-fix code. - `ScrollableActionsLayout` gained `centerBody` so screens that centred their content keep their look, and its sticky action block is now bounded by the viewport — otherwise it could itself outgrow the available height at scale 3.0 and clip the CTA (the same bug at the other end of the scale). - Three **horizontal** `RenderFlex` overflows of a different class fixed (Rows that cannot shrink, clipping content off the right edge from scale 1.3 — on large phones too): dashboard price widget, pending transaction row, tag selection, file picker field. - All 18 surfaces registered in the catalog; its self-test now verifies the production file really constructs the layout. Docs/CONTRIBUTING state the contract honestly (bounded height required and throws otherwise; `Spacer()` illegal in the body; the catalog is a review responsibility, not a completeness proof). ## Test plan - [x] `flutter analyze` clean - [x] `flutter test` — **3711 passed** (≈450 new matrix cells) - [x] Goldens regenerated (25) and re-verified green; visually inspected — the status pages intentionally pin the button to the bottom instead of centring it with the copy, which is what makes it reachable - [ ] CI on this PR ## Not covered Welcome page, sell confirm/executed sheets, pin setup / biometric sheets are **not** migrated (no sticky-CTA overflow found there yet) — named explicitly in the docs rather than silently implied.
…to the API limits (#855) Follow-up to #848. A full review of #848 together with its backend counterpart (DFXswiss/api#4198) surfaced two defects plus two housekeeping items. ## 1. The prefilled tax residences were parsed and then dropped (major) `GET /realunit/registration-info` returns both `countryAndTINs` and `swissTaxResidence` in the prefill, and `RealUnitUserDataDto` parses both — but `KycRegistrationTaxStep` only ever received `residenceCountry`. The additional rows were never seeded. Since #4198 the backend overwrites `user_data.tin` with **exactly** what this form submits (previously it only wrote it for a first-time customer). A returning user therefore never saw the tax residences they had declared earlier, and submitting the form silently erased them from the stored declaration. The form is now seeded from the prefill: every declared country is resolved and rendered as a row, with its TIN prefilled. A stored `swissTaxResidence: true` comes back as an explicit CH row (Swiss tax residence is declared via the flag and carries no TIN). ## 2. No upper bounds (major) The step allowed an unbounded number of rows and an unbounded TIN. The backend now caps `countryAndTINs` at 10 entries and each TIN at 64 characters — without client-side bounds the user would run into a raw server-side 400 (and, before the backend fix, into a 500). Both limits are mirrored here: the "add another tax residence" button disappears at 10 rows, and the TIN field is length-limited via `LengthLimitingTextInputFormatter` (no visible counter, so the goldens stay valid). ## 3. ARB key order (minor) `tool/alphabetize_localization.dart` sorts case-insensitively; both ARB files had been re-sorted case-sensitively, so the next mandated codegen run would have reshuffled the whole file. Re-ran the tool to restore its canonical order. ## 4. `docs/screens.md` (minor) The `KycRegistrationTaxStep` row still listed only slots `56`–`61`; the ten new slots `61b`–`61k` from `scripts/assemble-handbook-screenshots.sh` were missing, although the file itself requires keeping them in sync. ## Test plan - [x] 8 new tests (prefill seeding for CH- and DE-residents, no-prefill, row cap, TIN truncation, seed truncation above the cap, residence re-lock, page wiring) - [x] `flutter analyze` clean, full suite green (2988 passed) on the build host - [x] No golden baseline touched — the default rendering is unchanged - [ ] CI green after ready (draft skips CI)
…spatch sha (#856) ## Problem The `regenerate` job in `golden-regenerate.yaml` checks out the default `github.sha`, which GitHub pins at dispatch time. The self-hosted runner this workflow uses only has a single execution slot, so the job can sit queued for many minutes after `workflow_dispatch` before it actually starts. By the time it runs, the dispatched branch has frequently moved on past the pinned sha. The job then regenerates golden baselines against that stale tree and tries to push the result back onto the branch. Since the branch tip has advanced, the push is rejected with `! [rejected] (fetch first)`, and the whole run — including the ~costly golden regeneration on the self-hosted runner — is wasted. This is a documented, reproduced failure mode (run 29259187838). The comment that previously sat above `git push` attributed this to protected-branch behavior ("On protected branches (develop/main) this fails by design"). That's a misdiagnosis for this failure: the observed rejections were on a feature branch, caused by the branch moving during the queue wait, not by branch protection. ## Fix Add `ref: ${{ github.ref_name }}` to the `actions/checkout@v4` step so it checks out the live head of the dispatched branch at checkout time, instead of the sha pinned at dispatch time. This way the goldens are always rendered against the exact tree the commit will land on, regardless of how long the job sat in the runner queue. The push-step comment is reworded to separate the two distinct cases clearly: - Protected branch (develop/main): push fails by design, no force-push or bypass — unchanged. - Feature branch: after this fix, the checkout/push window is a matter of seconds rather than minutes, so this should normally succeed. If the branch still moves in that narrow window, the push still fails loudly, and the existing fallback artifact upload still recovers the regenerated PNGs. The fix for that case is simply to re-dispatch the workflow. ## Why not a rebase/retry instead An alternative would have been to `git pull --rebase` (or force-push) before the push and retry. That was deliberately not done: rebasing the commit would let baselines rendered against one tree get published against a different, newer tree they were never actually validated against — a correctness hazard, not just a convenience issue. Keeping the push fail-loud, with re-dispatch as the recovery path, guarantees committed baselines always match the tree they were rendered from. ## Risk The remaining race window (checkout to push) shrinks from potentially many minutes to roughly the job's own runtime (about a minute), so a residual failure is unlikely but not impossible. If it still happens, the push fails loudly as before and the fallback artifact upload (`if-no-files-found: error`) still preserves the regenerated baselines for manual recovery.
Promote: staging -> develop
## Problem Every push to `staging` currently runs the "RealUnit Build" workflow twice on the same SHA: 1. once from the `push: staging` trigger, and 2. once from the permanently-open auto-PR "Promote: staging → develop" (#841) via `pull_request`. Those two runs use different concurrency groups (PR number vs. `github.ref`), so `cancel-in-progress` never cancels one against the other. Both compete for the single self-hosted runner slot used by Visual Regression / `golden-tests`. ## Fix Map that same-repo staging-head PR (`pull_request` + `head_ref == 'staging'` + head repo matches this repository) into the same constant concurrency group as `push: staging` (`refs/heads/staging`). `cancel-in-progress` then cancels the duplicate, and exactly one real run survives per staging-lane commit. ## Fail-closed (not skip-as-success) This is deliberately **not** the skip-as-success approach previously proposed and rejected in #850. A step/job that is merely skipped and treated as green can mask a red `push: staging` result. Here there is no `if:` skip and no success-without-running path: both triggers still schedule real work; the concurrency group collapses the pair so one run is cancelled. A red stays red. Worst case is a plain `cancelled` check — fail-closed and fixable by re-running — never a masked green. ## Fork-identity guard The group remapping requires `github.event.pull_request.head.repo.full_name == github.repository`, so a fork branch that happens to be named `staging` cannot cancel the real staging lane. ## Feature PRs Unaffected. They keep the existing PR-number concurrency group (with `github.ref` fallback for push / workflow_dispatch).
…858) ## Summary Follow-up to #854 (which fixed 25 sticky-CTA surfaces). An **empirical** audit of the surfaces the original grep missed — they use `Column(mainAxisSize: .min)`, not `Spacer()`, so no text search finds them — proved **4 more bottom sheets** clip their CTA out of the hit-test region at large system text scale (dead button, no red stripe in release): | Sheet | breaks at | impact | |---|---|---| | `sell_confirm_sheet` | text scale 2.0 | cannot confirm a sale | | `sell_executed_sheet` | **~1.2** | success sheet not closable — it was shown **without** `isScrollControlled`, so clamped to 9/16 of screen height | | `forgot_pin_bottom_sheet` | 2.0 | cannot reset the PIN | | `enable_biometric_bottom_sheet` | 2.0 | cannot enable/skip biometrics | ## What changed - New `shrinkWrap` mode on `ScrollableActionsLayout`: sizes to content up to the available height, scrolls the body past it, keeps the actions pinned — so a short sheet is not forced to full height but never clips its CTA. With `shrinkWrap:false` (default) the 25 screens from #854 are **byte-identical** (their whole test suite re-run unchanged). - The 4 sheets migrated to `ScrollableActionsLayout(shrinkWrap: true)` bounded at 0.9× screen; `sell_executed`'s call site fixed to `isScrollControlled: true`; `sell_confirm`'s info-row horizontal overflow fixed (both label and value shrink-safe). - Each sheet gated by a matrix test pumped through a **real** `showModalBottomSheet` (so the screen-height bound is faithful); every test verified to fail against the pre-fix code. - Catalog now lists all **29** surfaces; `welcome_page` audited → safe (scrolls). Docs state honestly that no further sticky-CTA surface of this shape is known. ## Test plan - [x] `flutter analyze` clean - [x] `flutter test` — **4432 passed** incl. goldens (rebased on current staging with #855/#856) - [x] 3 sell-sheet goldens regenerated & visually inspected; pin sheets render pixel-identical - [ ] CI on this PR
…rmed-email quote gate (#860) ## Problem A buy confirm that the share register rejects with the `PrimaryEmailRequired` error code (HTTP 400) currently falls into `BuyConfirmError.unknown` and shows the generic technical-problem snackbar — the buyer gets no hint that an email confirmation is outstanding. This is the error path a real buyer hit repeatedly last week. ## Changes - **Confirm path:** `BuyConfirmError.primaryEmailRequired` — mapped from the API error code (never from message text), 503 precedence unchanged and pinned by a test. The snackbar reuses the existing `buyPaymentConfirmFailedAktionariat` copy, which describes exactly the required action (check your inbox for the pending confirmation); no new ARB keys. - **Quote path (forward-compatible):** handles the upcoming `PrimaryEmailNotConfirmed` quote error (`isValid:false`). The action button routes to the KYC page — which auto-resolves to its confirm-email step — instead of the email-capture flow, then re-fetches the quote. Unreachable until the API starts emitting the code, so this PR is fully backward-compatible. - Tests: cubit mappings (incl. 503-precedence pin), state props, snackbar copy, action-button routing (pushes KYC, not email capture), plus golden coverage for the new gate state and snackbar. ## Merge order This app change must be released **before** the API starts emitting `PrimaryEmailNotConfirmed` — older app versions would otherwise degrade to the generic retry dead-end on the quote screen. The confirm-path fix is effective immediately with the current API. --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
## Summary - isolate staging-push routing from canonical required checks - let the ready `staging → develop` PR own normal CI - dispatch the full `RealUnit Build` as a fail-safe when the promotion PR is missing, draft, stale, or cannot be queried - preserve per-lane cancellation for obsolete SHAs without any cross-event cancellation - upgrade first-party Actions to Node.js 24-compatible majors and disable the inapplicable Go module cache - document trigger ownership and fallback guarantees ## Root cause `push: staging` and the promotion PR previously shared one concurrency group. When the PR event arrived after the push event, `cancel-in-progress: true` cancelled the push run on the same head SHA. GitHub attached the cancelled `Analyze & Test`, `Coverage Floor Gate`, `Visual Regression`, and `BitBox quirks audit` checks to the current promotion PR, leaving it visibly red even though the replacement PR run succeeded. ## Design Canonical jobs remain in `pull-request.yaml` with their stable required-check names. That workflow now handles PRs, `push: develop`, and manual dispatches only. The new `staging-ci-fallback.yaml` workflow handles `push: staging`: 1. Query open `staging → develop` PRs using an exact base, head owner/branch, repository, draft-state, and head-SHA match. 2. If a ready same-repository PR already points at the pushed SHA, do nothing: its `synchronize` event owns canonical CI. 3. If the PR is missing, draft, stale, or the API lookup fails, dispatch `pull-request.yaml` on `staging` via `workflow_dispatch`. 4. Keep router concurrency isolated from `RealUnit Build`, so routing can never cancel required PR checks. This removes duplicate heavy macOS/self-hosted work in the normal promotion path while failing safe to a full build whenever PR ownership cannot be proven. ## Required-check safety The initial same-workflow approach was deliberately replaced after live verification showed that GitHub renders expressions in skipped job names literally. Keeping routing in a separate workflow means: - canonical required-check names are stable - the router never emits skipped checks with canonical names - a skipped job cannot accidentally satisfy a required check - no raw expression-based check names appear in the staging-push path ## Additional CI cleanup - `actions/checkout@v6` - `actions/setup-go@v6` with `cache: false` because this repository has no `go.mod`/`go.sum` - `actions/upload-artifact@v6` - `actions/download-artifact@v7` These versions use the Node.js 24 runtime. The self-hosted golden runner reported version `2.335.1`, above the Actions minimum of `2.327.1`. ## Validation - `actionlint -ignore 'label ".+" is unknown' .github/workflows/pull-request.yaml .github/workflows/staging-ci-fallback.yaml` - Ruby YAML parse for both workflows - `git diff --check` - exact embedded router script exercised with mocked GitHub responses: - ready PR at current SHA → no fallback dispatch - draft PR → full fallback dispatch - stale PR SHA → full fallback dispatch - GitHub API failure → full fallback dispatch - manual invocation → full dispatch The ignored `actionlint` diagnostics are the repository's existing custom self-hosted labels (`m3-ultra`, `realunit-app`). ### End-to-end result [Manual `RealUnit Build` run 29418489339](https://github.com/RealUnitCH/app/actions/runs/29418489339) completed successfully on commit `69e86893`: - `Analyze & Test` — success - `Coverage Floor Gate` — success, including artifact upload/download on the upgraded Actions - `Visual Regression` — success on the self-hosted runner - `BitBox quirks audit` — success without the previous `go.sum` cache warning - no Node.js 20 deprecation annotation
#862) ## Why RealUnitCH/web#23 adds a dedicated confirm-page end state (`no-registration`) with four new Playwright baselines (18 → 22 PNGs). The handbook deploy stages those baselines behind the `EXPECTED_WEB_BASELINE_COUNT` guard, which requires the count bump and the matching gallery cards in the same change. ## Changes - `handbook.yaml`: `EXPECTED_WEB_BASELINE_COUNT` 18 → 22. - `docs/handbook/de/index.html` (`#spec-web`): four cards for the new views (desktop de/en, tablet, mobile), mirroring the confirm-invalid card group. ## Coupling Merge together with RealUnitCH/web#23 — the guard fails the next handbook deploy if either side lands alone. The PR-level `Handbook Build Check` does not stage web baselines, so this PR's CI is unaffected either way.
Promote: staging -> develop
…l sites (#864) ## Summary Two small follow-ups from the responsive-layout review (#854/#858). **1. Group the IBAN like the rest of the app.** The beneficiary IBAN on the sell confirmation sheet and the payer IBAN on the buy payment-details card were shown raw, while the bank-account field/list already format IBANs in 4-char blocks via `IbanTextFormatter`. Both now use the same helper — consistent, and the inserted spaces also give the confirm sheet's `softWrap` natural break points at group boundaries. - On the buy card the row's value also feeds the **copy-to-clipboard** button. A grouped IBAN (with spaces) pasted into a bank-transfer form can fail validation, so a new optional `copyValue` on `_PaymentDetailsRow` keeps the clipboard **raw** while the display stays grouped. A test pins that the clipboard receives the ungrouped IBAN. **2. Guard the `SellButton` `isScrollControlled: true` call sites.** `SellButton` opens its confirm and executed bottom sheets with `isScrollControlled: true`, but no test drove that listener, so the flag could be removed silently (it is what keeps the sheet from being clamped to 9/16 of screen height, which would re-clip the CTA). New widget test mounts the real `SellButton`, emits `SellPaymentInfoSuccess`, and asserts via a `NavigatorObserver` that both pushed `ModalBottomSheetRoute`s are `isScrollControlled` — verified to go red if the flag is removed. ## Note on the third reported item A third follow-up was reported — "`build_runner --delete-conflicting-outputs` deletes `test/fixtures/dfx_countries.json`". On investigation this is **not a repo bug**: build_runner leaves the fixture untouched (verified empirically; the repo has no `build.yaml`, and drift/source_gen only emit git-ignored `lib/**/*.g.dart`). The fixture only appeared to vanish when a sync step excluded `*.json`. No code change needed. ## Test plan - [x] `flutter analyze` clean - [x] `flutter test` — **4446 passed** incl. goldens - [x] New call-site test proven to go red if `isScrollControlled` is removed; clipboard test proven to go red if the copy uses the grouped value - [x] Only the 2 sell-confirm goldens move (grouped IBAN); buy goldens unchanged (fixture was already grouped) - [ ] CI on this PR
## 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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Automatic Release PR
This PR was automatically created after changes were pushed to develop.
Commits: 32 new commit(s)
Checklist