diff --git a/.coverage-visibility-allowlist b/.coverage-visibility-allowlist new file mode 100644 index 000000000..e183db1b5 --- /dev/null +++ b/.coverage-visibility-allowlist @@ -0,0 +1,30 @@ +# Coverage visibility allowlist — see scripts/check-coverage-visibility.sh. +# +# In-scope .dart files that legitimately produce NO coverable lines, so they +# never emit an `SF:` record in the scoped tracefile and would otherwise trip +# the visibility gate. Every entry here is a pure abstract interface / port, an +# enum, const-only data, or a Drift table schema — there is no executable body +# to cover. +# +# This is a ratchet: a NEW in-scope file that carries no coverage fails the +# build. Fix it by adding a test that exercises it (preferred). Only add a line +# here if the file genuinely has no coverable lines, and say why in a comment. +# Blank lines and `#` comments are ignored. Paths are repo-relative. + +# Abstract interfaces / ports (implementations live elsewhere and are tested): +lib/packages/io/backup_exclusion_port.dart +lib/packages/io/documents_directory_port.dart +lib/packages/service/biometric/biometric_port.dart +lib/packages/service/price_service.dart +lib/screens/kyc/steps/ident/cubits/kyc_ident/sumsub_ident_port.dart + +# Enums (no bodies): +lib/packages/service/biometric/biometric_auth_outcome.dart +lib/packages/service/dfx/models/payment/payment_info_error.dart +lib/screens/restore_wallet/cubit/validate_seed/validate_seed_state.dart + +# Const-only data (covered as values by default_assets_test.dart, no lines): +lib/packages/utils/default_assets.dart + +# Drift table schema (column getters carry coverage:ignore-line): +lib/packages/storage/node_storage.dart diff --git a/.github/workflows/golden-regenerate.yaml b/.github/workflows/golden-regenerate.yaml index f6642a2f8..4436a7160 100644 --- a/.github/workflows/golden-regenerate.yaml +++ b/.github/workflows/golden-regenerate.yaml @@ -37,7 +37,13 @@ jobs: regenerate: name: Regenerate golden baselines runs-on: [self-hosted, macOS, ARM64, m3-ultra, realunit-app] - timeout-minutes: 30 + # Same reasoning as `golden-tests` in `pull-request.yaml`: the old 30 was + # sized for the cache era (~18 minutes of stalled Actions-cache overhead). + # Regeneration is that same workload plus a commit+push, so 15 minutes + # still leaves room for a cold Flutter SDK download, and it frees this + # repo's single self-hosted slot far sooner when a run wedges. Keep the + # two jobs' timeouts in sync, like the rest of their setup. + timeout-minutes: 15 steps: - uses: actions/checkout@v4 with: @@ -45,11 +51,24 @@ jobs: # this, checkout still works but the remote is configured with no # credential helper and the push fails with HTTP 403. token: ${{ secrets.GITHUB_TOKEN }} + # Single-slot runner can start minutes after dispatch. Default + # checkout pins github.sha (stale by then); render against that + # tree and the later push is rejected because the branch tip has + # moved. Checkout the live head instead (github.ref_name — the + # dispatched branch; workflow_dispatch always runs with --ref + # ) so baselines match the tree they get committed to. + ref: ${{ github.ref_name }} - uses: subosito/flutter-action@v2 with: flutter-version: "3.41.6" channel: "stable" - cache: true + # Self-hosted runner already keeps the Flutter SDK persistently + # under `_work/_tool` across runs. The Actions cache restore on + # this runner stalls at 0.5-0.8 MB/s, times out after the 10 + # minute segment timeout, falls back to a cache miss anyway, + # then burns ~8 minutes on a cache-save — ~18 minutes of pure + # overhead that returns nothing useful here. + cache: false - run: flutter pub get - run: dart run tool/generate_localization.dart - run: dart run tool/generate_release_info.dart @@ -75,9 +94,13 @@ jobs: exit 0 fi git commit -m "test(goldens): regenerate baselines on the self-hosted runner" - # On protected branches (develop/main) this fails by design. - # The next step uploads the PNGs as an artifact so the user can - # still recover the regen output. + # Protected branches (develop/main): fails by design — no force-push, + # no bypass. Feature branches: after the ref: fix above this should + # normally succeed. If the branch still moves between checkout and + # push (now a seconds-wide window), fail loud; the fallback artifact + # step below still uploads the PNGs. Recovery: re-dispatch (cheap). + # Do NOT rebase or force-push — that would publish baselines that no + # longer match the tree head they were rendered against. git push # Runs only if the push step failed. Uploads the regenerated PNGs so diff --git a/.github/workflows/handbook-build-check.yaml b/.github/workflows/handbook-build-check.yaml index a342620d9..8cf961580 100644 --- a/.github/workflows/handbook-build-check.yaml +++ b/.github/workflows/handbook-build-check.yaml @@ -68,8 +68,8 @@ jobs: set -euo pipefail bash scripts/assemble-handbook-screenshots.sh /tmp/handbook-shots count=$(ls -1 /tmp/handbook-shots/*.png | wc -l | tr -d ' ') - if [ "$count" != "61" ]; then - echo "expected 61 screenshots, got $count" >&2 + if [ "$count" != "278" ]; then + echo "expected 278 screenshots, got $count" >&2 exit 1 fi @@ -149,11 +149,11 @@ jobs: exit 1 fi - # Screenshots dir must contain all 61 PNGs assembled from Goldens. + # Screenshots dir must contain all 278 PNGs assembled from Goldens. # Hit one of them through the auth gate to verify wiring end-to-end. - # Mix of original 01-26 range + new 27-61 range so a regression - # in either half surfaces here. - for name in 01-welcome 11-dashboard 26-terms 35-dashboard-with-balance 46-buy-kyc-required 52-sell-unknown-error 53-buy-payment-details 61-kyc-registration-tax-tin-error; do + # Mix of the original 01-61 range and the 62-268 batch (every Golden + # baseline) so a regression in either half surfaces here. + for name in 01-welcome 11-dashboard 26-terms 35-dashboard-with-balance 46-buy-kyc-required 52-sell-unknown-error 53-buy-payment-details 61-kyc-registration-tax-tin-error 62-welcome-page-android 219-settings-security-page-default 268-phone-number-field-default; do code=$(curl -s -o /dev/null -w '%{http_code}' -u "${HANDBOOK_USER:-x}:${HANDBOOK_PASS:-x}" "http://127.0.0.1:8080/screenshots/${name}.png") # 200 (auth happens to match) or 401 (auth fails but file exists) # both prove the file is on disk. 404 means it was not assembled. diff --git a/.github/workflows/handbook-deploy.yaml b/.github/workflows/handbook-deploy.yaml index f114ec5c9..83aa834aa 100644 --- a/.github/workflows/handbook-deploy.yaml +++ b/.github/workflows/handbook-deploy.yaml @@ -25,7 +25,7 @@ on: branches: [staging, develop] paths: - "docs/handbook/**" - - "test/goldens/screens/**" + - "test/goldens/**" - "scripts/assemble-handbook-screenshots.sh" - "scripts/assemble-handbook-store-listing.py" - "scripts/templates/store-listing.html.tmpl" diff --git a/.github/workflows/handbook.yaml b/.github/workflows/handbook.yaml index f3fc345b9..578c5a87c 100644 --- a/.github/workflows/handbook.yaml +++ b/.github/workflows/handbook.yaml @@ -317,13 +317,13 @@ jobs: - name: Stage web e2e baselines from web repo env: WEB_DIR: _web-checkout - # RealUnitCH/web commits 18 Playwright visual baselines under + # RealUnitCH/web commits 22 Playwright visual baselines under # tests/__screenshots__/{desktop-chromium,tablet-chromium,mobile-safari}/. # `-ne` below fails the build on ANY drift (add OR remove) so a change # to the committed set upstream requires an explicit bump here AND a # matching card edit in docs/handbook/de/index.html (#spec-web) in the # SAME change — same guard rationale as the api steps above. - EXPECTED_WEB_BASELINE_COUNT: 18 + EXPECTED_WEB_BASELINE_COUNT: 22 run: | set -euo pipefail diff --git a/.github/workflows/pull-request.yaml b/.github/workflows/pull-request.yaml index 6d2d8ead2..d596f6ce8 100644 --- a/.github/workflows/pull-request.yaml +++ b/.github/workflows/pull-request.yaml @@ -16,32 +16,25 @@ name: RealUnit Build # below (saves macOS-runner minutes while work is still in progress). # - `labeled` / `unlabeled` are intentionally omitted: realunit-app has # no label-gated heavy job here (Tier 3 has its own workflow). -# - `push: develop` and `push: staging` keep post-merge verification -# as an authoritative source of truth, independent of any PR state. -# `staging` participates because feature → staging merges land an -# integration commit whose verification cannot be deferred to the -# auto-opened staging → develop PR (the gate must be authoritative -# at the moment the commit lands on staging). Other branches do not -# need a post-push check — the PR-event already covers them. +# - `push: develop` keeps post-merge verification as an authoritative +# source of truth. Staging fallback routing lives in the separate +# `staging-ci-fallback.yaml` workflow: it dispatches this workflow +# only when no ready staging → develop PR owns CI for the pushed SHA. # - `workflow_dispatch` is kept as a manual override and is NOT # draft-gated (see the job `if:` guard below). on: workflow_dispatch: push: - branches: [develop, staging] + branches: [develop] pull_request: branches-ignore: [main] types: [opened, synchronize, reopened, ready_for_review] -# Group by PR number so a new push to the same PR cancels the -# in-flight run for the outdated commit. macOS runners are scarce — -# letting an obsolete run finish wastes ~10 minutes per push. Grouping -# by SHA (the previous approach) put every commit in its own group, -# which meant no cancellation ever happened and back-to-back pushes -# queued sequentially. -# Falls back to `github.ref` for `push` and `workflow_dispatch` events -# (where there is no `pull_request.number`): `refs/heads/develop` for -# post-merge runs, the dispatched ref for manual triggers. +# Group PR runs by PR number, and push/dispatch runs by ref. A new SHA +# cancels work for the outdated SHA in the same lane. Staging push +# routing is isolated in `staging-ci-fallback.yaml`; it either relies on +# this PR workflow or dispatches this workflow as a fallback, but never +# shares a concurrency group with the PR run. concurrency: group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true @@ -60,7 +53,7 @@ jobs: runs-on: macos-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: subosito/flutter-action@v2 with: flutter-version: "3.41.6" @@ -151,7 +144,7 @@ jobs: - name: Upload coverage report if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: coverage-lcov path: coverage/lcov.info @@ -168,7 +161,7 @@ jobs: # fail-open (silent `exit 0`). - name: Upload coverage summary if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: coverage-summary path: coverage/lcov.summary @@ -228,14 +221,20 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Download coverage summary - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v7 with: name: coverage-summary path: coverage + - name: Download scoped coverage tracefile + uses: actions/download-artifact@v7 + with: + name: coverage-lcov + path: coverage + - name: Enforce coverage floor run: | set -euo pipefail @@ -275,6 +274,18 @@ jobs: fi fi + # Closes the coverage blind spot the floor % alone cannot see: an in-scope + # file that no test ever loads is absent from lcov.info entirely, so it + # sits in neither the numerator nor the denominator and the scoped % + # stays 100 % while the file is wholly untested. This step enumerates the + # on-disk activated surface (same globs as the filter step above) and + # fails when a file carries no coverage and is not in the committed + # lineless allowlist. See scripts/check-coverage-visibility.sh. + - name: Enforce in-scope file visibility + run: | + set -euo pipefail + bash scripts/check-coverage-visibility.sh coverage/lcov.info .coverage-visibility-allowlist + # Visual regression tests on the self-hosted runner. Goldens live # under `test/goldens/` and are validated pixel-for-pixel against committed # baselines under `test/goldens/screens/**/goldens/macos/`. The Mac Studio @@ -295,14 +306,29 @@ jobs: name: Visual Regression if: github.event_name != 'pull_request' || github.event.pull_request.draft == false runs-on: [self-hosted, macOS, ARM64, m3-ultra, realunit-app] - timeout-minutes: 30 + # 30 was sized for the era when `subosito/flutter-action@v2` still pulled + # the SDK through the Actions cache and burned ~18 minutes on a stalled + # restore plus the trailing cache-save. Without that overhead the job + # finishes in ~1 minute, so 15 is generous headroom — it still covers the + # worst normal case, where the SDK is absent from the runner's persistent + # tool cache and has to be downloaded from scratch. The lower ceiling is + # what matters here: the self-hosted runner has exactly ONE slot for this + # repo, so a wedged job blocks every other PR until it is killed. 15 hands + # the slot back twice as fast as 30. + timeout-minutes: 15 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: subosito/flutter-action@v2 with: flutter-version: "3.41.6" channel: "stable" - cache: true + # Self-hosted runner already keeps the Flutter SDK persistently + # under `_work/_tool` across runs. The Actions cache restore on + # this runner stalls at 0.5-0.8 MB/s, times out after the 10 + # minute segment timeout, falls back to a cache miss anyway, + # then burns ~8 minutes on a cache-save — ~18 minutes of pure + # overhead that returns nothing useful here. + cache: false - run: flutter pub get - run: dart run tool/generate_localization.dart - run: dart run tool/generate_release_info.dart @@ -311,7 +337,7 @@ jobs: run: flutter test test/goldens - name: Upload golden diff on failure if: failure() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: golden-diffs path: test/goldens/**/failures/** @@ -328,10 +354,13 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 with: go-version: "1.25.5" + # This repository has no Go module; caching would only emit a + # misleading "go.sum not found" warning for this audit-only job. + cache: false - name: Install bitbox-audit continue-on-error: true @@ -359,7 +388,7 @@ jobs: - name: Upload audit report if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: bitbox-audit-report path: bitbox-audit-report.md diff --git a/.github/workflows/staging-ci-fallback.yaml b/.github/workflows/staging-ci-fallback.yaml new file mode 100644 index 000000000..cacf7286d --- /dev/null +++ b/.github/workflows/staging-ci-fallback.yaml @@ -0,0 +1,77 @@ +name: Staging CI Router + +# A staging push normally produces a `synchronize` event on the open +# staging → develop promotion PR. That PR run owns the canonical required +# checks. If the PR is missing, draft, stale, or cannot be queried, this +# workflow dispatches the canonical RealUnit Build as a fail-safe. +on: + push: + branches: [staging] + workflow_dispatch: + +permissions: + actions: write + contents: read + pull-requests: read + +# Only the newest staging SHA needs routing. This group is deliberately +# isolated from RealUnit Build, so it can never cancel required PR checks. +concurrency: + group: staging-ci-router-${{ github.ref }} + cancel-in-progress: true + +jobs: + route: + name: Route staging CI + runs-on: ubuntu-latest + timeout-minutes: 2 + steps: + - name: Use PR CI or dispatch fail-safe CI + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + SHOULD_DISPATCH=true + REASON="manual routing request" + + if [ "${GITHUB_EVENT_NAME}" = "push" ]; then + REASON="promotion PR lookup failed" + if PULLS_JSON="$( + gh api --method GET "/repos/${GITHUB_REPOSITORY}/pulls" \ + -f state=open \ + -f base=develop \ + -f head="${GITHUB_REPOSITORY_OWNER}:staging" \ + -f per_page=100 + )"; then + if jq -e \ + --arg repo "${GITHUB_REPOSITORY}" \ + --arg sha "${GITHUB_SHA}" \ + 'any(.[]; .draft == false and .head.repo.full_name == $repo and .head.sha == $sha)' \ + <<<"${PULLS_JSON}" >/dev/null; then + SHOULD_DISPATCH=false + REASON="ready promotion PR at this SHA owns canonical CI" + else + REASON="no ready promotion PR at this SHA" + fi + else + echo "::warning::Could not query the staging promotion PR; dispatching fail-safe CI" + fi + fi + + if [ "${SHOULD_DISPATCH}" = "true" ]; then + gh workflow run pull-request.yaml \ + --repo "${GITHUB_REPOSITORY}" \ + --ref staging + echo "::notice::Dispatched RealUnit Build on staging (${REASON})" + else + echo "::notice::No fallback dispatch needed (${REASON})" + fi + + { + echo "### Staging CI routing" + echo + echo "- Fallback dispatched: \`${SHOULD_DISPATCH}\`" + echo "- Reason: ${REASON}" + echo "- SHA: \`${GITHUB_SHA}\`" + } >> "${GITHUB_STEP_SUMMARY}" diff --git a/.gitignore b/.gitignore index 67e3eb22b..b40a47ff2 100644 --- a/.gitignore +++ b/.gitignore @@ -102,4 +102,6 @@ docs/handbook/legal/ # `git status` after a local repro stays clean. _api-checkout/ _handlebars-only/ -_web-checkout/ \ No newline at end of file +_web-checkout/ +# Alchemist/flutter golden failure dumps +**/goldens/**/failures/ diff --git a/.maestro/handbook/01-welcome.yaml b/.maestro/handbook/01-welcome.yaml index efad68dbb..6ad025a6d 100644 --- a/.maestro/handbook/01-welcome.yaml +++ b/.maestro/handbook/01-welcome.yaml @@ -1,6 +1,22 @@ # Tier-3 navigation smoke. The handbook screenshot for this flow is the # visual-regression Golden mapped in scripts/assemble-handbook-screenshots.sh; # this flow's diagnostic capture lands in build/handbook-captures/01-welcome.png. +# +# Timeout — why 120s here and not the suite-wide 30s: +# This is the FIRST flow, so it runs against a cold XCUITest driver on a +# freshly `simctl erase`-d device. On iOS 26 simulators, retrieving the +# view hierarchy of the deep Flutter semantics tree is pathologically slow +# when cold — a single snapshot has been observed taking 45-64s on a loaded +# `macos-latest` runner (see the driver post-mortem in a #824 run: the assert +# spent 64s inside one hierarchy fetch that only returned after the old 30s +# timeout had already elapsed, so it failed even though "Start" WAS in the +# tree). "Start" is a plain AppFilledButton label that is always present and +# accessible — the app is not slow, the XCUITest snapshot is. Every later +# flow reuses the now-warm driver (`--reinstall-driver=false`), so their 30s +# assertions retrieve the hierarchy fast enough; only this cold first fetch +# needs the head-room. 120s comfortably absorbs the worst observed single +# fetch while still failing a genuinely broken welcome screen well inside the +# flow's budget. appId: swiss.realunit.app --- - launchApp: @@ -9,4 +25,4 @@ appId: swiss.realunit.app - waitForAnimationToEnd - extendedWaitUntil: visible: 'Start' - timeout: 30000 + timeout: 120000 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f021f04d8..90547ff1f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,6 +13,26 @@ flutter analyze # lint check After changing ARB files, always regenerate: `dart run tool/generate_localization.dart` +## Responsive UI & sticky CTAs — CRITICAL + +The app must stay fully usable on every **standard phone** we support (smallest iPhone SE / compact Android → largest Pro Max / large Android) and at every **system text scale** from smaller than default through extreme accessibility (`textScale` 0.85–3.0). + +**Layout rule** + +- Flows with long content + bottom actions (bottom sheets, onboarding, confirmations) use [`ScrollableActionsLayout`](lib/widgets/scrollable_actions_layout.dart): scrollable body, **CTAs fixed outside** the scroll view. +- Never rely on `Spacer()` + fixed sheet height alone — that is how the BitBox pairing `Bestätigen` button became untappable on iOS. +- `Spacer()` inside the body is illegal (unbounded main axis in the scroll view → RenderFlex crash). Use `centerBody: true` to vertically center content that still fits. +- Host must provide **bounded height** (bottom sheet, `Expanded`, or fixed `SizedBox`); unbounded height throws `FlutterError` in every build mode. + +**Test rule (gate for this bug class)** + +- Matrix: [`test/helper/responsive_matrix.dart`](test/helper/responsive_matrix.dart) × [`layout_assertions.dart`](test/helper/layout_assertions.dart) (`expectNoLayoutOverflow`, `expectFullyTappable`). +- Catalog: sticky-CTA surfaces listed in [`responsive_surface_catalog.dart`](test/helper/responsive_surface_catalog.dart) have a matrix test path and a production path; the catalog self-test fails if either file is missing or the production file no longer references `ScrollableActionsLayout`. Listing every sticky-CTA surface is a review responsibility. +- Prefer real `tester.tap` / `expectFullyTappable` over `onPressed?.call()`. +- Details and PR checklist: [`docs/testing.md`](docs/testing.md) § *Responsive layout / sticky CTAs*. + +New sticky-CTA UI without `ScrollableActionsLayout` + matrix entry is a **blocking** review finding. + ## Branch Flow Three branches participate in the release lane: diff --git a/Dockerfile.handbook b/Dockerfile.handbook index 00a759f6a..fd09eaa95 100644 --- a/Dockerfile.handbook +++ b/Dockerfile.handbook @@ -26,6 +26,9 @@ WORKDIR /work RUN apk add --no-cache bash coreutils COPY scripts/assemble-handbook-screenshots.sh ./scripts/ COPY test/goldens/screens/ ./test/goldens/screens/ +# Shared-widget baselines (e.g. widgets/form/phone_number_field) live outside +# screens/ but are mapped into the handbook too, so they must be staged here. +COPY test/goldens/widgets/ ./test/goldens/widgets/ RUN bash ./scripts/assemble-handbook-screenshots.sh /out # Store-listing section: derived export of the Fastlane metadata diff --git a/README.md b/README.md index dc34ffbd1..2575e765d 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,8 @@ The 100% rule above is the target state. Until the items below land, it is aspir - [x] lcov threshold check failing the build below a committed floor on the scope above - [x] Floor gate lives in its own CI job (`Coverage Floor Gate`), wired up as a required status check on `develop` + `main` - [x] GitHub branch protection on `develop` requiring the `Coverage Floor Gate` check (ruleset `PRs` / id `11317379`) -- [x] Inline `// coverage:ignore-*` annotations on truly unreachable paths, each with a one-line reason — applied to Drift schema getters across `lib/packages/storage/`, defensive `assert(false) → throw StateError` fallthroughs in `wallet.dart`, `BitboxCredentials` sync entry points that only exist to satisfy the web3dart interface, the platform-channel forwarders in `PathProviderAdapter` and `BiometricServiceAdapter`, and the `_localTesting` dev-only `Uri.http` branch in `api_config.dart` +- [x] Inline `// coverage:ignore-*` annotations on truly unreachable paths, each with a one-line reason — applied to Drift schema getters across `lib/packages/storage/`, defensive `assert(false) → throw StateError` fallthroughs in `wallet.dart`, the platform-channel forwarders in `PathProviderAdapter` and `BiometricServiceAdapter`, the Android-only telemetry-kill file write in `fuck_firebase.dart`, and the `_localTesting` dev-only `Uri.http` branch in `api_config.dart`. `BitboxCredentials`' synchronous web3dart entry points used to be listed here too; they now carry `throwsA(isA())` tests instead of an ignore, matching the tested `_DebugCredentials` stubs in `wallet.dart` +- [x] Visibility gate closing the "never-loaded file" blind spot — `flutter test --coverage` only records libraries a test actually imports, so an in-scope file no test loads is absent from `lcov.info` entirely and sits in neither the numerator nor the denominator: the scoped % stays 100 % while the file is wholly untested. The `Coverage Floor Gate` job runs [`scripts/check-coverage-visibility.sh`](scripts/check-coverage-visibility.sh), which enumerates the on-disk activated surface (same globs as the scope filter) and fails when a file produced no `SF:` record. Files that genuinely have no coverable lines — pure abstract interfaces / ports, export-only barrels, const/enum-only declarations — are ratcheted in `.coverage-visibility-allowlist`; a new blind spot fails the build until it is tested (preferred) or, only if truly lineless, allow-listed in the same PR with a reason **Ratchet protocol.** The committed floor lives in two flat files at the repo root: `.coverage-floor-lines` and `.coverage-floor-functions` (integer percent, no `%` suffix). CI fails the build when scoped coverage drops below either value. Raising the floor is encouraged on every PR that raises measured coverage — bump the file in the same commit and the gate moves up. Lowering the floor requires explicit reviewer sign-off; PR convention is the `coverage:lower-floor` label so the regression is visible in the PR list rather than smuggled in. The functions floor is parked at a placeholder today because `flutter test --coverage` does not emit `FN` records — the gate warns instead of failing on that metric until upstream adds support. @@ -162,7 +163,8 @@ Tier 1 specs live under `test/integration/**` and run inside the same `flutter t | Workflow | Trigger | Action | | ---------------------------- | ------------------------------------------------------------- | --------------------------------------------------------------------------------------- | -| `pull-request.yaml` | Any PR except PRs to `main` · push `develop` · manual | `flutter analyze` + `flutter test --coverage --exclude-tags golden`, scope lcov to the activated surface, fail below the committed floor, upload lcov artifact. In parallel, the `Visual Regression` job runs `flutter test test/goldens` on the self-hosted runner against the committed pixel baselines under `test/goldens/**/goldens/macos/` and uploads diff PNGs on failure. Jobs: `Analyze & Test`, `Coverage Floor Gate`, `Visual Regression`, `BitBox quirks audit`. | +| `pull-request.yaml` | Any PR except PRs to `main` · push `develop` · manual | Runs `flutter analyze` + `flutter test --coverage --exclude-tags golden`, enforces scoped coverage, validates Goldens on the self-hosted runner, and uploads diagnostics. Jobs: `Analyze & Test`, `Coverage Floor Gate`, `Visual Regression`, `BitBox quirks audit`. | +| `staging-ci-fallback.yaml` | Push `staging` · manual | Routes staging CI without cross-event cancellation: a ready `staging → develop` PR owns canonical CI; otherwise it dispatches `pull-request.yaml` on `staging` as a fail-safe. | | `tier3-handbook.yaml` | Any PR except PRs to `main`, with label `tier3:full` · push `develop` · manual | Tier-3 navigation/tap-routing smoke: runs every `.maestro/handbook/*.yaml` flow on a fresh `iPhone 17` simulator and uploads diagnostic captures (`build/handbook-captures/`) as a build artifact. Pixel drift on the page renders is owned by `Visual Regression` in `pull-request.yaml`, not this job. | | `bitbox-simulator.yml` | Any PR except PRs to `main` touching `lib/packages/hardware_wallet/**`, `lib/packages/wallet/**`, `lib/screens/hardware_connect_bitbox/**`, their test mirrors, `pubspec.yaml`, or the workflow itself · manual | Runs the BitBox02 firmware simulator with `bitbox-testkit` baselines (Tier 2) | | `bitbox-simulator-slash.yml` | `/bitbox-simulator` comment on any PR | Same engine as above, on-demand per PR (variants: default / `ref=main`) | diff --git a/assets/languages/strings_de.arb b/assets/languages/strings_de.arb index abbabed85..7e4a93127 100644 --- a/assets/languages/strings_de.arb +++ b/assets/languages/strings_de.arb @@ -3,6 +3,7 @@ "accountTypeHuman": "Natürliche Person", "addBankAccount": "Bankkonto hinzufügen", "address": "Adresse", + "addTaxResidence": "Weiteren Steuerwohnsitz hinzufügen", "aktionariatDisclaimer": "Haftungsausschluss", "aktionariatImprint": "Impressum", "aktionariatPrivacyPolicy": "Datenschutzerklärung", @@ -73,7 +74,7 @@ "copyClipboard": "In die Zwischenablage kopiert", "countriesLoadFailed": "Die Länderliste konnte nicht geladen werden. Bitte versuchen Sie es erneut.", "country": "Land", - "countryHint": "Schweiz", + "countryHint": "Land auswählen", "createWallet": "Neue Wallet erstellen", "createWalletConfirm": "Ich habe es gesichert", "createWalletRecoveryKeyTitle": "Wiederherstellungs-Wörter", @@ -123,6 +124,8 @@ "kycLinkWalletDescription": "Sie sind als Aktionär eingetragen. Bestätigen Sie diese Wallet, um sie Ihrem Konto hinzuzufügen.", "kycLinkWalletSubmit": "Wallet hinzufügen", "kycLinkWalletTitle": "Wallet hinzufügen", + "kycManualReviewDescription": "Ihre RealUnit-Registrierung wird von unserem Team manuell geprüft, bevor sie abgeschlossen werden kann. Sie müssen nichts weiter tun — wir kümmern uns darum. Ihr Status wird automatisch aktualisiert, sobald die Prüfung abgeschlossen ist.", + "kycManualReviewTitle": "Registrierung wird geprüft", "kycMergeProcessingDescription": "Wir schließen die Zusammenführung Ihres Kontos ab. Das dauert in der Regel nur einen Moment — Ihr Verifizierungsstatus wird automatisch aktualisiert.", "kycMergeProcessingTitle": "Konten werden zusammengeführt", "kycPending": "Daten werden geprüft", @@ -247,8 +250,10 @@ "registerPhoneNumberOnlyDigits": "Nur Zahlen sind erlaubt", "registrationFailed": "Registrierung fehlgeschlagen:\n${message}", "registrationForwardingFailed": "Registrierung angenommen, aber die Weiterleitung an die Gesellschaft ist verzögert. Wir versuchen es automatisch erneut.", + "registrationRejected": "Der Server hat Ihre Registrierung abgelehnt:\n${reason}\nIhre Daten wurden nicht gespeichert — bitte prüfen Sie Ihre Angaben und senden Sie erneut ab.", "registrationRequired": "Zusätzliche Angaben erforderlich", "registrationRequiredDescription": "Um RealUnit Aktientoken zu kaufen, benötigen wir noch einige Daten von Ihnen.", + "removeTaxResidence": "Entfernen", "reset": "Zurücksetzen", "residence": "Residenz", "restoreWallet": "Wallet wiederherstellen", diff --git a/assets/languages/strings_en.arb b/assets/languages/strings_en.arb index c4eee91d7..ad3658495 100644 --- a/assets/languages/strings_en.arb +++ b/assets/languages/strings_en.arb @@ -3,6 +3,7 @@ "accountTypeHuman": "Individual person", "addBankAccount": "Add bank account", "address": "Address", + "addTaxResidence": "Add another tax residence", "aktionariatDisclaimer": "Disclaimer", "aktionariatImprint": "Imprint", "aktionariatPrivacyPolicy": "Privacy Policy", @@ -73,7 +74,7 @@ "copyClipboard": "Copied to clipboard", "countriesLoadFailed": "Could not load the country list. Please try again.", "country": "Country", - "countryHint": "Switzerland", + "countryHint": "Select country", "createWallet": "Create new Wallet", "createWalletConfirm": "I’ve written it down", "createWalletRecoveryKeyTitle": "Recovery Phrase", @@ -123,6 +124,8 @@ "kycLinkWalletDescription": "You are registered as a shareholder. Confirm this wallet to add it to your account.", "kycLinkWalletSubmit": "Add wallet", "kycLinkWalletTitle": "Add wallet", + "kycManualReviewDescription": "Your RealUnit registration needs a manual check by our team before it's complete. There's nothing you need to do — we'll take it from here. Your status updates automatically once the review is done.", + "kycManualReviewTitle": "Registration under review", "kycMergeProcessingDescription": "We're finishing your account merge. This usually takes a moment — your verification status will update automatically.", "kycMergeProcessingTitle": "Merging your accounts", "kycPending": "Data is being verified", @@ -247,8 +250,10 @@ "registerPhoneNumberOnlyDigits": "Only numbers are allowed", "registrationFailed": "Registration failed:\n${message}", "registrationForwardingFailed": "Registration accepted, but forwarding to the company is delayed. We will retry automatically.", + "registrationRejected": "The server rejected your registration:\n${reason}\nYour data has not been saved — please check your entries and submit again.", "registrationRequired": "Additional information required", "registrationRequiredDescription": "To purchase RealUnit stock tokens, we need some additional information from you.", + "removeTaxResidence": "Remove", "reset": "Reset", "residence": "Residence", "restoreWallet": "Restore wallet", diff --git a/docs/handbook/README.md b/docs/handbook/README.md index 4d98401f8..8eb55d72c 100644 --- a/docs/handbook/README.md +++ b/docs/handbook/README.md @@ -1,7 +1,7 @@ # RealUnit Handbook User-Guide + Test-Doku in einem. Jeder Screenshot ist eine Visual-Regression- -Golden-Baseline aus `test/goldens/screens/`; das Handbook bleibt nur dann +Golden-Baseline aus `test/goldens/` (`screens/` + `widgets/`); das Handbook bleibt nur dann visuell korrekt, wenn die App es auch ist — eine Drift im echten Seitenrendering flippt den entsprechenden Golden-Test rot, bevor das Handbook-Image gebaut wird. @@ -21,8 +21,8 @@ deployten Image (`handbook.realunit.app` / `dev-handbook.realunit.app`). ## Screenshots regenerieren -Es gibt keinen separaten Regeneration-Schritt: Die 61 Handbook-Screenshots -sind direkt die Golden-Baselines unter `test/goldens/screens/` (gemappt in +Es gibt keinen separaten Regeneration-Schritt: Die 278 Handbook-Screenshots +sind direkt die Golden-Baselines unter `test/goldens/` (gemappt in `scripts/assemble-handbook-screenshots.sh`). Eine UI-Änderung an einer der gemappten Pages produziert beim `flutter test test/goldens` einen Diff — diesen via `golden-regenerate.yaml` auf dem self-hosted Runner regenerieren lassen, und der diff --git a/docs/handbook/de/index.html b/docs/handbook/de/index.html index 538ba0a5d..b9aaa3a28 100644 --- a/docs/handbook/de/index.html +++ b/docs/handbook/de/index.html @@ -819,6 +819,189 @@
  • 17Verkaufen — States
  • +
  • + 18Willkommen — Wallet-Auswahl +
  • +
  • + 19Wallet erstellen — Ladezustand +
  • +
  • + 20Sicherung überprüfen — Prüfung läuft und Erfolg +
  • +
  • + 21Sicherung überprüfen — Fehler und Leerzustand +
  • +
  • + 22Wallet wiederherstellen — Eingabe und Ablauf +
  • +
  • + 23Wallet wiederherstellen — Ergebnis +
  • +
  • + 24PIN einrichten — Bestätigung und Speichern +
  • +
  • + 25PIN eingeben — Standard und Prüfung +
  • +
  • + 26PIN eingeben — Biometrie +
  • +
  • + 27PIN eingeben — Fehler und Sperren +
  • +
  • + 28App-Sperre und PIN zurücksetzen +
  • +
  • + 29Dashboard — Kursanzeige ohne Bestand +
  • +
  • + 30Dashboard — Bestandsentwicklung +
  • +
  • + 31Dashboard — Transaktionen +
  • +
  • + 32Dashboard — Beträge ausblenden +
  • +
  • + 33Transaktionsverlauf — Übersicht und Filter +
  • +
  • + 34Transaktionsverlauf — Beleg-Download (PDF) +
  • +
  • + 35Empfangen — QR-Code und Adresse +
  • +
  • + 36Kauf — Umrechner und Währungswahl +
  • +
  • + 37Kauf — Freigabe-Hürden vor dem Angebot +
  • +
  • + 38Kauf — Verbindliche Bestätigung +
  • +
  • + 39Zahlungsdetails nach dem Kauf +
  • +
  • + 40REALU verkaufen — Eingabemaske +
  • +
  • + 41REALU verkaufen — Bankkonto verwalten +
  • +
  • + 42REALU verkaufen — Bestätigen und Abschluss +
  • +
  • + 43REALU verkaufen (BitBox) — Schritt 1: Gasgebühren +
  • +
  • + 44Schritt 2: Tausch REALU → ZCHF +
  • +
  • + 45Schritt 3: ZCHF an DFX senden +
  • +
  • + 46Schritt 3: Einzahlung wiederholen (Fehlerfälle) +
  • +
  • + 47BitBox verbinden — Start und Verbindungsaufbau +
  • +
  • + 48BitBox verbinden — Kopplung prüfen +
  • +
  • + 49BitBox verbinden — Anmeldesignatur und Abschluss +
  • +
  • + 50BitBox verbinden — Fehler- und Sonderfälle +
  • +
  • + 51BitBox — Adresswiederherstellung +
  • +
  • + 52KYC — E-Mail und 2-Faktor-Authentifizierung +
  • +
  • + 53KYC — Registrierung +
  • +
  • + 54KYC — Staatsangehörigkeit +
  • +
  • + 55KYC — Finanzdaten +
  • +
  • + 56KYC — Identitätsprüfung und Wallet-Verknüpfung +
  • +
  • + 57KYC — Status- und Ergebnisseiten +
  • +
  • + 58Einstellungen +
  • +
  • + 59Spracheinstellungen — Laden und Fehler +
  • +
  • + 60Währungsauswahl — Laden und Fehler +
  • +
  • + 61Netzwerk — Umschalten +
  • +
  • + 62Seed-Anzeige — Ladezustand +
  • +
  • + 63Sicherheit — Übersicht und Biometrie-Umschalter +
  • +
  • + 64Sicherheit — Umschaltvorgang und Fehler +
  • +
  • + 65Steuerbericht +
  • +
  • + 66Nutzerdaten — Übersicht +
  • +
  • + 67Nutzerdaten — Laden und Fehler +
  • +
  • + 68Daten bearbeiten — Formulare +
  • +
  • + 69Daten bearbeiten — Verarbeitung und Ergebnis +
  • +
  • + 70Rechtliche Hinweise — Disclaimer-Ablauf +
  • +
  • + 71Rechtsdokumente — Anzeige und Ladezustände +
  • +
  • + 72Support — Übersicht und E-Mail-Erfassung +
  • +
  • + 73Support — Ticket erstellen +
  • +
  • + 74Support — Meine Tickets +
  • +
  • + 75Support — Chat +
  • +
  • + 76Debug-Auth — Adresse und Nachricht abrufen +
  • +
  • + 77Debug-Auth — Signieren und Authentifizieren +
  • +
  • + 78Telefonnummer-Eingabe +
  • WWeb · realunit.app
  • @@ -2152,13 +2335,13 @@

    14KYC — Registrierung & E-Mail

    />
    - Abschliessender Schritt der Registrierung. Der Pflicht-Länder-Picker „Land - des steuerlichen Wohnsitzes" ist mit dem im Adress-Schritt erfassten - Wohnsitzland vorbelegt — die meisten Menschen sind steueransässig, wo sie - wohnen — bleibt aber frei änderbar. Die Abbildung zeigt den unbelegten - Fallback-Zustand (kein Wohnsitzland vorhanden), in dem der User genau ein - Land wählen muss. Der „Abschliessen"-CTA übermittelt die vollständige - KYC-Anfrage an das DFX-Backend. + Abschliessender Schritt der Registrierung. Das Wohnsitzland aus dem + Adress-Schritt ist als Steuerwohnsitz hart verdrahtet (nicht entfernbar). + Weitere Steuerwohnsitze können hinzugefügt werden; bei Nicht-CH ist die + TIN Pflicht. Die Abbildung zeigt den unbelegten Fallback-Zustand (kein + Wohnsitzland vorhanden), in dem der User mindestens ein Land wählen muss. + Der „Abschliessen"-CTA übermittelt die vollständige KYC-Anfrage an das + DFX-Backend.
    @@ -2174,8 +2357,10 @@

    14KYC — Registrierung & E-Mail

    />
    - Geöffneter Länder-Picker des Steuer-Schritts. Der User wählt genau ein Land - des steuerlichen Wohnsitzes aus der scrollbaren Liste. + Geöffneter Länder-Picker des Steuer-Schritts im leeren Fallback (noch kein + Adressland). Bei gesetztem Wohnsitzland entfällt der Picker für den + primären Eintrag; er erscheint nur für zusätzlich hinzugefügte + Steuerwohnsitze.
    @@ -2191,8 +2376,9 @@

    14KYC — Registrierung & E-Mail

    />
    - Ist die Schweiz (CH) als steuerlicher Wohnsitz gewählt, entfällt die - TIN-Abfrage — der Schritt lässt sich direkt abschliessen. + Ist die Schweiz (CH) Wohnsitz- und damit auch Steuerwohnsitz (locked), + entfällt die TIN-Abfrage — der Schritt lässt sich direkt abschliessen. + Weitere Nicht-CH-Steuerwohnsitze können optional ergänzt werden (dann mit TIN).
    @@ -2208,9 +2394,9 @@

    14KYC — Registrierung & E-Mail

    />
    - Bei einem Nicht-CH-Steuerwohnsitz erscheint zusätzlich das Pflichtfeld - „Steueridentifikationsnummer (TIN)", das zusammen mit dem gewählten Land - übermittelt wird. + Bei Nicht-CH-Wohnsitz (z. B. DE) ist dieses Land als Steuerwohnsitz + locked und die TIN ist Pflicht. Über „Weiteren Steuerwohnsitz hinzufügen" + können weitere Länder ergänzt werden (jeweils mit TIN, ausser CH).
    @@ -2249,6 +2435,195 @@

    14KYC — Registrierung & E-Mail

    blockiert.
    +
    +
    + + + kyc_registration_tax_step_multi.png +
    +
    + KYC Registrierung — Mehrere Steuerwohnsitze +
    +
    + Wohnsitz DE ist locked inkl. TIN-Feld; ein zweiter Steuerwohnsitz + (z. B. CH) wurde über „Weiteren Steuerwohnsitz hinzufügen" + ergänzt. Beliebig viele zusätzliche Länder sind möglich. +
    +
    + +

    Kundenszenarien Steuerwohnsitz (S1–S5)

    +

    + Fünf repräsentative Kundenfälle: Das Wohnsitzland (Adresse) ist jeweils + als primärer Steuerwohnsitz locked. Submit leitet + swissTaxResidence und countryAndTINs + (alle Nicht-CH-Zeilen mit getrimmter TIN) ab. +

    + +
    +
    + + + kyc_tax_scenario_s1_ch_only.png +
    +
    + S1 — nur CH +
    +
    + S1: Wohnsitz CH, nur CH als Steuerwohnsitz. + Keine TIN. Submit: swissTaxResidence=true, + countryAndTINs=null. +
    +
    + +
    +
    + + + kyc_tax_scenario_s2_de_only_empty_tin.png +
    +
    + S2 — DE only, leere TIN +
    +
    + S2 (leer): Wohnsitz DE locked, TIN-Feld sichtbar und noch leer. +
    +
    + +
    +
    + + + kyc_tax_scenario_s2_de_only_filled.png +
    +
    + S2 — DE only, TIN ausgefüllt +
    +
    + S2 (ausgefüllt): DE locked mit TIN. + Submit: swissTaxResidence=false, + countryAndTINs=[{DE, tin}]. +
    +
    + +
    +
    + + + kyc_tax_scenario_s2_de_only_tin_error.png +
    +
    + S2 — DE only, TIN-Validierungsfehler +
    +
    + S2 (Fehler): Abschliessen ohne TIN — Validierungsfehler am TIN-Feld. +
    +
    + +
    +
    + + + kyc_tax_scenario_s3_ch_fr.png +
    +
    + S3 — CH + FR +
    +
    + S3: Wohnsitz CH locked + zusätzliches FR mit TIN. + Submit: swissTaxResidence=true, + countryAndTINs=[{FR, tin}]. +
    +
    + +
    +
    + + + kyc_tax_scenario_s3_ch_fr_tin_error.png +
    +
    + S3 — CH + FR, TIN-Fehler +
    +
    + S3 (Fehler): FR hinzugefügt, TIN leer — Validierungsfehler. +
    +
    + +
    +
    + + + kyc_tax_scenario_s4_de_ch.png +
    +
    + S4 — DE + CH +
    +
    + S4: Wohnsitz DE locked inkl. TIN + zusätzliches CH (ohne TIN). + Submit: swissTaxResidence=true, + countryAndTINs=[{DE, tin}]. +
    +
    + +
    +
    + + + kyc_tax_scenario_s5_de_fr_us.png +
    +
    + S5 — DE + FR + US +
    +
    + S5: Wohnsitz DE locked + FR + US, alle TINs ausgefüllt. + Submit: swissTaxResidence=false, + countryAndTINs=[{DE},{FR},{US}]. +
    +
    + +
    +
    + + + kyc_tax_scenario_s5_de_fr_us_partial_tin_error.png +
    +
    + S5 — fehlende TIN +
    +
    + S5 (Fehler): DE + FR + US, eine TIN fehlt — Validierungsfehler. +
    +
    @@ -2547,59 +2922,5079 @@

    17Verkaufen — States


    -
    +
    -

    WWeb · realunit.app

    -
    RealUnitCH/web · tests/__screenshots__/
    +

    18Willkommen — Wallet-Auswahl

    +
    test/goldens/screens/welcome/
    - Live aus web-Repo + 1 Screen
    -

    - Die öffentliche Website realunit.app - (Landingpage plus der Aktionariat-Adressbestätigungs-Flow unter - /confirm-aktionariat) hat ihre eigene Visual-Regression-Suite. - Jedes Bild hier ist eine Playwright-Baseline aus dem - RealUnitCH/web-Repo - (tests/__screenshots__/), aufgenommen im gepinnten - Linux-Container über drei Viewports — Desktop (Chromium), - Tablet (Chromium) und Mobile (Safari). Es gilt dieselbe - Eigenschaft wie bei den App-Screens oben: Driftet das Seitenrendering, - wird zuerst der Visual-Regression-Test im web-Repo rot, bevor die - Baseline sich ändert. Bei jedem Handbook-Build werden die aktuell - akzeptierten Baselines aus develop automatisch ins Image - gezogen — Single Source of Truth ist das web-Repo, dieses Handbook - spiegelt nur den aktuellen Stand. + Der Willkommensbildschirm ist der Einstiegspunkt der App und erscheint beim allerersten Start, bevor eine Wallet eingerichtet ist. Der Nutzer wählt hier, mit welchem Wallet-Typ er RealUnit Aktientoken verwahren möchte.

    -

    Startseite

    -
    +
    - - - desktop-chromium/home.png + + + screens/welcome/goldens/macos/welcome_page_android.png
    Startseite — Desktop
    - Die Landingpage von realunit.app in der Desktop-Ansicht (Chromium). + Zeigt den ersten Schritt des Willkommensbildschirms auf Android mit dem Titel RealUnit Wallet, dem Untertitel zum kostenlosen Kauf und zur bankenunabhängigen Verwahrung sowie zwei Auswahlkarten: Software-Wallet (App) zum Erstellen oder Wiederherstellen einer Wallet und BitBox Hardware-Wallet für Besitzer einer Bitbox02 Nova. Diese Ansicht sieht der Nutzer beim ersten App-Start, solange noch keine Wallet eingerichtet ist; ein Tippen auf Software-Wallet führt zum zweiten Schritt mit der Wahl zwischen Erstellen und Wiederherstellen.
    -
    -
    +
    +
    + +
    + +
    + +
    +
    +

    19Wallet erstellen — Ladezustand

    +
    test/goldens/screens/create_wallet/
    +
    +
    + 1 Screen + +
    +
    +
    +

    + Der Bildschirm „Wallet erstellen" generiert beim Öffnen im Hintergrund eine neue Seed-Phrase. Bis diese vorliegt, zeigt die App einen kurzen Ladezustand, bevor die Wiederherstellungswörter erscheinen. +

    + +
    +
    +
    + + + screens/create_wallet/goldens/macos/create_wallet_page_loading.png +
    +
    + Wallet erstellen: zentrierter Ladespinner +
    +
    + Dieser Zustand zeigt einen zentrierten Ladeindikator auf leerer, hellblauer Fläche mit oberer App-Leiste, solange noch keine Wallet generiert wurde. Der Nutzer sieht ihn kurz direkt nach dem Öffnen des Bildschirms „Wallet erstellen", während im Hintergrund die neue Seed-Phrase erzeugt wird. Sobald das Wallet bereitsteht, wird der Spinner durch Titel, Erklärtext und die verdeckte Seed-Karte ersetzt. +
    +
    +
    +
    + +
    + +
    + +
    +
    +

    20Sicherung überprüfen — Prüfung läuft und Erfolg

    +
    test/goldens/screens/verify_seed/
    +
    +
    + 2 Screens + +
    +
    +
    +

    + Auf diesem Bildschirm gibt der Nutzer vier abgefragte Wörter seiner Wiederherstellungsphrase erneut ein, um zu bestätigen, dass er die Sicherung korrekt notiert hat. Diese beiden Zustände zeigen den Bestätigen-Button, während die Wallet gespeichert wird, und nach erfolgreicher Prüfung. +

    + +
    +
    +
    + + + screens/verify_seed/goldens/macos/verify_seed_page_verifying.png +
    +
    + Sicherung überprüfen — Prüfung läuft +
    +
    + Der Nutzer hat die vier abgefragten Wörter eingegeben und auf Bestätigen getippt; die Wallet wird gerade gespeichert und als aktuell gesetzt. Der Bestätigen-Button ist ausgegraut und zeigt einen Ladeindikator, solange der Speichervorgang läuft. +
    +
    +
    +
    + + + screens/verify_seed/goldens/macos/verify_seed_page_verified.png +
    +
    + Sicherung überprüfen — erfolgreich +
    +
    + Die eingegebenen Wörter stimmen und die Wallet wurde erfolgreich gespeichert. Der Button wird grün und zeigt mit Häkchen Seed erfolgreich überprüft; dieser Zustand bleibt rund zwei Sekunden sichtbar, bevor das Onboarding weiterschaltet. +
    +
    +
    +
    + +
    + +
    + +
    +
    +

    21Sicherung überprüfen — Fehler und Leerzustand

    +
    test/goldens/screens/verify_seed/
    +
    +
    + 3 Screens + +
    +
    +
    +

    + Diese Zustände decken die Fehlerfälle ab: eine falsche Eingabe, ein fehlgeschlagenes Speichern der korrekt bestätigten Wallet sowie den kurzen leeren Übergangszustand, bevor die abzufragenden Wörter feststehen. +

    + +
    +
    +
    + + + screens/verify_seed/goldens/macos/verify_seed_page_error.png +
    +
    + Sicherung überprüfen — falsche Wörter +
    +
    + Mindestens eines der vier eingegebenen Wörter stimmt nicht mit der Wiederherstellungsphrase überein. Der Eingaberahmen wird rot umrandet und der Button erscheint rot mit dem Text Die Wörter stimmen nicht überein. +
    +
    +
    +
    + + + screens/verify_seed/goldens/macos/verify_seed_page_commit_failed.png +
    +
    + Sicherung überprüfen — Speichern fehlgeschlagen +
    +
    + Die Wörter waren korrekt, doch das Speichern der Wallet auf dem Gerät ist fehlgeschlagen. Der blaue Button zeigt mit Aktualisieren-Symbol Ihre Wallet konnte nicht gespeichert werden — zum Erneut-Versuchen tippen und lässt sich antippen, um den Vorgang zu wiederholen. +
    +
    +
    +
    + + + screens/verify_seed/goldens/macos/verify_seed_page_empty.png +
    +
    + Sicherung überprüfen — leerer Übergangszustand +
    +
    + Es sind noch keine abzufragenden Wortpositionen bestimmt, daher zeigt der Bildschirm ausser dem leeren Hintergrund nichts an. Dieser kurze Übergangszustand tritt auf, bevor die vier zufällig ausgewählten Wörter feststehen. +
    +
    +
    +
    + +
    + +
    + +
    +
    +

    22Wallet wiederherstellen — Eingabe und Ablauf

    +
    test/goldens/screens/restore_wallet/
    +
    +
    + 2 Screens + +
    +
    +
    +

    + Auf diesem Bildschirm gibt der Nutzer seine bestehenden 12 Wiederherstellungs-Wörter ein, um wieder Zugriff auf die Wallet zu erhalten. Diese beiden Zustände zeigen die abgeschlossene Eingabe und die laufende Wiederherstellung, beide mit ockerfarbenem Rahmen. +

    + +
    +
    +
    + + + screens/restore_wallet/goldens/macos/restore_wallet_page_complete.png +
    +
    + Seed vollständig, Weiter-Button aktiv +
    +
    + Alle 12 Wiederherstellungs-Wörter sind eingegeben und im BIP39-Wörterbuch erkannt; der Rahmen um die nummerierte Wortliste ist ockerfarben und der blaue Weiter-Button ist aktiv. Ein Tippen auf Weiter startet die Prüfung der Seed-Phrase. +
    +
    +
    +
    + + + screens/restore_wallet/goldens/macos/restore_wallet_page_restoring.png +
    +
    + Wiederherstellung läuft, Ladespinner +
    +
    + Die Seed-Phrase wurde als gültig geprüft und die Wallet wird gerade wiederhergestellt; der Rahmen bleibt ockerfarben und der Weiter-Button zeigt statt Text einen Ladespinner. Der Nutzer sieht diesen Zustand kurz nach dem Absenden, solange die Wiederherstellung im Gange ist. +
    +
    +
    +
    + +
    + +
    + +
    +
    +

    23Wallet wiederherstellen — Ergebnis

    +
    test/goldens/screens/restore_wallet/
    +
    +
    + 2 Screens + +
    +
    +
    +

    + Sobald die Seed-Phrase geprüft ist, wechselt der Rahmen um die Wortliste auf grün und der Button meldet das Ergebnis. Diese beiden Zustände zeigen die erfolgreiche Wiederherstellung sowie den Fehlerfall mit Wiederholen-Möglichkeit. +

    + +
    +
    +
    + + + screens/restore_wallet/goldens/macos/restore_wallet_page_success.png +
    +
    + Wiederherstellung erfolgreich, grüner Button +
    +
    + Die Wallet wurde erfolgreich wiederhergestellt; der Rahmen ist nun grün und der Button erscheint grün mit Häkchen und der Beschriftung Wallet wiederhergestellt. Nach rund zwei Sekunden wechselt die App automatisch zur Startseite. +
    +
    +
    +
    + + + screens/restore_wallet/goldens/macos/restore_wallet_page_failed.png +
    +
    + Wiederherstellung fehlgeschlagen, erneut versuchen +
    +
    + Die Wiederherstellung ist fehlgeschlagen; der Rahmen ist zwar grün, doch der Button zeigt ein Aktualisieren-Symbol und die Beschriftung Wiederherstellung fehlgeschlagen – erneut versuchen. Dieser Button ist tippbar, sodass der Nutzer den Versuch direkt wiederholen kann. +
    +
    +
    +
    + +
    + +
    + +
    +
    +

    24PIN einrichten — Bestätigung und Speichern

    +
    test/goldens/screens/pin/
    +
    +
    + 3 Screens + +
    +
    +
    +

    + Beim Anlegen der PIN muss der sechsstellige Code zweimal eingegeben werden. Diese Zustände zeigen die Bestätigungsphase: das Speichern, eine Nichtübereinstimmung und einen Speicherfehler. +

    + +
    +
    +
    + + + screens/pin/goldens/macos/setup_pin_page_submitting.png +
    +
    + PIN einrichten: Speichern-Spinner +
    +
    + Der Bestätigungsschritt, nachdem die zweite Eingabe mit der ersten übereinstimmt: Der Ziffernblock ist durch einen Ladeindikator mit dem Label Speichern… ersetzt. Der Nutzer sieht dies kurz, während die PIN in den sicheren Speicher geschrieben wird. +
    +
    +
    +
    + + + screens/pin/goldens/macos/setup_pin_page_confirm_mismatch.png +
    +
    + PIN einrichten: PINs stimmen nicht überein +
    +
    + Der Bestätigungsschritt mit rot markierten Punkten und der Meldung Die PINs stimmen nicht überein. Versuchen Sie es erneut. Erscheint, wenn die zur Bestätigung eingegebene PIN von der ersten Eingabe abweicht. +
    +
    +
    +
    + + + screens/pin/goldens/macos/setup_pin_page_store_failed.png +
    +
    + PIN einrichten: Speichern fehlgeschlagen +
    +
    + Der Ziffernblock bleibt sichtbar und darunter steht die rote Meldung Die PIN konnte nicht gespeichert werden. Versuchen Sie es erneut. Tritt auf, wenn das Ablegen der PIN im sicheren Speicher fehlschlägt. +
    +
    +
    +
    + +
    + +
    + +
    +
    +

    25PIN eingeben — Standard und Prüfung

    +
    test/goldens/screens/pin/
    +
    +
    + 2 Screens + +
    +
    +
    +

    + Der Entsperrbildschirm verlangt die sechsstellige PIN, um die Wallet oder eine geschützte Funktion freizugeben. Hier die neutrale Eingabe und die laufende Prüfung. +

    + +
    +
    +
    + + + screens/pin/goldens/macos/verify_pin_page_default.png +
    +
    + PIN eingeben: Standardzustand +
    +
    + Der neutrale Entsperrbildschirm mit dem Titel Geben Sie Ihre PIN ein, sechs leeren Punkten und dem Ziffernblock. Dies ist der Ausgangszustand, wenn die App oder eine geschützte Funktion die PIN verlangt. +
    +
    +
    +
    + + + screens/pin/goldens/macos/verify_pin_page_verifying.png +
    +
    + PIN eingeben: Prüfung läuft +
    +
    + Nach vollständiger PIN-Eingabe ist der Ziffernblock durch einen Ladeindikator ersetzt. Der Zustand deckt sowohl die Prüfung des PIN-Hashes als auch das anschliessende Laden der Wallet ab, während dieser Bildschirm noch oben liegt. +
    +
    +
    +
    + +
    + +
    + +
    +
    +

    26PIN eingeben — Biometrie

    +
    test/goldens/screens/pin/
    +
    +
    + 4 Screens + +
    +
    +
    +

    + Ist die biometrische Entsperrung eingerichtet, erscheint im Ziffernblock eine zusätzliche Taste. Ist die Biometrie vorübergehend oder dauerhaft nicht nutzbar, erklärt ein Hinweistext, warum die Taste fehlt. +

    + +
    +
    +
    + + + screens/pin/goldens/macos/verify_pin_page_biometric_button.png +
    +
    + PIN eingeben: Biometrie-Taste +
    +
    + Der Ziffernblock zeigt zusätzlich eine Biometrie-Taste (Fingerabdruck bzw. Gesicht). Sie erscheint nur, wenn die biometrische Entsperrung eingerichtet und aktuell nutzbar ist; ein Tippen löst die Entsperrung per Face ID oder Fingerabdruck aus. +
    +
    +
    +
    + + + screens/pin/goldens/macos/verify_pin_page_biometric_hint.png +
    +
    + PIN eingeben: Biometrie gesperrt +
    +
    + Statt der Biometrie-Taste erscheint der Hinweis Face ID/Biometrie ist gesperrt. Entsperren Sie Ihr Gerät einmal mit Ihrem Gerätecode (PIN, Muster oder Passwort). Tritt auf, wenn die Biometrie nach zu vielen Fehlversuchen vorübergehend gesperrt ist. +
    +
    +
    +
    + + + screens/pin/goldens/macos/verify_pin_page_biometric_hint_not_enrolled.png +
    +
    + PIN eingeben: Biometrie nicht eingerichtet +
    +
    + Der Hinweistext Face ID/Biometrie ist auf diesem Gerät nicht mehr eingerichtet. erklärt, warum die Biometrie-Taste fehlt. Erscheint, wenn die zuvor aktivierte Biometrie am Gerät entfernt wurde. +
    +
    +
    +
    + + + screens/pin/goldens/macos/verify_pin_page_biometric_hint_unavailable.png +
    +
    + PIN eingeben: Biometrie nicht verfügbar +
    +
    + Der Hinweistext Biometrische Entsperrung ist derzeit nicht verfügbar. ersetzt die Biometrie-Taste. Erscheint, wenn die Biometrie momentan nicht angesprochen werden kann; die PIN-Eingabe bleibt weiterhin möglich. +
    +
    +
    +
    + +
    + +
    + +
    +
    +

    27PIN eingeben — Fehler und Sperren

    +
    test/goldens/screens/pin/
    +
    +
    + 4 Screens + +
    +
    +
    +

    + Nach Fehleingaben markiert der Bildschirm die Punkte rot und blendet eine Meldung ein. Bei zu vielen Fehlversuchen wird der Ziffernblock gesperrt; ein Speicherfehler macht die PIN unüberprüfbar. +

    + +
    +
    +
    + + + screens/pin/goldens/macos/verify_pin_page_failure.png +
    +
    + PIN eingeben: falsche PIN +
    +
    + Die Punkte sind rot und darunter steht Die PIN ist falsch. Versuchen Sie es erneut. Dieser Zustand folgt auf eine falsche Eingabe; der Ziffernblock bleibt aktiv, sodass ein neuer Versuch möglich ist. +
    +
    +
    +
    + + + screens/pin/goldens/macos/verify_pin_page_temporarily_locked.png +
    +
    + PIN eingeben: vorübergehend gesperrt +
    +
    + Nach zu vielen Fehlversuchen ist der Ziffernblock gesperrt und die Meldung Zu viele Fehlversuche. Versuchen Sie es in 1h 5m erneut. zeigt die verbleibende Wartezeit. Der Countdown zählt herunter, bis die Eingabe wieder freigegeben wird. +
    +
    +
    +
    + + + screens/pin/goldens/macos/verify_pin_page_locked.png +
    +
    + PIN eingeben: dauerhaft gesperrt +
    +
    + Der Ziffernblock ist gesperrt und die Meldung lautet Zu viele Fehlversuche. Nutzen Sie 'PIN vergessen?', um zurückzusetzen. Dieser Endzustand nach dem Ausschöpfen aller Versuche lässt nur noch das Zurücksetzen der Wallet zu. +
    +
    +
    +
    + + + screens/pin/goldens/macos/verify_pin_page_unverifiable.png +
    +
    + PIN eingeben: nicht überprüfbar (Gate) +
    +
    + Der Ziffernblock ist gesperrt und die Meldung lautet Ihre PIN kann nicht überprüft werden (Speicherfehler). Sperren Sie die App und setzen Sie die Wallet über den Sperrbildschirm zurücksetzen. Diese Variante ohne 'PIN vergessen?'-Taste erscheint, wenn eine geschützte Funktion die PIN nicht aus dem Speicher lesen kann. +
    +
    +
    +
    + +
    + +
    + +
    +
    +

    28App-Sperre und PIN zurücksetzen

    +
    test/goldens/screens/pin/
    +
    +
    + 3 Screens + +
    +
    +
    +

    + Beim App-Start dient der PIN-Bildschirm als Sperrbildschirm und bietet zusätzlich die Aktion 'PIN vergessen?'. Darüber lässt sich die Wallet im Notfall zurücksetzen und über die Seed-Phrase wiederherstellen. +

    + +
    +
    +
    + + + screens/pin/goldens/macos/verify_pin_page_app_lock.png +
    +
    + App-Sperre mit PIN vergessen +
    +
    + Der Entsperrbildschirm beim App-Start: unter dem Ziffernblock steht zusätzlich die Aktion PIN vergessen? Nur diese App-Sperr-Variante bietet diesen Ausweg an. +
    +
    +
    +
    + + + screens/pin/goldens/macos/verify_pin_page_unverifiable_app_lock.png +
    +
    + App-Sperre: nicht überprüfbar +
    +
    + Die App-Sperre bei einem Speicherfehler: der Ziffernblock ist gesperrt und die Meldung Ihre PIN kann nicht überprüft werden (Speicherfehler). Setzen Sie die Wallet über 'PIN vergessen?' zurücksetzen und stellen Sie sie mit Ihrer Seed-Phrase wieder her. verweist auf die darunter angebotene Aktion PIN vergessen? +
    +
    +
    +
    + + + screens/pin/goldens/macos/forgot_pin_bottom_sheet_default.png +
    +
    + PIN vergessen: Wallet zurücksetzen +
    +
    + Das Bestätigungs-Bottom-Sheet nach Tippen auf PIN vergessen? mit dem Titel Wallet wird zurückgesetzt und dem Warnhinweis, dass Wallet und Daten gelöscht werden und die Wiederherstellungsphrase gesichert sein muss. Zwei Tasten stehen zur Wahl: Schliessen und Zurücksetzen. +
    +
    +
    +
    + +
    + +
    + +
    +
    +

    29Dashboard — Kursanzeige ohne Bestand

    +
    test/goldens/screens/dashboard/
    +
    +
    + 2 Screens + +
    +
    +
    +

    + Solange die Wallet keine REALU-Anteile hält, zeigt das Dashboard oben den RealUnit-Aktienkurs mit Kurschart und Zeitraum-Auswahl und darunter die Token-Illustration mit dem Kaufen-Button. Diese beiden Zustände unterscheiden sich nur darin, ob bereits Kursdaten geladen sind. +

    + +
    +
    +
    + + + screens/dashboard/goldens/macos/dashboard_empty.png +
    +
    + Leeres Dashboard ohne Kursdaten und ohne Bestand +
    +
    + Startzustand einer Wallet ohne Guthaben und ohne geladene Kursdaten: Die Kopfzeile zeigt RealUnit Aktienkurs mit dem Platzhalter CHF --.-- und ein leeres Kurschart mit reinem Raster. Darunter erscheinen die Token-Illustration und der Button RealUnit kaufen. Der Nutzer sieht dies unmittelbar nach dem Einrichten, bevor Kurs oder Bestand vorhanden sind. +
    +
    +
    +
    + + + screens/dashboard/goldens/macos/dashboard_price_chart.png +
    +
    + Dashboard mit geladenem Kurschart, kein Bestand +
    +
    + Gleicher bestandsloser Aufbau, aber mit geladenen Kursdaten: Die Kopfzeile zeigt CHF 1.53, darunter verläuft die Kurskurve mit Verlaufs-Füllung und den Achsenwerten 1.56 und 1.47. Über die Buttons 1W/1M/3M/1J/MAX wählt man den Zeitraum; aktiv ist MAX. Da noch kein REALU gehalten wird, bleiben Token-Illustration und Kaufen-Button darunter. +
    +
    +
    +
    + +
    + +
    + +
    +
    +

    30Dashboard — Bestandsentwicklung

    +
    test/goldens/screens/dashboard/
    +
    +
    + 1 Screen + +
    +
    +
    +

    + Sobald eine Portfolio-Historie vorliegt, ersetzt die Kopfzeile den reinen Kurs durch die Wertentwicklung des eigenen Bestands samt Veränderung über den gewählten Zeitraum. +

    + +
    +
    +
    + + + screens/dashboard/goldens/macos/dashboard_portfolio_chart.png +
    +
    + Kopfzeile mit Bestandsentwicklung statt Kurs +
    +
    + Sobald eine Portfolio-Historie vorliegt, ersetzt die Kopfzeile den Kurs durch Bestandsentwicklung mit dem aktuellen Wert CHF153.00 und rechts der Veränderung +5.00 CHF | +3.38% für den Zeitraum Max. Das Chart zeigt die Wertentwicklung des eigenen Bestands mit den Achsenwerten von 135 bis 160. +
    +
    +
    +
    + +
    + +
    + +
    +
    +

    31Dashboard — Transaktionen

    +
    test/goldens/screens/dashboard/
    +
    +
    + 2 Screens + +
    +
    +
    +

    + Der untere Bereich des Dashboards zeigt laufende und abgeschlossene Transaktionen. Ausstehende DFX-Aufträge erscheinen unabhängig vom Bestand, die Bestands- und Verlaufsansicht erst ab einem Guthaben grösser null. +

    + +
    +
    +
    + + + screens/dashboard/goldens/macos/dashboard_pending_transactions.png +
    +
    + Karte mit ausstehenden Transaktionen und Ladeindikator +
    +
    + Unter dem Kurschart erscheint die Karte Ausstehende Transaktionen mit noch laufenden DFX-Aufträgen: Kauf / In Bearbeitung / 500 CHF und Verkauf / Warte auf Zahlung / 30 REALU, jeweils mit Datum und drehendem Ladeindikator. Der Nutzer sieht dies, solange ein Kauf oder Verkauf beim Zahlungsdienst noch nicht abgeschlossen ist; da hier noch kein Guthaben vorliegt, folgen darunter Token-Illustration und Kaufen-Button. +
    +
    +
    +
    + + + screens/dashboard/goldens/macos/dashboard_recent_transactions.png +
    +
    + Dashboard mit Bestand, Aktionen und letzten Transaktionen +
    +
    + Ansicht mit vorhandenem Guthaben: Oben die Aktionen Kaufen und Verkaufen, darunter der Abschnitt Bestand mit REALU 153.00 CHF und dem Detail 100 x 1.53 CHF. Die Karte Letzte Transaktionen listet die jüngsten Bewegungen (+ 50 REALU, - 20 REALU, + 100 REALU) mit Datum und schliesst mit dem Link Transaktionshistorie. +
    +
    +
    +
    + +
    + +
    + +
    +
    +

    32Dashboard — Beträge ausblenden

    +
    test/goldens/screens/dashboard/
    +
    +
    + 1 Screen + +
    +
    +
    +

    + Ist der Datenschutz-Schalter aktiv, maskiert das Dashboard alle Geldbeträge, lässt die übrige Struktur aber unverändert. +

    + +
    +
    +
    + + + screens/dashboard/goldens/macos/dashboard_hidden_amounts.png +
    +
    + Dashboard mit maskierten Beträgen bei aktivem Datenschutz +
    +
    + Dieselbe Bestandsansicht bei aktivem Datenschutz-Schalter Beträge ausblenden: Der Bestandswert und alle Transaktionsbeträge sind durch \*\*\*.\*\* ersetzt, während Struktur, Anzahl 100 x 1.53 CHF und Beschriftungen sichtbar bleiben. Der Nutzer aktiviert diesen Modus in den Einstellungen, um Geldbeträge vor fremden Blicken zu verbergen. +
    +
    +
    +
    + +
    + +
    + +
    +
    +

    33Transaktionsverlauf — Übersicht und Filter

    +
    test/goldens/screens/transaction_history/
    +
    +
    + 3 Screens + +
    +
    +
    +

    + Der Transaktionsverlauf listet alle RealUnit-Käufe und -Verkäufe der Wallet. Über die beiden Datumsfelder Anfangsdatum und Enddatum lässt sich der angezeigte Zeitraum eingrenzen. +

    + +
    +
    +
    + + + screens/transaction_history/goldens/macos/transaction_history_page_default.png +
    +
    + Transaktionsverlauf ohne Einträge +
    +
    + Zeigt den leeren Transaktionsverlauf: die beiden Filterfelder Anfangsdatum (Vorgabe ein Jahr zurück) und Enddatum (heute) sind gesetzt, im Zeitraum liegen aber keine Buchungen vor. Der PDF-Sammel-Download oben rechts ist deshalb ausgegraut und inaktiv. +
    +
    +
    +
    + + + screens/transaction_history/goldens/macos/transaction_history_page_list.png +
    +
    + Transaktionsverlauf mit drei Einträgen +
    +
    + Der befüllte Verlauf mit drei Buchungen: zwei Käufe (+ 50 und + 100 REALU) sowie ein Verkauf (- 20 REALU), je mit Datum, Uhrzeit und eigenem Beleg-Symbol am rechten Rand. Der blaue PDF-Button oben rechts ist jetzt aktiv und erzeugt einen Sammelbeleg über alle sichtbaren Buchungen. +
    +
    +
    +
    + + + screens/transaction_history/goldens/macos/transaction_history_page_date_picker.png +
    +
    + Geöffneter Datumswähler +
    +
    + Nach Antippen des Feldes Anfangsdatum öffnet sich der Kalender-Dialog (hier auf Mai 2025) über der abgedunkelten Seite. Mit OK wird das gewählte Datum übernommen und der Verlauf neu gefiltert, Abbrechen schliesst den Dialog ohne Änderung. +
    +
    +
    +
    + +
    + +
    + +
    +
    +

    34Transaktionsverlauf — Beleg-Download (PDF)

    +
    test/goldens/screens/transaction_history/
    +
    +
    + 3 Screens + +
    +
    +
    +

    + Zu jeder Buchung lässt sich ein PDF-Beleg erzeugen: einzeln über das Symbol in der Zeile oder gesammelt über den grossen PDF-Button. Diese Zustände zeigen die Beleg-Erzeugung und den Fehlerfall. +

    + +
    +
    +
    + + + screens/transaction_history/goldens/macos/transaction_history_row_receipt_loading.png +
    +
    + Einzelbeleg wird erzeugt +
    +
    + Eine einzelne Zeile (Kauf, + 50 REALU) während der Erzeugung des Einzelbelegs: das Download-Symbol am rechten Rand ist durch einen kleinen Ladekreis ersetzt. Der Nutzer sieht dies unmittelbar nach dem Tippen auf das Beleg-Symbol der Zeile. +
    +
    +
    +
    + + + screens/transaction_history/goldens/macos/transaction_history_row_receipt_failure.png +
    +
    + Fehlermeldung Beleg-Erzeugung +
    +
    + Konnte der Beleg nicht erstellt werden, erscheint am unteren Rand eine rote Fehlermeldung 'Beleg konnte nicht erstellt werden.'. Das Download-Symbol der Zeile ist wieder sichtbar, sodass ein erneuter Versuch möglich ist. +
    +
    +
    +
    + + + screens/transaction_history/goldens/macos/transaction_history_multi_receipt_loading.png +
    +
    + Sammelbeleg wird erzeugt +
    +
    + Der grosse blaue PDF-Sammel-Button zeigt während der Erzeugung des Sammelbelegs über alle gefilterten Buchungen einen Ladekreis statt des Download-Symbols. Dieser Zustand erscheint nach Antippen des PDF-Buttons oben rechts. +
    +
    +
    +
    + +
    + +
    + +
    +
    +

    35Empfangen — QR-Code und Adresse

    +
    test/goldens/screens/receive/
    +
    +
    + 2 Screens + +
    +
    +
    +

    + Die Empfangen-Seite zeigt die Wallet-Adresse als QR-Code sowie darunter als antippbaren Text zum Kopieren. Dieselbe Ansicht erscheint in zwei Varianten, die sich nur im Rahmen unterscheiden: als aufziehbares Bottom-Sheet oder als vollflächige Seite mit Zurück-Pfeil. +

    + +
    +
    +
    + + + screens/receive/goldens/macos/receive_page_default.png +
    +
    + Empfangen als Bottom-Sheet mit QR-Code +
    +
    + Die Bottom-Sheet-Variante der Empfangen-Seite: oben der horizontale Ziehbalken (Handlebar), darunter der QR-Code der eigenen Wallet-Adresse und die zweizeilig formatierte Adresse mit fett hervorgehobenem Anfang und Ende sowie dem Kopieren-Symbol. Der Nutzer sieht diese Variante, wenn die Seite als aufgezogenes Blatt von unten geöffnet wird; ein Tippen auf die Adresse kopiert sie in die Zwischenablage. +
    +
    +
    +
    + + + screens/receive/goldens/macos/receive_page_full_page.png +
    +
    + Empfangen als Vollseite mit Zurück-Pfeil +
    +
    + Die vollflächige Variante derselben Empfangen-Seite: oben eine AppBar mit Zurück-Pfeil statt Ziehbalken, darunter identisch der QR-Code, die zweizeilige Adresse und das Kopieren-Symbol. Diese Variante erscheint, wenn die Seite über die Navigation als eigene Route geöffnet wird; über den Pfeil gelangt der Nutzer zurück. +
    +
    +
    +
    + +
    + +
    + +
    +
    +

    36Kauf — Umrechner und Währungswahl

    +
    test/goldens/screens/buy/
    +
    +
    + 4 Screens + +
    +
    +
    +

    + Die Kaufseite (RealUnit Aktientoken kaufen) beginnt mit dem Umrechner: oben Sie bezahlen (Fiat-Betrag und Währung), darunter Sie erhalten (RealU-Anteile). Diese Ansichten zeigen den Normalzustand, ein geladenes Angebot sowie die Währungsauswahl und deren Fehlerfall. +

    + +
    +
    +
    + + + screens/buy/goldens/macos/buy_initial.png +
    +
    + Kaufseite Startzustand mit leerem Umrechner +
    +
    + Der Startzustand der Kaufseite mit leerem Umrechner: die Felder Sie bezahlen (Währung CHF) und Sie erhalten (RealU) sind noch ohne Betrag. Es ist noch kein Angebot abgerufen, deshalb erscheint unten keine Kauf-Schaltfläche. +
    +
    +
    +
    + + + screens/buy/goldens/macos/buy_payment_info_loaded.png +
    +
    + Gültiges Kaufangebot mit Kauf-Schaltfläche +
    +
    + Ein gültiges Angebot ist geladen: der Umrechner zeigt 100 CHF gegen 1.00 RealU. Unten erscheint die verbindliche Kauf-Schaltfläche Jetzt verbindlich kaufen, mit der der Nutzer den Kauf abschliessen kann. +
    +
    +
    +
    + + + screens/buy/goldens/macos/buy_currency_picker_open.png +
    +
    + Geöffnete Währungsauswahl mit CHF und EUR +
    +
    + Die Währungsauswahl ist geöffnet: nach Tippen auf das Währungsfeld zeigt das Menü die kaufbaren Währungen CHF und EUR mit Kürzel und Namen. Der Nutzer sieht das, wenn er die Bezahlwährung wechseln möchte. +
    +
    +
    +
    + + + screens/buy/goldens/macos/buy_currency_load_failed.png +
    +
    + Fehler beim Laden der Währungsliste, Auswahl gesperrt +
    +
    + Die Währungsliste konnte nicht geladen werden: eine rote Snackbar meldet den Fehler und die Währungsauswahl ist deaktiviert. Dieser Zustand tritt auf, wenn der Abruf der kaufbaren Währungen fehlschlägt. +
    +
    +
    +
    + +
    + +
    + +
    +
    +

    37Kauf — Freigabe-Hürden vor dem Angebot

    +
    test/goldens/screens/buy/
    +
    +
    + 3 Screens + +
    +
    +
    +

    + Bevor ein verbindliches Angebot erscheint, kann die App den Kauf blockieren, weil noch etwas fehlt oder ein externer Dienst ausfällt. Je nach Grund zeigt sie einen Info-Hinweis, eine passende Aktions-Schaltfläche oder beides. +

    + +
    +
    +
    + + + screens/buy/goldens/macos/buy_primary_email_required.png +
    +
    + Kauf verlangt zuerst primäre E-Mail-Adresse +
    +
    + Der Kauf ist gesperrt, weil dem Konto eine primäre E-Mail-Adresse fehlt. Es wird kein Info-Block angezeigt, sondern nur die Schaltfläche Weiter, die zur E-Mail-Erfassung führt; danach wird das Angebot erneut geprüft. +
    +
    +
    +
    + + + screens/buy/goldens/macos/buy_bitbox_disconnected.png +
    +
    + Hinweis BitBox nicht verbunden mit Verbinden-Button +
    +
    + Die BitBox ist nicht verbunden: ein Info-Block (BitBox ist nicht verbunden samt Erklärung) und darunter die Schaltfläche BitBox erneut verbinden. Der Nutzer sieht das, wenn die Verbindung zur Hardware-Wallet während des Kaufs unterbrochen ist. +
    +
    +
    +
    + + + screens/buy/goldens/macos/buy_price_source_unavailable.png +
    +
    + Kursanbieter Aktionariat liefert keine Kurse +
    +
    + Der externe Kursanbieter Aktionariat liefert aktuell keine Kurse: ein Info-Block (Problem beim Kursanbieter (Aktionariat) samt Erklärung) weist darauf hin, dass das Problem beim Anbieter liegt. Es gibt hier keine Aktions-Schaltfläche, sobald Aktionariat wieder Kurse liefert, funktioniert alles automatisch. +
    +
    +
    +
    + +
    + +
    + +
    +
    +

    38Kauf — Verbindliche Bestätigung

    +
    test/goldens/screens/buy/
    +
    +
    + 4 Screens + +
    +
    +
    +

    + Sobald ein gültiges Angebot vorliegt, erscheint unten die Schaltfläche Jetzt verbindlich kaufen. Diese Zustände zeigen den laufenden Bestätigungsvorgang und die drei möglichen Fehlermeldungen, die als Snackbar erscheinen. +

    + +
    +
    +
    + + + screens/buy/goldens/macos/buy_confirm_loading.png +
    +
    + Kauf-Schaltfläche mit Ladespinner +
    +
    + Die Bestätigung läuft gerade: die Schaltfläche Jetzt verbindlich kaufen zeigt einen Ladespinner. Der Nutzer sieht das unmittelbar nach dem Tippen, während die App den verbindlichen Kauf beim Server bestätigt. +
    +
    +
    +
    + + + screens/buy/goldens/macos/buy_confirm_failed_aktionariat.png +
    +
    + Snackbar-Fehler, Blockchain-Adresse evtl. unbestätigt +
    +
    + Die Bestätigung ist fehlgeschlagen mit einer Snackbar, die auf Aktionariat verweist: möglicherweise fehlt noch die Bestätigung der Blockchain-Adresse im E-Mail-Postfach. Die Kauf-Schaltfläche darunter ist wieder bedienbar. +
    +
    +
    +
    + + + screens/buy/goldens/macos/buy_confirm_failed_amount_too_low.png +
    +
    + Snackbar-Fehler, Betrag unter Mindestbetrag +
    +
    + Die Bestätigung ist fehlgeschlagen, weil der Betrag unter dem Mindestbetrag für den Kauf liegt. Die Snackbar bittet, den Betrag zu erhöhen und es erneut zu versuchen. +
    +
    +
    +
    + + + screens/buy/goldens/macos/buy_confirm_failed_unknown.png +
    +
    + Snackbar-Fehler, allgemeines technisches Problem +
    +
    + Die Bestätigung ist mit einem allgemeinen technischen Fehler fehlgeschlagen: die Snackbar bittet, es später erneut zu versuchen und bei anhaltendem Fehler den Support zu kontaktieren. Dieser Fall deckt unbekannte Fehlerursachen ab. +
    +
    +
    +
    + +
    + +
    + +
    +
    +

    39Zahlungsdetails nach dem Kauf

    +
    test/goldens/screens/buy/
    +
    +
    + 4 Screens + +
    +
    +
    +

    + Nach der verbindlichen Bestätigung öffnet sich die Seite Zahlungsdetails mit der Banküberweisungs-Anweisung, die zusätzlich per E-Mail zugestellt wird. Je nachdem, ob ein QR-Code und ein Verwendungszweck vorliegen, ändert sich der Aufbau. +

    + +
    +
    +
    + + + screens/buy/goldens/macos/buy_payment_details_qr_details_tab.png +
    +
    + Zahlungsdetails, Reiter Details aktiv, QR verfügbar +
    +
    + Die Zahlungsdetails bei vorhandenem QR-Code: die Umschaltleiste Details / QR-Code ist sichtbar und der Reiter Details ist aktiv. Darunter steht die Tabelle mit Betrag, Verwendungszweck, IBAN, BIC und Empfängerangaben zum Kopieren. +
    +
    +
    +
    + + + screens/buy/goldens/macos/buy_payment_details_qr_code_tab.png +
    +
    + Zahlungsdetails, QR-Code-Reiter mit generiertem QR +
    +
    + Auf der Zahlungsdetails-Seite ist der Reiter QR-Code gewählt: die App zeigt einen generierten QR-Code (aus den Swiss-QR-Zahlungsdaten). Damit kann der Nutzer die Überweisung im E-Banking bequem einscannen. +
    +
    +
    +
    + + + screens/buy/goldens/macos/buy_payment_details_qr_code_tab_svg.png +
    +
    + Zahlungsdetails, QR-Code-Reiter mit SVG-Grafik +
    +
    + Ebenfalls der Reiter QR-Code, hier jedoch die Variante, bei der der Server den QR-Code als fertige SVG-Grafik liefert und die App diese direkt anzeigt. Inhaltlich derselbe Zweck wie der generierte QR-Code. +
    +
    +
    +
    + + + screens/buy/goldens/macos/buy_payment_details_no_purpose.png +
    +
    + Zahlungsdetails ohne Verwendungszweck und ohne QR +
    +
    + Die Zahlungsdetails ohne Verwendungszweck: da kein Verwendungszweck vorliegt, wird die entsprechende Zeile in der Tabelle weggelassen. Ohne QR-Code fehlt zudem die Umschaltleiste, es ist nur die Detail-Tabelle sichtbar. +
    +
    +
    +
    + +
    + +
    + +
    +
    +

    40REALU verkaufen — Eingabemaske

    +
    test/goldens/screens/sell/
    +
    +
    + 3 Screens + +
    +
    +
    +

    + Die Hauptansicht des Verkaufs mit den Feldern Sie verkaufen, Sie erhalten und Bankkonto. Der Verkaufsbutton unten wird erst aktiv, wenn Betrag und Bankkonto vorhanden sind. +

    + +
    +
    +
    + + + screens/sell/goldens/macos/sell_no_account_zero_balance.png +
    +
    + Verkaufsmaske ohne Guthaben und ohne Bankkonto +
    +
    + Die Startansicht der Verkaufsmaske bei einem Guthaben von 0 REALU. Das Feld Sie verkaufen ist leer und zeigt keinen MAX-Button, das Bankkonto steht auf Bitte auswählen... und der Button REALU verkaufen ist deaktiviert. +
    +
    +
    +
    + + + screens/sell/goldens/macos/sell_with_balance.png +
    +
    + Verkaufsmaske mit Guthaben, MAX-Button sichtbar +
    +
    + Dieselbe Maske, sobald ein Guthaben vorhanden ist: im Feld Sie verkaufen erscheint zusätzlich der MAX-Button für den Gesamtbestand. Solange kein Bankkonto gewählt ist, bleibt der Verkaufsbutton deaktiviert. +
    +
    +
    +
    + + + screens/sell/goldens/macos/sell_bank_account_selected.png +
    +
    + Verkaufsmaske vollständig ausgefüllt, Button aktiv +
    +
    + Der vollständig ausgefüllte Zustand mit Betrag 100 REALU, Gegenwert 100 CHF und einem ausgewählten Bankkonto (IBAN im Feld Bankkonto). Erst jetzt wird der blaue Button 100 REALU verkaufen aktiv. +
    +
    +
    +
    + +
    + +
    + +
    +
    +

    41REALU verkaufen — Bankkonto verwalten

    +
    test/goldens/screens/sell/
    +
    +
    + 2 Screens + +
    +
    +
    +

    + Auszahlungskonten werden über eine eigene Auswahlseite und ein Erfassungsformular verwaltet. Ohne hinterlegtes Konto kann kein Verkauf ausgelöst werden. +

    + +
    +
    +
    + + + screens/sell/goldens/macos/sell_bank_account_selection_page_default.png +
    +
    + Bankkonto-Auswahlseite mit leerer Liste +
    +
    + Die Auswahlseite für Auszahlungskonten mit noch leerer Liste. Sichtbar ist nur die Aktion Bankkonto hinzufügen; über sie öffnet der Nutzer das Formular zum Erfassen eines neuen Kontos. +
    +
    +
    +
    + + + screens/sell/goldens/macos/sell_add_bank_account_sheet.png +
    +
    + Formular zum Hinzufügen eines Bankkontos +
    +
    + Das Bottom-Sheet Zahlungskonto hinzufügen im leeren Ausgangszustand mit dem Pflichtfeld IBAN und dem optionalen Feld Bezeichnung (optional). Mit Weiter wird die IBAN geprüft und das Konto gespeichert. +
    +
    +
    +
    + +
    + +
    + +
    +
    +

    42REALU verkaufen — Bestätigen und Abschluss

    +
    test/goldens/screens/sell/
    +
    +
    + 3 Screens + +
    +
    +
    +

    + Vor der Auszahlung fasst ein Bottom-Sheet den Verkauf zusammen und wartet auf die Bestätigung; nach erfolgreicher Übermittlung folgt die Erfolgsmeldung. +

    + +
    +
    +
    + + + screens/sell/goldens/macos/sell_confirm_sheet.png +
    +
    + Verkauf-Bestätigungssheet mit Übersicht +
    +
    + Das Bestätigungs-Sheet Verkauf prüfen und bestätigen mit der Zusammenfassung: Menge REALU 100, Betrag in CHF 100.0 und die Empfänger-IBAN. Der Button Bestätigen ist aktiv und löst den Verkauf aus. +
    +
    +
    +
    + + + screens/sell/goldens/macos/sell_confirm_sheet_loading.png +
    +
    + Bestätigungssheet, Button im Ladezustand +
    +
    + Derselbe Bestätigungsdialog unmittelbar nach dem Tippen auf Bestätigen: der Button zeigt einen Ladeindikator und ist ausgegraut, während der Verkauf an die Zahlungsschnittstelle übermittelt wird. +
    +
    +
    +
    + + + screens/sell/goldens/macos/sell_executed_sheet.png +
    +
    + Erfolgssheet nach abgeschlossenem Verkauf +
    +
    + Die Erfolgsmeldung nach einem abgeschlossenen Verkauf: ein blaues Häkchen, der Titel Verkauf erfolgreich und der Hinweis, dass der Betrag auf das angegebene Bankkonto ausgezahlt wird. Mit Schliessen kehrt der Nutzer zurück. +
    +
    +
    +
    + +
    + +
    + +
    +
    +

    43REALU verkaufen (BitBox) — Schritt 1: Gasgebühren

    +
    test/goldens/screens/sell_bitbox/
    +
    +
    + 3 Screens + +
    +
    +
    +

    + Der Verkauf von REALU mit der BitBox-Hardware-Wallet läuft als Assistent über drei Schritte, die der Fortschrittsbalken oben anzeigt. Schritt 1 stellt sicher, dass die Wallet genug ETH für die Transaktionsgebühren hat. +

    + +
    +
    +
    + + + screens/sell_bitbox/goldens/macos/sell_bitbox_page_default.png +
    +
    + Schritt 1, Wallet-Guthaben wird geprüft +
    +
    + Der Startzustand direkt nach dem Öffnen des Verkaufs-Assistenten: Der Fortschrittsbalken markiert Schritt 1 von 3, darunter drehen sich ein Ladeindikator und der Text Wallet-Guthaben wird geprüft. Die App prüft hier, ob genug ETH für die Gasgebühren vorhanden ist. +
    +
    +
    +
    + + + screens/sell_bitbox/goldens/macos/sell_bitbox_waiting_for_eth.png +
    +
    + Gasgebühren werden angefordert, Weiter deaktiviert +
    +
    + Reicht das ETH-Guthaben nicht, zeigt Schritt 1 ein Sanduhr-Symbol mit der Überschrift Gasgebühren werden angefordert und dem Hinweis, dass ein kleiner ETH-Betrag ans Wallet gesendet wird und dies einige Minuten dauern kann. Der Weiter-Button ist deaktiviert (ausgegraut), solange auf die Gutschrift gewartet wird. +
    +
    +
    +
    + + + screens/sell_bitbox/goldens/macos/sell_bitbox_eth_ready.png +
    +
    + Wallet bereit, Weiter aktiv +
    +
    + Sobald genug ETH vorhanden ist, wechselt Schritt 1 auf ein Häkchen-Symbol mit der Überschrift Wallet bereit und dem Text, dass das Wallet genug ETH zum Fortfahren hat. Der Weiter-Button ist nun aktiv und führt zu Schritt 2. +
    +
    +
    +
    + +
    + +
    + +
    +
    +

    44Schritt 2: Tausch REALU → ZCHF

    +
    test/goldens/screens/sell_bitbox/
    +
    +
    + 3 Screens + +
    +
    +
    +

    + Im zweiten Schritt wird REALU über den BrokerBot in ZCHF getauscht. Der Nutzer prüft die Konditionen und bestätigt den Tausch anschliessend auf der BitBox. +

    + +
    +
    +
    + + + screens/sell_bitbox/goldens/macos/sell_bitbox_preparing_swap.png +
    +
    + Schritt 2, Tausch wird vorbereitet +
    +
    + Zu Beginn von Schritt 2 markiert der Fortschrittsbalken Segment 2 von 3 und es dreht sich nur ein Ladeindikator ohne weiteren Text. Im Hintergrund werden die unsignierten Tausch-Transaktionen vorbereitet. +
    +
    +
    +
    + + + screens/sell_bitbox/goldens/macos/sell_bitbox_awaiting_swap_confirm.png +
    +
    + Tausch-Übersicht REALU zu ZCHF +
    +
    + Die Prüfkarte für den Tausch mit dem Titel REALU → ZCHF tauschen: Sie zeigt Sie senden 100 REALU und Sie erhalten ca. 100.00 ZCHF, den Hinweis zur Bestätigung über den BrokerBot sowie einen Bestätigen-Button. Nach dem Tippen muss der Tausch auf der BitBox freigegeben werden. +
    +
    +
    +
    + + + screens/sell_bitbox/goldens/macos/sell_bitbox_swapping.png +
    +
    + Tausch on-chain, auf BitBox bestätigen +
    +
    + Während der Tausch signiert und übertragen wird, zeigt Schritt 2 einen Ladeindikator mit dem Text Tausch on-chain. Bestätigen Sie auf der Bitbox. Der Nutzer muss die Transaktion jetzt auf dem Gerät freigeben. +
    +
    +
    +
    + +
    + +
    + +
    +
    +

    45Schritt 3: ZCHF an DFX senden

    +
    test/goldens/screens/sell_bitbox/
    +
    +
    + 3 Screens + +
    +
    +
    +

    + Im dritten Schritt werden die getauschten ZCHF an die DFX-Einzahlungsadresse überwiesen. Der Nutzer bestätigt die Einzahlung, danach läuft die Übertragung on-chain. +

    + +
    +
    +
    + + + screens/sell_bitbox/goldens/macos/sell_bitbox_awaiting_deposit_confirm.png +
    +
    + Einzahlungs-Übersicht ZCHF an DFX +
    +
    + Die Prüfkarte für Schritt 3 mit dem Titel ZCHF an DFX senden: Sie zeigt Sie senden 100.00 ZCHF und als DFX-Einzahlung die gekürzte Zieladresse (z. B. 0xDEP0…1234), dazu den Hinweis zur BitBox-Bestätigung und einen Bestätigen-Button. +
    +
    +
    +
    + + + screens/sell_bitbox/goldens/macos/sell_bitbox_depositing.png +
    +
    + ZCHF wird gesendet, auf BitBox bestätigen +
    +
    + Nach dem Bestätigen der Einzahlung erscheint in Schritt 3 ein Ladeindikator mit dem Text ZCHF wird gesendet. Bestätigen Sie auf der Bitbox. Die Einzahlungs-Transaktion muss jetzt auf dem Gerät freigegeben werden. +
    +
    +
    +
    + + + screens/sell_bitbox/goldens/macos/sell_bitbox_retrying_deposit.png +
    +
    + ZCHF-Einzahlung wird abgeschlossen +
    +
    + Dieselbe Spinner-Ansicht wie beim Einzahlen, jedoch mit dem Text ZCHF-Einzahlung wird abgeschlossen. Sie erscheint bei einem reinen Server-Wiederholungsversuch auf bereits signierten Daten, der ohne erneute BitBox-Bestätigung abläuft. +
    +
    +
    +
    + +
    + +
    + +
    +
    +

    46Schritt 3: Einzahlung wiederholen (Fehlerfälle)

    +
    test/goldens/screens/sell_bitbox/
    +
    +
    + 2 Screens + +
    +
    +
    +

    + Schlägt die ZCHF-Einzahlung fehl, zeigt der Assistent statt des Spinners eine Fehlermeldung mit Wiederholen-Schaltfläche. Je nachdem, ob die Einzahlung schon on-chain gelandet ist, unterscheiden sich Titel und Text. +

    + +
    +
    +
    + + + screens/sell_bitbox/goldens/macos/sell_bitbox_deposit_retry.png +
    +
    + Einzahlung fehlgeschlagen, Wiederholen +
    +
    + Fehlerzustand, wenn die Einzahlung gar nicht auf die Blockchain gelangt ist: ein rotes Fehler-Symbol mit der Überschrift Einzahlung fehlgeschlagen und dem Hinweis, dass der Tausch abgeschlossen ist, die ZCHF-Einzahlung aber nicht gesendet werden konnte und die Mittel sicher sind. Über den Wiederholen-Button wird der Versuch erneut gestartet. +
    +
    +
    +
    + + + screens/sell_bitbox/goldens/macos/sell_bitbox_confirm_retry.png +
    +
    + Bestätigung ausstehend, Wiederholen +
    +
    + Fehler-Variante, wenn die Einzahlung bereits on-chain ist und nur die Bestätigung fehlt: rotes Fehler-Symbol mit der Überschrift Bestätigung ausstehend und dem Hinweis, dass die ZCHF-Einzahlung gesendet, aber noch nicht bei DFX bestätigt wurde. Der Wiederholen-Button setzt am Bestätigungsschritt wieder an. +
    +
    +
    +
    + +
    + +
    + +
    +
    +

    47BitBox verbinden — Start und Verbindungsaufbau

    +
    test/goldens/screens/hardware_connect_bitbox/
    +
    +
    + 3 Screens + +
    +
    +
    +

    + Der Einstieg in die BitBox-Kopplung: Die App zeigt zunächst die Aufforderung, das Gerät zu verbinden, und wartet im Hintergrund darauf, dass eine BitBox gefunden wird. Der Startzustand unterscheidet sich zwischen Android und iOS nur im Hinweistext; danach folgt der laufende Verbindungsaufbau. +

    + +
    +
    +
    + + + screens/hardware_connect_bitbox/goldens/macos/connect_bitbox_page_default.png +
    +
    + BitBox verbinden — Startzustand (Android) +
    +
    + Der Startzustand des BitBox-Kopplungs-Sheets, solange noch kein Gerät verbunden ist. Titel BitBox verbinden mit der Aufforderung, die BitBox mit dem Smartphone zu verbinden, und nur einem Abbrechen-Button; die App wartet im Hintergrund, bis eine BitBox gefunden wird. Dies ist die Android-Textvariante ohne Bluetooth-Hinweis. +
    +
    +
    +
    + + + screens/hardware_connect_bitbox/goldens/macos/connect_bitbox_page_default_ios.png +
    +
    + BitBox verbinden — Startzustand (iOS) +
    +
    + Derselbe nicht-verbundene Startzustand, jedoch mit der iOS-Textvariante: Der Hinweis fordert zusätzlich dazu auf, Bluetooth zu aktivieren. Diese Fassung erscheint auf iPhones, wo die BitBox per Bluetooth statt per USB angebunden wird. +
    +
    +
    +
    + + + screens/hardware_connect_bitbox/goldens/macos/connect_bitbox_page_connecting.png +
    +
    + BitBox — Verbindungsaufbau läuft +
    +
    + Der Zustand Verbindungsaufbau, nachdem eine BitBox gefunden wurde. Der Text kündigt an, dass in Kürze ein Kopplungscode auf dem Gerät erscheint; darunter dreht ein Ladeindikator. Es gibt keine Buttons, da der Nutzer nur warten muss. +
    +
    +
    +
    + +
    + +
    + +
    +
    +

    48BitBox verbinden — Kopplung prüfen

    +
    test/goldens/screens/hardware_connect_bitbox/
    +
    +
    + 2 Screens + +
    +
    +
    +

    + Sobald ein Gerät gefunden ist, gleicht der Nutzer den Kopplungscode zwischen App und BitBox ab und bestätigt die Verbindung. Anschliessend stellt die App die Kopplung fertig. +

    + +
    +
    +
    + + + screens/hardware_connect_bitbox/goldens/macos/connect_bitbox_page_check_hash.png +
    +
    + Kopplungscode auf BitBox prüfen +
    +
    + Der Kopplungscode-Abgleich: In einer blau hinterlegten Pille steht der Kanal-Hash (hier 01:23:45:67:89:AB), den der Nutzer mit dem auf der BitBox angezeigten Code vergleicht. Über Bestätigen wird die Kopplung bestätigt, Abbrechen bricht sie ab; ein Hinweis kündigt die anschliessende Anmelde-Bestätigung an. +
    +
    +
    +
    + + + screens/hardware_connect_bitbox/goldens/macos/connect_bitbox_page_pairing.png +
    +
    + BitBox — Kopplung wird abgeschlossen +
    +
    + Der Zustand Kopplung läuft, nachdem der Code bestätigt wurde. Es erscheint dieselbe Aufforderung zum Code-Abgleich mit einem drehenden Ladeindikator und ohne Buttons, während die App die Verbindung mit dem Gerät herstellt. +
    +
    +
    +
    + +
    + +
    + +
    +
    +

    49BitBox verbinden — Anmeldesignatur und Abschluss

    +
    test/goldens/screens/hardware_connect_bitbox/
    +
    +
    + 2 Screens + +
    +
    +
    +

    + Zum Abschluss der Einrichtung erfasst die App einmalig eine Anmeldesignatur direkt auf dem Gerät und meldet danach die erfolgreiche Verbindung. +

    + +
    +
    +
    + + + screens/hardware_connect_bitbox/goldens/macos/connect_bitbox_page_capturing_signature.png +
    +
    + Anmeldung auf BitBox bestätigen +
    +
    + Der Zustand Anmeldesignatur erfassen mit dem Titel Anmeldung bestätigen. Der Nutzer wird gebeten, die Anmeldeanfrage direkt auf der BitBox zu bestätigen; diese Signatur wird einmalig erfasst, damit künftige Käufe das Gerät nicht erneut benötigen. Ein Ladeindikator zeigt das Warten, es gibt keine Buttons. +
    +
    +
    +
    + + + screens/hardware_connect_bitbox/goldens/macos/connect_bitbox_page_connected.png +
    +
    + BitBox verbunden — Abschluss bestätigen +
    +
    + Der Verbunden-Zustand als Abschluss der Kopplung. Titel Verbunden, die Aufforderung, den letzten Anweisungen auf der BitBox zu folgen, und ein einzelner Bestätigen-Button ohne Abbrechen-Option, der die Einrichtung abschliesst. +
    +
    +
    +
    + +
    + +
    + +
    +
    +

    50BitBox verbinden — Fehler- und Sonderfälle

    +
    test/goldens/screens/hardware_connect_bitbox/
    +
    +
    + 3 Screens + +
    +
    +
    +

    + Zustände, in denen die Kopplung nicht glatt durchläuft: ein Gerät ohne eingerichtete Wallet, eine fehlgeschlagene Anmeldesignatur und die allgemeine Fehler-Snackbar beim Rückfall in den nicht-verbundenen Zustand. +

    + +
    +
    +
    + + + screens/hardware_connect_bitbox/goldens/macos/connect_bitbox_page_not_initialized.png +
    +
    + BitBox noch nicht eingerichtet +
    +
    + Der Sonderfall BitBox noch nicht eingerichtet: Das Gerät ist gekoppelt, hat aber noch keine Wallet. Der Text bittet, über die BitBox-App eine Wallet einzurichten oder wiederherzustellen. Wiederholen prüft den Gerätestatus erneut, Abbrechen verlässt den Vorgang. +
    +
    +
    +
    + + + screens/hardware_connect_bitbox/goldens/macos/connect_bitbox_page_signature_failed.png +
    +
    + Anmeldung nicht abgeschlossen +
    +
    + Der Fehlerzustand Anmeldung nicht abgeschlossen, wenn die Anmeldesignatur nicht erfasst werden konnte. Der Nutzer kann über Wiederholen einen neuen Versuch starten oder mit Trotzdem fortfahren ohne Signatur weitermachen — dann wird die BitBox beim ersten Kauf möglicherweise erneut gebraucht. +
    +
    +
    +
    + + + screens/hardware_connect_bitbox/goldens/macos/connect_bitbox_page_failed_snackbar.png +
    +
    + Snackbar: Verbindungsfehler +
    +
    + Die Fehler-Snackbar am unteren Rand mit dem Hinweis, dass ein Fehler aufgetreten ist und man es erneut versuchen soll. Sie erscheint bei jedem Rückfall in den nicht-verbundenen Zustand, etwa nach abgebrochener Kopplung; dahinter ist wieder die Startansicht mit Abbrechen-Button sichtbar. +
    +
    +
    +
    + +
    + +
    + +
    +
    +

    51BitBox — Adresswiederherstellung

    +
    test/goldens/screens/hardware_connect_bitbox/
    +
    +
    + 1 Screen + +
    +
    +
    +

    + Ein Sonderablauf als Vollbildseite: Wenn eine bereits gespeicherte BitBox-Wallet ihre Adresse verloren hat, wird sie durch erneutes Koppeln repariert. +

    + +
    +
    +
    + + + screens/hardware_connect_bitbox/goldens/macos/bitbox_address_recovery_page_default.png +
    +
    + BitBox-Adresswiederherstellung — Startzustand +
    +
    + Die Adresswiederherstellungs-Seite, die als Vollbild erscheint, wenn eine bereits gespeicherte BitBox-Wallet ihre Adresse verloren hat. Optisch identisch zum normalen Startzustand (BitBox verbinden, nur Abbrechen), aber erneutes Koppeln füllt die fehlende Adresse in die bestehende Wallet nach, statt eine zweite anzulegen. +
    +
    +
    +
    + +
    + +
    + +
    +
    +

    52KYC — E-Mail und 2-Faktor-Authentifizierung

    +
    test/goldens/screens/kyc/
    +
    +
    + 13 Screens + +
    +
    +
    +

    + Der Einstieg in die Verifizierung dreht sich um die E-Mail-Adresse und die 2-Faktor-Authentifizierung. Diese Seiten decken das Registrieren und Bestätigen der E-Mail, die Verifizierung wiederkehrender Nutzer sowie die Code-Eingabe der 2FA ab — jeweils inklusive Lade- und Fehlerzustände. +

    + +
    +
    +
    + + + screens/kyc/goldens/macos/kyc_email_page_default.png +
    +
    + E-Mail registrieren — leeres Eingabefeld +
    +
    + Der Einstieg in die Verifizierung: die Seite E-Mail registrieren mit leerem Eingabefeld. Der Nutzer trägt hier die E-Mail-Adresse ein, mit der das RealUnit-Konto verknüpft werden soll. +
    +
    +
    +
    + + + screens/kyc/goldens/macos/kyc_email_page_error_snackbar_does_not_match.png +
    +
    + E-Mail-Fehler: Adresse stimmt nicht überein +
    +
    + Rote Fehler-SnackBar, wenn die eingegebene Adresse nicht mit der bereits verifizierten E-Mail übereinstimmt. Der Nutzer wird aufgefordert, die ursprüngliche Registrierungs-E-Mail zu verwenden. +
    +
    +
    +
    + + + screens/kyc/goldens/macos/kyc_email_page_error_snackbar_unknown.png +
    +
    + E-Mail-Fehler: unbekannter Fehler +
    +
    + Rote Fehler-SnackBar bei einem unbekannten Fehler während der E-Mail-Übermittlung. Angezeigt wird die generische Meldung, dass etwas schiefgelaufen ist und der Nutzer es erneut versuchen soll. +
    +
    +
    +
    + + + screens/kyc/goldens/macos/kyc_confirm_email_page_default.png +
    +
    + E-Mail-Bestätigung — Ausgangszustand +
    +
    + Die Seite E-Mail-Bestätigung im Ausgangszustand. Sie fordert den Nutzer auf, die zugesandte E-Mail zu öffnen, auf den Bestätigungslink zu klicken und anschliessend zurückzukehren. +
    +
    +
    +
    + + + screens/kyc/goldens/macos/kyc_email_verification_page_default.png +
    +
    + E-Mail-Verifizierung — Willkommen zurück +
    +
    + Die Seite Willkommen zurück für wiederkehrende Nutzer, deren Konto bereits existiert. Sie bittet darum, die soeben zugesandte E-Mail über den Link zu bestätigen, um mit dem bestehenden Konto fortzufahren. +
    +
    +
    +
    + + + screens/kyc/goldens/macos/kyc_email_verification_page_loading.png +
    +
    + E-Mail-Verifizierung lädt — Software-Wallet +
    +
    + Die Verifizierung läuft: der Bestätigungs-Button zeigt einen Ladespinner. Bei einer Software-Wallet wird kein zusätzlicher BitBox-Hinweis eingeblendet. +
    +
    +
    +
    + + + screens/kyc/goldens/macos/kyc_email_verification_page_loading_bitbox.png +
    +
    + E-Mail-Verifizierung lädt — BitBox-Hinweis +
    +
    + Wie der Ladezustand, jedoch mit BitBox-Hardware-Wallet: zusätzlich erscheint der Hinweis, die Signatur auf der BitBox zu bestätigen, wobei sich die Nachricht über mehrere Seiten erstreckt. +
    +
    +
    +
    + + + screens/kyc/goldens/macos/kyc_email_verification_page_error_snackbar.png +
    +
    + E-Mail-Verifizierung fehlgeschlagen +
    +
    + Rote Fehler-SnackBar, wenn die E-Mail-Verifizierung fehlschlägt — etwa weil der Bestätigungslink noch nicht angeklickt wurde. Der Button bleibt im Ruhezustand ohne Spinner. +
    +
    +
    +
    + + + screens/kyc/goldens/macos/kyc_2fa_page_default.png +
    +
    + 2FA — leeres Code-Feld +
    +
    + Die 2-Faktor-Authentifizierung im Ausgangszustand mit leerem Code-Feld. Ein per E-Mail versendeter Code wird hier eingegeben, um mit der Identitätsprüfung fortzufahren. +
    +
    +
    +
    + + + screens/kyc/goldens/macos/kyc_2fa_page_verify_loading.png +
    +
    + 2FA — Code wird geprüft (Spinner) +
    +
    + Der eingegebene Code wird geprüft: der Weiter-Button zeigt seinen Ladespinner. Übergangszustand direkt nach dem Absenden des Codes. +
    +
    +
    +
    + + + screens/kyc/goldens/macos/kyc_2fa_page_resend_loading.png +
    +
    + 2FA — Code wird erneut gesendet +
    +
    + Der Nutzer hat Code erneut senden angetippt: der Resend-Button ist deaktiviert und zeigt die Sende-Beschriftung. Der Weiter-Button bleibt unverändert. +
    +
    +
    +
    + + + screens/kyc/goldens/macos/kyc_2fa_page_verify_failure.png +
    +
    + 2FA — falscher Code +
    +
    + Rote SnackBar mit der Meldung Der Code ist falsch, wenn ein ungültiger 2FA-Code eingegeben wurde. Der Nutzer kann den Code korrigieren und erneut absenden. +
    +
    +
    +
    + + + screens/kyc/goldens/macos/kyc_2fa_page_request_code_failure.png +
    +
    + 2FA — Code-Versand fehlgeschlagen +
    +
    + Rote SnackBar, wenn das Versenden des Codes per E-Mail fehlschlägt (etwa bei einer Ratenbegrenzung). Die Meldung weist auf ein Problem beim Senden der Mail hin. +
    +
    +
    +
    + +
    + +
    + +
    +
    +

    53KYC — Registrierung

    +
    test/goldens/screens/kyc/
    +
    +
    + 12 Screens + +
    +
    +
    +

    + Die Registrierung erfasst persönliche Daten, Adresse und Steueransässigkeit in einem dreistufigen Formular. Diese Einträge zeigen die einzelnen Schritte, vorausgefüllte Daten, geöffnete Dropdowns, Validierungsfehler sowie die Zustände beim Absenden inklusive BitBox-Signatur. +

    + +
    +
    +
    + + + screens/kyc/goldens/macos/kyc_registration_page_default.png +
    +
    + Registrierung — leerer persönlicher Schritt +
    +
    + Der Einstieg in die Registrierung auf dem ersten Schritt Persönliche Daten, mit leerem Formular. Hier werden Kontotyp, Vor- und Nachname, Geburtsdatum, Telefonnummer und Staatsangehörigkeit erfasst. +
    +
    +
    +
    + + + screens/kyc/goldens/macos/kyc_registration_page_prefilled.png +
    +
    + Registrierung — vorausgefülltes Formular +
    +
    + Derselbe persönliche Schritt, jedoch mit vorausgefüllten Daten aus einem bestehenden Konto (Name, Telefon, Geburtsdatum, Staatsangehörigkeit). Der Nutzer muss die übernommenen Angaben nur noch bestätigen. +
    +
    +
    +
    + + + screens/kyc/goldens/macos/kyc_registration_personal_step_account_type_open.png +
    +
    + Registrierung — Kontotyp-Dropdown offen +
    +
    + Das geöffnete Kontotyp-Auswahlmenü im persönlichen Schritt. Das Overlay zeigt die auswählbaren Kontotypen. +
    +
    +
    +
    + + + screens/kyc/goldens/macos/kyc_registration_personal_step_phone_prefix_open.png +
    +
    + Registrierung — Vorwahl-Dropdown offen +
    +
    + Das geöffnete Auswahlmenü für die Telefon-Ländervorwahl (z. B. +41 / +49). Der Nutzer wählt hier die Vorwahl vor der Eingabe der Telefonnummer. +
    +
    +
    +
    + + + screens/kyc/goldens/macos/kyc_registration_personal_step_validation_error.png +
    +
    + Registrierung — Validierungsfehler persönlich +
    +
    + Nach Antippen von Weiter bei leerem Formular erhalten die Pflichtfelder (Vorname, Nachname, Geburtsdatum, Staatsangehörigkeit) einen roten Fehlerrahmen. Nur das Telefonfeld zeigt zusätzlich einen Fehlertext. +
    +
    +
    +
    + + + screens/kyc/goldens/macos/kyc_registration_page_address_step.png +
    +
    + Registrierung — Adressschritt (Residenz) +
    +
    + Der zweite Registrierungsschritt Residenz mit den Feldern für Strasse, Hausnummer, PLZ, Ort und Land. Der Seiten-Pager steht auf Seite 2 von 3. +
    +
    +
    +
    + + + screens/kyc/goldens/macos/kyc_registration_address_step_validation_error.png +
    +
    + Registrierung — Validierungsfehler Adresse +
    +
    + Der Adressschritt nach Weiter mit leeren Feldern: alle vier Textfelder sowie das Länder-Dropdown wechseln auf einen roten Fehlerrahmen. Die Fehlertexte sind unterdrückt, sichtbar ist nur der Rahmen. +
    +
    +
    +
    + + + screens/kyc/goldens/macos/kyc_registration_page_tax_step.png +
    +
    + Registrierung — Steueransässigkeit +
    +
    + Der dritte Registrierungsschritt Steueransässigkeit zur Angabe des Landes des steuerlichen Wohnsitzes. Der Pager steht auf Seite 3 von 3. +
    +
    +
    +
    + + + screens/kyc/goldens/macos/kyc_registration_page_submit_loading.png +
    +
    + Registrierung — Sende-Overlay mit Spinner +
    +
    + Beim Absenden der Registrierung legt sich ein weisses, vollflächiges Overlay mit Spinner über den Pager. Der Nutzer wartet, bis die Übermittlung abgeschlossen ist. +
    +
    +
    +
    + + + screens/kyc/goldens/macos/kyc_registration_page_submit_failure_snackbar.png +
    +
    + Registrierung — Fehler beim Absenden +
    +
    + Rote SnackBar Registrierung fehlgeschlagen, wenn die Übermittlung abgelehnt wird (hier durch eine abgebrochene Signatur ausgelöst). Die Formulareingaben bleiben erhalten. +
    +
    +
    +
    + + + screens/kyc/goldens/macos/kyc_registration_page_forwarding_failed_snackbar.png +
    +
    + Registrierung — Weiterleitung verzögert +
    +
    + SnackBar nach einer erfolgreichen Übermittlung: die Registrierung wurde angenommen, aber die Weiterleitung an die Gesellschaft ist verzögert und wird automatisch erneut versucht. +
    +
    +
    +
    + + + screens/kyc/goldens/macos/kyc_registration_page_bitbox_required.png +
    +
    + Registrierung — BitBox-Verbindungs-Sheet +
    +
    + Wenn zum Abschluss eine BitBox-Signatur nötig ist, öffnet sich das modale Verbindungs-Sheet über dem Pager. Der Nutzer verbindet hier seine BitBox-Hardware-Wallet. +
    +
    +
    +
    + +
    + +
    + +
    +
    +

    54KYC — Staatsangehörigkeit

    +
    test/goldens/screens/kyc/
    +
    +
    + 7 Screens + +
    +
    +
    +

    + Im Schritt Staatsangehörigkeit wählt der Nutzer sein Land aus einer Länderliste. Die Einträge decken den Normalzustand, das geöffnete Dropdown, das Laden bzw. den Fehler der Länderliste sowie die Übermittlungs- und Validierungszustände ab. +

    + +
    +
    +
    + + + screens/kyc/goldens/macos/kyc_nationality_page_default.png +
    +
    + Staatsangehörigkeit — Ausgangszustand +
    +
    + Die Seite Staatsangehörigkeit im Ausgangszustand: die Länderliste ist geladen, es ist noch kein Land gewählt. Der Nutzer wählt hier seine Staatsangehörigkeit aus. +
    +
    +
    +
    + + + screens/kyc/goldens/macos/kyc_nationality_page_dropdown_open.png +
    +
    + Staatsangehörigkeit — Länder-Dropdown offen +
    +
    + Das geöffnete Länder-Dropdown mit der auswählbaren Länderliste. Häufige Länder (CH/DE/IT/FR) werden nach oben sortiert. +
    +
    +
    +
    + + + screens/kyc/goldens/macos/kyc_nationality_page_country_loading.png +
    +
    + Staatsangehörigkeit — Länderliste lädt +
    +
    + Während die Länderliste geladen wird, zeigt das Auswahlfeld einen kleinen Spinner im Feld. Das Dropdown ist noch nicht auswählbar. +
    +
    +
    +
    + + + screens/kyc/goldens/macos/kyc_nationality_page_country_error.png +
    +
    + Staatsangehörigkeit — Länderliste-Fehler +
    +
    + Konnte die Länderliste nicht geladen werden, erscheint ein roter Hinweistext mit einem Erneut-versuchen-Button anstelle des Dropdowns. +
    +
    +
    +
    + + + screens/kyc/goldens/macos/kyc_nationality_page_submit_loading.png +
    +
    + Staatsangehörigkeit — wird übermittelt +
    +
    + Nach der Auswahl wird die Staatsangehörigkeit übermittelt: der Weiter-Button zeigt seinen Ladespinner. +
    +
    +
    +
    + + + screens/kyc/goldens/macos/kyc_nationality_page_submit_failure.png +
    +
    + Staatsangehörigkeit — Übermittlung fehlgeschlagen +
    +
    + Rote SnackBar, wenn das Setzen der Staatsangehörigkeit fehlschlägt. Der darunterliegende Formularkörper bleibt im Ruhezustand. +
    +
    +
    +
    + + + screens/kyc/goldens/macos/kyc_nationality_page_validation_error.png +
    +
    + Staatsangehörigkeit — kein Land gewählt +
    +
    + Weiter wurde angetippt, ohne ein Land zu wählen: das Länderfeld erhält einen roten Fehlerrahmen ohne zusätzlichen Fehlertext. +
    +
    +
    +
    + +
    + +
    + +
    +
    +

    55KYC — Finanzdaten

    +
    test/goldens/screens/kyc/
    +
    +
    + 13 Screens + +
    +
    +
    +

    + Der Finanzdaten-Schritt stellt dynamisch geladene Fragen unterschiedlichen Typs (Text, Einfach-, Mehrfachauswahl, Kontrollkästchen). Diese Einträge zeigen die Fragetypen, die Beschreibungs-Varianten, den Fortschritt sowie die Lade-, Fallback- und Fehlerzustände. +

    + +
    +
    +
    + + + screens/kyc/goldens/macos/kyc_financial_data_page_default.png +
    +
    + Finanzdaten — Laden (Spinner) +
    +
    + Der Finanzdaten-Schritt beim Einstieg: während die Fragen vom Server geladen werden, zeigt die Ansicht einen zentrierten Ladespinner. +
    +
    +
    +
    + + + screens/kyc/goldens/macos/kyc_financial_data_loading_page_default.png +
    +
    + Finanzdaten — Ladeseite +
    +
    + Die eigenständige Ladeseite Finanzdaten mit Titelleiste und zentriertem Spinner. Zwischenzustand, während die Finanzdaten-Abfrage vorbereitet wird. +
    +
    +
    +
    + + + screens/kyc/goldens/macos/kyc_financial_data_page_fallback.png +
    +
    + Finanzdaten — leerer Übergangs-Bildschirm +
    +
    + Ein bewusst leerer Bildschirm, der für die kurzen Momente vor dem Laden bzw. nach dem erfolgreichen Absenden erscheint, bevor die App zum nächsten Schritt weiterleitet. +
    +
    +
    +
    + + + screens/kyc/goldens/macos/kyc_financial_data_failure_page_default.png +
    +
    + Finanzdaten — Fehlerseite +
    +
    + Die Fehlerseite Finanzdaten mit einer Fehlermeldung, wenn der Finanzdaten-Schritt nicht geladen oder verarbeitet werden konnte. +
    +
    +
    +
    + + + screens/kyc/goldens/macos/kyc_financial_data_page_submit_failure.png +
    +
    + Finanzdaten — Übermittlung fehlgeschlagen +
    +
    + Beim fehlgeschlagenen Absenden bleibt die Fragenseite mit den Antworten erhalten und eine rote SnackBar meldet, dass die Übermittlung fehlgeschlagen ist. +
    +
    +
    +
    + + + screens/kyc/goldens/macos/kyc_financial_data_questions_page_default.png +
    +
    + Finanzdaten — Textfrage, leer +
    +
    + Eine Textfrage der Finanzdaten (z. B. jährliches Einkommen) mit leerem Antwortfeld. Da es die letzte Frage ist und noch keine Antwort vorliegt, ist der Abschliessen-Button deaktiviert. +
    +
    +
    +
    + + + screens/kyc/goldens/macos/kyc_financial_data_questions_page_no_description.png +
    +
    + Finanzdaten — Frage ohne Beschreibung +
    +
    + Eine Finanzdaten-Frage ohne Beschreibungstext: unter dem Titel folgt direkt das Eingabefeld, der sonst übliche Beschreibungsblock entfällt. +
    +
    +
    +
    + + + screens/kyc/goldens/macos/kyc_financial_data_questions_page_answered.png +
    +
    + Finanzdaten — Frage beantwortet +
    +
    + Eine Textfrage mit bereits eingetragener Antwort: das Feld zeigt den erfassten Wert und der Abschliessen-Button ist aktiviert. +
    +
    +
    +
    + + + screens/kyc/goldens/macos/kyc_financial_data_questions_page_single_choice.png +
    +
    + Finanzdaten — Einfachauswahl +
    +
    + Eine Einfachauswahl-Frage als Radiobutton-Liste (z. B. Haupteinkommensquelle), bei der noch keine Option markiert ist. +
    +
    +
    +
    + + + screens/kyc/goldens/macos/kyc_financial_data_questions_page_multiple_choice.png +
    +
    + Finanzdaten — Mehrfachauswahl +
    +
    + Eine Mehrfachauswahl-Frage als Liste von Kontrollkästchen (z. B. gehaltene Anlagearten), bei der noch keine Option angehakt ist. +
    +
    +
    +
    + + + screens/kyc/goldens/macos/kyc_financial_data_questions_page_checkbox.png +
    +
    + Finanzdaten — Bestätigungs-Checkbox +
    +
    + Eine Bestätigungsfrage als einzelnes Kontrollkästchen mit Beschreibung, mit dem der Nutzer die Richtigkeit seiner Angaben bestätigt. +
    +
    + +
    +
    + + + screens/kyc/goldens/macos/kyc_financial_data_questions_page_not_last.png +
    +
    + Finanzdaten — Frage 1 von 3 (Weiter) +
    +
    + Eine Frage, die nicht die letzte ist: der Fortschritt zeigt Frage 1 von 3 und der Button trägt die Beschriftung Weiter statt Abschliessen. +
    +
    +
    +
    + +
    + +
    + +
    +
    +

    56KYC — Identitätsprüfung und Wallet-Verknüpfung

    +
    test/goldens/screens/kyc/
    +
    +
    + 11 Screens + +
    +
    +
    +

    + Nach der Registrierung folgen die Identitätsprüfung und die Verknüpfung der Wallet mit dem Konto. Diese Einträge decken die Ident-Zustände (Start, Fehler, endgültige Ablehnung), die Wallet-Verknüpfung samt BitBox und die Sonderseite für nicht signierfähige Debug-Wallets ab. +

    + +
    +
    +
    + + + screens/kyc/goldens/macos/kyc_ident_page_default.png +
    +
    + Identitätsprüfung — Ausgangszustand +
    +
    + Die Seite Identitätsprüfung im Ausgangszustand mit Illustration und Weiter-Button. Der Nutzer startet hier die Ausweis-Identifikation. +
    +
    +
    +
    + + + screens/kyc/goldens/macos/kyc_ident_page_loading.png +
    +
    + Identitätsprüfung — startet (Spinner) +
    +
    + Der Start der Identifikation läuft: der Weiter-Button zeigt seinen Ladespinner, während der Identifikations-Dienst geöffnet wird. +
    +
    +
    +
    + + + screens/kyc/goldens/macos/kyc_ident_page_error.png +
    +
    + Identitätsprüfung — Fehler (erneut versuchen) +
    +
    + Rote SnackBar bei einem Fehler während der Identitätsprüfung (z. B. Netzwerkproblem), über dem sonst unveränderten Ruhezustand. Der Nutzer kann es erneut versuchen. +
    +
    +
    +
    + + + screens/kyc/goldens/macos/kyc_ident_page_finally_rejected.png +
    +
    + Identitätsprüfung — endgültig abgelehnt +
    +
    + Wurde die Identitätsprüfung endgültig abgelehnt, ist der Weiter-Button dauerhaft deaktiviert und eine rote SnackBar verweist auf den Support. +
    +
    + + + + + + +
    +
    + + + screens/kyc/goldens/macos/kyc_signature_unsupported_page_default.png +
    +
    + Signatur nicht verfügbar (Debug-Wallet) +
    +
    + Die Seite Signatur nicht verfügbar: im Debug-Modus (Adresse plus Signatur) ist die erforderliche EIP-712-Signatur technisch nicht möglich. Der Nutzer wird gebeten, eine Software-Wallet oder BitBox zu verwenden. +
    +
    +
    +
    + +
    + +
    + +
    +
    +

    57KYC — Status- und Ergebnisseiten

    +
    test/goldens/screens/kyc/
    +
    +
    + 7 Screens + +
    +
    +
    +

    + Über den gesamten KYC-Ablauf hinweg zeigt die App Status- und Ergebnisseiten. Diese Einträge decken die allgemeine Ladeseite, den Prüf-, Abschluss- und Fehlerstatus sowie die Sonderfälle manuelle Prüfung und Kontozusammenführung ab. +

    + +
    +
    +
    + + + screens/kyc/goldens/macos/kyc_loading_page_default.png +
    +
    + KYC — Ladeseite +
    +
    + Die allgemeine Ladeseite der Verifizierung mit zentriertem Spinner. Sie erscheint, während die App den aktuellen KYC-Status abfragt und den nächsten Schritt bestimmt. +
    +
    +
    +
    + + + screens/kyc/goldens/macos/kyc_pending_page_default.png +
    +
    + KYC — Schritt in Prüfung +
    +
    + Die Seite Daten werden geprüft: der zuletzt abgeschlossene Schritt (hier die Identitätsprüfung) befindet sich noch in Prüfung. Über den Aktualisieren-Button kann der Nutzer den Status neu abrufen. +
    +
    +
    +
    + + + screens/kyc/goldens/macos/kyc_completed_page_default.png +
    +
    + KYC — Verifikation abgeschlossen +
    +
    + Die Abschlussseite Verifikation abgeschlossen: der Nutzer hat nun genug Rechte, um die gewünschte Aktion auszuführen, und kann die Seite über Schliessen verlassen. +
    +
    +
    +
    + + + screens/kyc/goldens/macos/kyc_failure_page_default.png +
    +
    + KYC — Fehler beim Laden +
    +
    + Die allgemeine Fehlerseite Fehler beim Laden mit der konkreten Fehlermeldung. Sie rät, es später erneut zu versuchen oder den Support zu kontaktieren. +
    +
    +
    +
    + + + screens/kyc/goldens/macos/kyc_manual_review_page_default.png +
    +
    + KYC — manuelle Prüfung +
    +
    + Die Seite Registrierung wird geprüft: die Anmeldung wird vom Team manuell geprüft. Der Nutzer muss nichts tun, der Status aktualisiert sich automatisch; ein Aktualisieren-Button ist vorhanden. +
    +
    +
    +
    + + + screens/kyc/goldens/macos/kyc_account_merge_page_default.png +
    +
    + KYC — Kontozusammenführung erforderlich +
    +
    + Die Seite Kontozusammenführung erforderlich: die Identität wurde bereits in einem anderen Konto gefunden. Der Nutzer soll die Zusammenführung über die zugesandte E-Mail bestätigen. +
    +
    +
    +
    + + + screens/kyc/goldens/macos/kyc_merge_processing_page_default.png +
    +
    + KYC — Konten werden zusammengeführt +
    +
    + Die Seite Konten werden zusammengeführt mit Warte-Spinner, während die Zusammenführung abgeschlossen wird. Der Verifizierungsstatus aktualisiert sich anschliessend automatisch. +
    +
    +
    +
    + +
    + +
    + +
    +
    +

    58Einstellungen

    +
    test/goldens/screens/settings/
    +
    +
    + 2 Screens + +
    +
    +
    +

    + Die Einstellungsseite bündelt Sprache, Währung, Steuerbericht, Nutzerdaten und Sicherheit. Von hier aus lässt sich die Wallet auch zurücksetzen, abgesichert durch eine ausdrückliche Bestätigung. +

    + +
    +
    +
    + + + screens/settings/goldens/macos/settings_page_bitbox.png +
    +
    + Einstellungen mit BitBox-Wallet, ohne Sicherungs-Kachel +
    +
    + Zeigt die Einstellungsseite, wenn eine BitBox-Hardware-Wallet geöffnet ist. Anders als bei einer Software-Wallet fehlt hier die Kachel Wallet-Sicherung, weil die Wiederherstellungsphrase bei einer Hardware-Wallet auf dem Gerät selbst liegt. Die Kachel Netzwerk ist nur in Entwickler-Builds sichtbar. +
    +
    +
    +
    + + + screens/settings/goldens/macos/settings_confirm_logout_wallet_sheet_checked.png +
    +
    + Wallet-Zurücksetzen-Dialog mit gesetztem Häkchen +
    +
    + Zeigt das Bestätigungs-Sheet RealUnit Wallet zurücksetzen mit gesetztem grünem Häkchen, mit dem der Nutzer bestätigt, seine Wiederherstellungsphrase gesichert zu haben. Erst dadurch wird die Schaltfläche Zurücksetzen aktiv und blau; ohne Häkchen bleibt sie deaktiviert. Dieses Sheet erscheint nach dem Tippen auf Wallet zurücksetzen. +
    +
    +
    +
    + +
    + +
    + +
    +
    +

    59Spracheinstellungen — Laden und Fehler

    +
    test/goldens/screens/settings_languages/
    +
    +
    + 2 Screens + +
    +
    +
    +

    + Die Seite Sprachen lädt beim Öffnen die verfügbaren App-Sprachen vom Server. Solange die Liste noch nicht da ist oder das Laden fehlschlägt, zeigt die Seite statt der Sprachauswahl einen Zwischenzustand. +

    + +
    +
    +
    + + + screens/settings_languages/goldens/macos/settings_languages_page_loading.png +
    +
    + Sprachen-Seite lädt mit Ladekreisel +
    +
    + Der Ladezustand der Sprachauswahl: unter der Titelleiste erscheint mittig ein Ladekreisel, während die Liste der verfügbaren Sprachen vom Server geholt wird. Dieser Zustand ist normalerweise nur kurz sichtbar, bis die Sprachen geladen sind. +
    +
    +
    +
    + + + screens/settings_languages/goldens/macos/settings_languages_page_error.png +
    +
    + Fehlermeldung Sprachliste mit Wiederholen-Button +
    +
    + Der Fehlerzustand: Konnte die Sprachliste nicht geladen werden, zeigt die Seite ein Warnsymbol, den Titel Sprachliste konnte nicht geladen werden und den Hinweis, die Internetverbindung zu prüfen und es erneut zu versuchen. Über den Button Wiederholen lädt der Nutzer die Sprachen neu. +
    +
    +
    +
    + +
    + +
    + +
    +
    +

    60Währungsauswahl — Laden und Fehler

    +
    test/goldens/screens/settings_currencies/
    +
    +
    + 2 Screens + +
    +
    +
    +

    + Die Seite Währung in den Einstellungen listet die unterstützten Fiat-Währungen auf, aus denen die Anzeigewährung der Wallet gewählt wird. Bevor die Liste erscheint, lädt die App die Währungen nach; schlägt das fehl, zeigt sie einen Fehlerhinweis mit Wiederholen-Schaltfläche. Diese beiden Zustände deckt der Abschnitt ab. +

    + +
    +
    +
    + + + screens/settings_currencies/goldens/macos/settings_currencies_page_loading.png +
    +
    + Währungsseite: Ladeindikator +
    +
    + Der Ladezustand der Währungsseite: Nur die Titelleiste Währung ist sichtbar, in der Bildschirmmitte dreht sich ein Ladeindikator. Diesen Zustand sehen Sie kurz, solange die App die Liste der unterstützten Währungen vom Server abruft. +
    +
    +
    +
    + + + screens/settings_currencies/goldens/macos/settings_currencies_page_error.png +
    +
    + Währungsseite: Ladefehler mit Wiederholen +
    +
    + Der Fehlerzustand: Konnte die Währungsliste nicht geladen werden, erscheinen ein Warnsymbol, die Meldung Währungsliste konnte nicht geladen werden sowie der Hinweis, die Internetverbindung zu prüfen und es erneut zu versuchen. Über die Schaltfläche Wiederholen startet die App einen neuen Ladeversuch. +
    +
    +
    +
    + +
    + +
    + +
    +
    +

    61Netzwerk — Umschalten

    +
    test/goldens/screens/settings_network/
    +
    +
    + 1 Screen + +
    +
    +
    +

    + Unter Einstellungen → Netzwerk wählt man zwischen den Netzwerken Mainnet und Testnet. Das aktive Netzwerk trägt einen blauen Haken; beim Umschalten auf das andere Netzwerk erscheint kurz ein Ladeindikator, bis der Wechsel abgeschlossen ist. +

    + +
    +
    +
    + + + screens/settings_network/goldens/macos/settings_network_page_switching.png +
    +
    + Netzwerk-Seite: Wechsel zu Testnet läuft +
    +
    + Die Netzwerk-Einstellung listet Mainnet (mit blauem Haken als aktuell aktives Netz) und Testnet. Der Nutzer hat gerade auf Testnet getippt, um das Netzwerk zu wechseln: Statt eines Hakens zeigt Testnet einen kleinen Ladekreis, solange der Wechsel im Gange ist. Diesen Zwischenzustand sieht man nur für den Moment, in dem der Netzwerkwechsel noch verarbeitet wird. +
    +
    +
    +
    + +
    + +
    + +
    +
    +

    62Seed-Anzeige — Ladezustand

    +
    test/goldens/screens/settings_seed/
    +
    +
    + 1 Screen + +
    +
    +
    +

    + Der Bildschirm „Wallet-Sicherung" zeigt die 12 Wiederherstellungs-Wörter, mit denen die Wallet gesichert und wiederhergestellt werden kann. Bevor die Wörter im Speicher liegen, wird anstelle der Seed-Karte ein Ladeindikator eingeblendet. +

    + +
    +
    +
    + + + screens/settings_seed/goldens/macos/settings_seed_page_loading.png +
    +
    + Wallet-Sicherung mit Ladespinner statt Seed-Karte +
    +
    + Der Bildschirm Wallet-Sicherung mit Illustration, Titel und den Hinweistexten zum Notieren der 12 Wiederherstellungs-Wörter. Unter der Überschrift Wiederherstellungs-Wörter erscheint statt der Seed-Karte ein Ladespinner. Diesen Zustand sieht man kurz beim Öffnen des Bildschirms, solange die Wallet noch entsperrt wird und die 12 Wörter noch nicht im Speicher liegen; sobald sie geladen sind, ersetzt die verdeckte Seed-Karte den Spinner. +
    +
    +
    +
    + +
    + +
    + +
    +
    +

    63Sicherheit — Übersicht und Biometrie-Umschalter

    +
    test/goldens/screens/settings_security/
    +
    +
    + 3 Screens + +
    +
    +
    +

    + Die Seite Sicherheit bündelt die PIN- und Biometrie-Einstellungen der App. Welche Zeilen erscheinen, hängt davon ab, ob das Gerät Biometrie unterstützt und ob der Nutzer sie bereits aktiviert hat. +

    + +
    +
    +
    + + + screens/settings_security/goldens/macos/settings_security_page_default.png +
    +
    + Sicherheit — Standard, Biometrie aktiviert +
    +
    + Die Sicherheitsseite mit den beiden Einträgen PIN ändern und Mit Biometrie entsperren. Der Schalter der Biometrie-Zeile steht auf An (blau), weil das Gerät Biometrie unterstützt und der Nutzer sie aktiviert hat. +
    +
    +
    +
    + + + screens/settings_security/goldens/macos/settings_security_page_biometrics_disabled.png +
    +
    + Sicherheit — Biometrie unterstützt, aber aus +
    +
    + Dieselbe Seite wie im Standard, doch der Schalter Mit Biometrie entsperren steht auf Aus. Diesen Zustand sieht der Nutzer, wenn das Gerät Biometrie zwar unterstützt, er sie aber noch nicht aktiviert hat. +
    +
    +
    +
    + + + screens/settings_security/goldens/macos/settings_security_page_no_biometrics.png +
    +
    + Sicherheit — ohne Biometrie-Zeile +
    +
    + Nur der Eintrag PIN ändern ist sichtbar; die Biometrie-Zeile fehlt vollständig. Dieser Zustand erscheint auf Geräten ohne Biometrie-Hardware oder ohne hinterlegte biometrische Registrierung. +
    +
    +
    +
    + +
    + +
    + +
    +
    +

    64Sicherheit — Umschaltvorgang und Fehler

    +
    test/goldens/screens/settings_security/
    +
    +
    + 2 Screens + +
    +
    +
    +

    + Das Ein- oder Ausschalten der Biometrie läuft über einen kurzen Betriebssystem-Dialog. Diese Zustände zeigen den laufenden Vorgang sowie die Fehlermeldung, falls er nicht durchgeht. +

    + +
    +
    +
    + + + screens/settings_security/goldens/macos/settings_security_page_busy.png +
    +
    + Sicherheit — Biometrie-Umschaltung läuft +
    +
    + Während das Ein- oder Ausschalten der Biometrie noch läuft, ersetzt ein kleiner Ladekreisel den Schalter in der Zeile Mit Biometrie entsperren. Ein weiterer Tipp wird ignoriert, bis der Vorgang abgeschlossen ist. +
    +
    +
    +
    + + + screens/settings_security/goldens/macos/settings_security_page_error_snackbar.png +
    +
    + Sicherheit — Fehler beim Umschalten der Biometrie +
    +
    + Schlägt das Aktivieren oder Deaktivieren der Biometrie fehl (etwa durch Sperre oder fehlende Registrierung), erscheint am unteren Rand eine rote Meldung: „Die Einstellung für die biometrische Entsperrung konnte nicht geändert werden." Ein reiner Abbruch des Betriebssystem-Dialogs löst diese Meldung nicht aus. +
    +
    +
    +
    + +
    + +
    + +
    +
    +

    65Steuerbericht

    +
    test/goldens/screens/settings_tax_report/
    +
    +
    + 3 Screens + +
    +
    +
    +

    + Über Einstellungen erreichen Sie den Screen Steuerbericht, auf dem Sie zu einem selbst gewählten Stichdatum einen Vermögens-/Steuerbericht als PDF erzeugen. Der Screen besteht aus einem kurzen Hinweistext, einem Datumsfeld und dem Knopf zur PDF-Erstellung. +

    + +
    +
    +
    + + + screens/settings_tax_report/goldens/macos/settings_tax_report_page_default.png +
    +
    + Steuerbericht — Startzustand mit Datumsfeld und PDF-Knopf +
    +
    + Der Startzustand des Steuerbericht-Screens: der Hinweis „Hier können Sie Ihren Steuerbericht für ein spezifisches Datum generieren.", darunter das Datumsfeld (vorbelegt mit dem letzten Jahresende, hier 31.12.2025) und der blaue Knopf PDF. Diesen Zustand sehen Sie beim Öffnen des Screens; ein Tippen auf PDF startet die Erstellung für das gewählte Datum. +
    +
    +
    +
    + + + screens/settings_tax_report/goldens/macos/settings_tax_report_page_date_picker.png +
    +
    + Datumsauswahl — Material-Dialog zur Stichtag-Wahl +
    +
    + Nach dem Tippen auf das Datumsfeld öffnet sich der Material-Datumsdialog (Select date), hier auf den vorbelegten Stichtag Mittwoch, 31. Dez. 2025 gesetzt. Hier wählen Sie das Stichdatum des Berichts und bestätigen mit OK oder brechen mit Cancel ab; der Screen im Hintergrund ist während der Auswahl abgedunkelt. +
    +
    +
    +
    + + + screens/settings_tax_report/goldens/macos/settings_tax_report_page_failure_snackbar.png +
    +
    + Fehler — rote Meldung am unteren Rand +
    +
    + Schlägt die Erstellung fehl, erscheint am unteren Rand eine rote Fehler-Meldung: „Konnte Steuerausweis nicht generieren.". Der Screen bleibt unverändert bedienbar, sodass Sie den Vorgang direkt erneut anstossen können. +
    +
    +
    +
    + +
    + +
    + +
    +
    +

    66Nutzerdaten — Übersicht

    +
    test/goldens/screens/settings_user_data/
    +
    +
    + 6 Screens + +
    +
    +
    +

    + Die Seite Nutzerdaten zeigt die bei DFX hinterlegten KYC-Angaben des Kontos als Liste: Kontotyp, Name, Geburtstag, Staatsangehörigkeit, E-Mail, Telefonnummer und Residenz. Je nach Berechtigung und Datenlage ändert sich, welche Zeilen und welche Bearbeiten-Schaltflächen erscheinen. +

    + +
    +
    +
    + + + screens/settings_user_data/goldens/macos/settings_user_data_page_default.png +
    +
    + Nutzerdaten-Übersicht, vollständig, ohne Bearbeiten-Buttons +
    +
    + Der Standardzustand mit vollständig geladenen Nutzerdaten. Alle Zeilen von Kontotyp bis Residenz werden angezeigt; da keine Bearbeitungsrechte gesetzt sind, fehlen die Stift-Schaltflächen. So sieht ein Konto ohne Editierberechtigung aus. +
    +
    +
    +
    + + + screens/settings_user_data/goldens/macos/settings_user_data_page_editable.png +
    +
    + Nutzerdaten mit Bearbeiten-Buttons (Stift) +
    +
    + Dieselbe Übersicht, aber mit aktiven Bearbeitungsrechten: Neben Name, Telefonnummer und Residenz erscheint je eine runde Stift-Schaltfläche, die ins jeweilige Bearbeiten-Formular führt. Kontotyp, Geburtstag, Staatsangehörigkeit und E-Mail bleiben unveränderbar. +
    +
    +
    +
    + + + screens/settings_user_data/goldens/macos/settings_user_data_page_pending.png +
    +
    + Nutzerdaten mit Badge Änderung in Prüfung +
    +
    + Zeigt eine laufende KYC-Änderung: Neben Name und Residenz steht der blaue, kursive Hinweis Änderung in Prüfung. Der Nutzer sieht das, wenn er eine Namens- oder Adressänderung eingereicht hat und diese noch geprüft wird. +
    +
    +
    +
    + + + screens/settings_user_data/goldens/macos/settings_user_data_page_no_birthday.png +
    +
    + Nutzerdaten-Übersicht ohne Geburtstag-Zeile +
    +
    + Variante ohne hinterlegtes Geburtsdatum: Die Zeile Geburtstag entfällt komplett, alle übrigen Zeilen bleiben erhalten. Der Nutzer sieht das, wenn zu seinem Konto kein Geburtstag gespeichert ist. +
    +
    +
    +
    + + + screens/settings_user_data/goldens/macos/settings_user_data_page_email_only.png +
    +
    + Nutzerdaten, nur E-Mail-Zeile +
    +
    + Reduzierter Zustand, wenn keine vollständigen Nutzerdaten, aber eine E-Mail-Adresse vorliegen. Angezeigt wird nur die einzelne Zeile E-Mail. Typisch für Konten, die erst die E-Mail bestätigt, aber noch kein KYC durchlaufen haben. +
    +
    +
    +
    + + + screens/settings_user_data/goldens/macos/settings_user_data_page_empty.png +
    +
    + Keine Nutzerdaten hinterlegt +
    +
    + Der leere Zustand ohne jegliche Nutzerdaten und ohne E-Mail. Zentriert steht der Hinweis Zu dieser Wallet sind keine Nutzerdaten hinterlegt. So sieht eine Wallet aus, für die noch keine Daten bei DFX existieren. +
    +
    +
    +
    + +
    + +
    + +
    +
    +

    67Nutzerdaten — Laden und Fehler

    +
    test/goldens/screens/settings_user_data/
    +
    +
    + 3 Screens + +
    +
    +
    +

    + Solange die Angaben noch geladen werden oder das Laden scheitert, zeigt die Seite statt der Liste einen Platzhalter- oder Fehlerzustand. Bei BitBox-Wallets kommt ein zusätzlicher Hardware-Sonderfall hinzu. +

    + +
    +
    +
    + + + screens/settings_user_data/goldens/macos/settings_user_data_page_loading.png +
    +
    + Nutzerdaten werden geladen (Spinner) +
    +
    + Der Ladezustand: Während die Nutzerdaten vom Server abgerufen werden, erscheint mittig ein Aktivitätsindikator (Spinner). Danach wechselt die Seite in einen der Übersichts- oder Fehlerzustände. +
    +
    +
    +
    + + + screens/settings_user_data/goldens/macos/settings_user_data_page_failure.png +
    +
    + Fehler beim Laden der Nutzerdaten +
    +
    + Der Fehlerzustand beim Laden. Mittig steht der Text Beim Laden der Nutzerdaten ist ein Fehler aufgetreten. Der Nutzer sieht das, wenn die Nutzerdaten nicht vom Server geladen werden konnten. +
    +
    +
    +
    + + + screens/settings_user_data/goldens/macos/settings_user_data_page_bitbox_disconnected.png +
    +
    + BitBox nicht verbunden, Wieder-verbinden-Button +
    +
    + Sonderfall bei einer BitBox-Wallet, deren Hardware getrennt ist: Titel BitBox ist nicht verbunden, eine kurze Erklärung und der blaue Button BitBox erneut verbinden. Über den Button startet der Nutzer den Wiederverbindungs-Dialog, danach werden die Daten neu geladen. +
    +
    +
    +
    + +
    + +
    + +
    +
    +

    68Daten bearbeiten — Formulare

    +
    test/goldens/screens/settings_user_data/
    +
    +
    + 3 Screens + +
    +
    +
    +

    + Über die Stift-Schaltflächen der Übersicht gelangt der Nutzer in eigene Formulare, um Name, Telefonnummer oder Residenz zu ändern. Jede Änderung wird per Speichern zur erneuten KYC-Prüfung eingereicht. +

    + +
    +
    +
    + + + screens/settings_user_data/goldens/macos/settings_edit_name_page_default.png +
    +
    + Formular Name ändern +
    +
    + Das Formular Name ändern im Ausgangszustand mit den leeren Feldern Vorname und Nachname (Platzhalter Max/Mustermann) sowie dem Upload-Feld Nachweis-Dokument. Unten steht Speichern. Ein Nachweisdokument ist zum Absenden erforderlich. +
    +
    +
    +
    + + + screens/settings_user_data/goldens/macos/settings_edit_phone_number_page_default.png +
    +
    + Formular Telefonnummer ändern +
    +
    + Das Formular Telefonnummer ändern im Ausgangszustand: eine Länderauswahl mit Vorwahl +41 und das Eingabefeld für die Nummer (Platzhalter 1231234567), darunter Speichern. Hier trägt der Nutzer seine neue Telefonnummer ein. +
    +
    +
    +
    + + + screens/settings_user_data/goldens/macos/settings_edit_address_page_default.png +
    +
    + Formular Residenz/Adresse ändern +
    +
    + Das Formular Adresse ändern im Ausgangszustand mit den leeren Feldern Strasse, Nummer, PLZ und Stadt, der Auswahl Land und dem Upload-Feld Nachweis-Dokument. Unten steht Speichern. Auch hier ist ein Nachweisdokument nötig. +
    +
    +
    +
    + +
    + +
    + +
    +
    +

    69Daten bearbeiten — Verarbeitung und Ergebnis

    +
    test/goldens/screens/settings_user_data/
    +
    +
    + 3 Screens + +
    +
    +
    +

    + Nach dem Absenden eines Bearbeiten-Formulars durchläuft die Änderung Lade-, Prüf- und Fehlerzustände. Diese Zwischenseiten teilen sich Name-, Telefon- und Adress-Formular. +

    + +
    +
    +
    + + + screens/settings_user_data/goldens/macos/settings_edit_loading_page_default.png +
    +
    + Bearbeiten-Formular lädt (Spinner) +
    +
    + Der Ladezustand eines Bearbeiten-Formulars: Nur die Kopfzeile und ein mittiger Spinner sind sichtbar, während die Daten geladen oder die Eingabe verarbeitet wird. +
    +
    +
    +
    + + + screens/settings_user_data/goldens/macos/settings_edit_pending_page_default.png +
    +
    + Änderung in Prüfung, Aktualisieren-Button +
    +
    + Der Prüf-Zustand nach dem Einreichen: Titel Daten werden geprüft mit dem Hinweis, dass der Schritt (hier Change address) noch unter Prüfung ist, plus Button Aktualisieren zum erneuten Abrufen des Status. +
    +
    +
    +
    + + + screens/settings_user_data/goldens/macos/settings_edit_failure_page_default.png +
    +
    + Fehler beim Laden im Bearbeiten-Formular +
    +
    + Der Fehlerzustand eines Bearbeiten-Formulars: Titel Fehler beim Laden, eine Beschreibung mit der konkreten Fehlermeldung und dem Hinweis, bei anhaltendem Fehler den Support zu kontaktieren, sowie der Button Aktualisieren. +
    +
    +
    +
    + +
    + +
    + +
    +
    +

    70Rechtliche Hinweise — Disclaimer-Ablauf

    +
    test/goldens/screens/legal/
    +
    +
    + 1 Screen + +
    +
    +
    +

    + Der Disclaimer wird als mehrstufiger Ablauf vor der ersten Nutzung angezeigt. Ein Fortschrittsbalken oben zeigt, an welcher Stelle der Schritte man sich befindet; unten führen die Buttons Ablehnen und Zustimmen weiter. +

    + +
    + +
    +
    + +
    + +
    + +
    +
    +

    71Rechtsdokumente — Anzeige und Ladezustände

    +
    test/goldens/screens/legal/
    +
    +
    + 3 Screens + +
    +
    +
    +

    + Rechtsdokumente wie Nutzungsbedingungen und AGB werden aus mitgelieferten Asset-Dateien geladen und als formatierter Markdown-Text dargestellt. Die folgenden Zustände zeigen den leeren Ladeplatzhalter, den erfolgreich geladenen Text sowie den Fehlerfall. +

    + +
    + + + +
    +
    + +
    + +
    + +
    +
    +

    72Support — Übersicht und E-Mail-Erfassung

    +
    test/goldens/screens/support/
    +
    +
    + 4 Screens + +
    +
    +
    +

    + Der Einstieg in den Support-Bereich: die Auswahl zwischen neuem Ticket und bestehenden Tickets sowie das Erfassen der primären E-Mail-Adresse, die für die Kommunikation zwingend hinterlegt sein muss. +

    + +
    +
    +
    + + + screens/support/goldens/macos/support_page_default.png +
    +
    + Support-Startseite mit zwei Kacheln +
    +
    + Die Startseite des Support-Bereichs mit zwei Kacheln: Neues Ticket erstellen und Meine Tickets. Der Nutzer sieht diesen Einstieg, sobald er den Support kontaktiert, und wählt hier zwischen einem neuen Anliegen und der Übersicht seiner bestehenden Tickets. +
    +
    +
    +
    + + + screens/support/goldens/macos/support_email_capture_page_default.png +
    +
    + E-Mail-Adresse für Support hinterlegen +
    +
    + Das Formular E-Mail-Adresse hinzufügen mit dem Hinweistext, dass für die Erfassung eines Support-Tickets eine E-Mail-Adresse benötigt wird. Der Nutzer sieht diesen Standardzustand, wenn er den Support nutzen will, aber noch keine primäre E-Mail-Adresse hinterlegt hat. +
    +
    +
    +
    + + + screens/support/goldens/macos/support_email_capture_page_submitting.png +
    +
    + E-Mail-Erfassung wird gesendet +
    +
    + Derselbe E-Mail-Dialog, während die eingegebene Adresse gespeichert wird: Die Schaltfläche Weiter zeigt statt der Beschriftung eine Ladeanimation. Dieser Zustand erscheint kurz nach dem Absenden, solange die Registrierung der E-Mail-Adresse läuft. +
    +
    +
    +
    + + + screens/support/goldens/macos/support_email_capture_page_buy_flow.png +
    +
    + E-Mail-Erfassung aus dem Kauf-Ablauf +
    +
    + Dasselbe E-Mail-Formular, jedoch mit abweichendem Erklärtext für den Kauf-Ablauf: Für den Kauf wird die primäre E-Mail-Adresse benötigt. Der Nutzer sieht diese Variante, wenn die Kaufbestätigung eine hinterlegte E-Mail-Adresse verlangt und ihn dafür auf dieses Formular leitet. +
    +
    +
    +
    + +
    + +
    + +
    +
    +

    73Support — Ticket erstellen

    +
    test/goldens/screens/support/
    +
    +
    + 3 Screens + +
    +
    +
    +

    + Das Formular zum Erfassen eines neuen Support-Tickets: Anliegen-Typ per Tag auswählen, Anliegen im Textfeld beschreiben und absenden. +

    + +
    +
    +
    + + + screens/support/goldens/macos/support_create_ticket_page_default.png +
    +
    + Leeres Formular für neues Ticket +
    +
    + Das leere Formular Neues Ticket erstellen mit der Auswahl Anliegen auswählen (Tags Allgemeines Anliegen, Fehlerbericht, KYC-Problem), dem Nachrichtenfeld und der noch deaktivierten Schaltfläche Senden. So sieht der Nutzer das Formular direkt beim Öffnen, bevor er einen Typ wählt oder etwas eingibt. +
    +
    +
    +
    + + + screens/support/goldens/macos/support_create_ticket_page_filled.png +
    +
    + Ticket-Formular ausgefüllt, Senden aktiv +
    +
    + Das ausgefüllte Formular mit ausgewähltem Anliegen-Typ und eingegebener Nachricht: Die Schaltfläche Senden ist nun aktiv. Der Nutzer sieht diesen Zustand, sobald er einen Typ gewählt und eine Nachricht erfasst hat und das Ticket abschicken kann. +
    +
    +
    +
    + + + screens/support/goldens/macos/support_create_ticket_page_submitting.png +
    +
    + Ticket wird abgesendet +
    +
    + Das Ticket-Formular während des Absendens: Die Schaltfläche Senden zeigt eine Ladeanimation. Dieser Zustand erscheint, nachdem der Nutzer auf Senden getippt hat, solange das Ticket an den Server übermittelt wird. +
    +
    +
    +
    + +
    + +
    + +
    +
    +

    74Support — Meine Tickets

    +
    test/goldens/screens/support/
    +
    +
    + 4 Screens + +
    +
    +
    +

    + Die Liste der eigenen Support-Tickets mit ihren Lade-, Fehler- und Inhaltszuständen. Ein farbiger Statuspunkt zeigt je Ticket, ob es offen oder geschlossen ist. +

    + +
    +
    +
    + + + screens/support/goldens/macos/support_tickets_page_default.png +
    +
    + Keine Support-Tickets vorhanden +
    +
    + Die Ticket-Übersicht im leeren Zustand mit dem zentrierten Hinweis Keine Tickets vorhanden. Der Nutzer sieht dies, wenn er noch kein Support-Ticket erstellt hat. +
    +
    +
    +
    + + + screens/support/goldens/macos/support_tickets_page_loading.png +
    +
    + Ticket-Liste wird geladen +
    +
    + Die Ticket-Übersicht während des Ladens mit einer zentrierten Ladeanimation. Dieser Zustand erscheint kurz, während die Liste der eigenen Tickets vom Server abgerufen wird. +
    +
    +
    +
    + + + screens/support/goldens/macos/support_tickets_page_loaded.png +
    +
    + Ticket-Liste mit offenem und geschlossenem Ticket +
    +
    + Die geladene Ticket-Liste mit zwei Einträgen: ein offenes Ticket mit grünem Statuspunkt und ein geschlossenes mit grauem Punkt, jeweils mit Titel und Erstellungsdatum. Der Nutzer sieht diese Ansicht, wenn Tickets vorhanden sind, und kann einen Eintrag antippen, um den Chat zu öffnen. +
    +
    +
    +
    + + + screens/support/goldens/macos/support_tickets_page_error.png +
    +
    + Fehler beim Laden der Tickets +
    +
    + Die Ticket-Übersicht im Fehlerzustand mit dem zentrierten Text Tickets konnten nicht geladen werden. Diese Meldung erscheint, wenn die Ticket-Liste nicht vom Server abgerufen werden konnte. +
    +
    +
    +
    + +
    + +
    + +
    +
    +

    75Support — Chat

    +
    test/goldens/screens/support/
    +
    +
    + 5 Screens + +
    +
    +
    +

    + Der Chat-Verlauf zu einem einzelnen Ticket: Nachrichten von Kunde und Support als Sprechblasen, mit Eingabefeld bei offenen Tickets und seinen Lade-, Sende-, Fehler- und Geschlossen-Zuständen. +

    + +
    +
    +
    + + + screens/support/goldens/macos/support_chat_page_default.png +
    +
    + Chat wird geladen +
    +
    + Der Ticket-Chat während des Ladens mit einer zentrierten Ladeanimation. Der Nutzer sieht diesen Zustand kurz, während der Chatverlauf eines Tickets abgerufen wird. +
    +
    +
    +
    + + + screens/support/goldens/macos/support_chat_page_loaded.png +
    +
    + Geladener Chat mit Nachrichten und Eingabefeld +
    +
    + Der geladene Chat eines offenen Tickets: die Kunden-Nachricht als blaue, rechtsbündige Sprechblase und die Antwort des Supports als graue, linksbündige Sprechblase mit Label Support und Uhrzeit. Unten steht das Eingabefeld Nachricht eingeben mit Sende-Schaltfläche bereit. +
    +
    +
    +
    + + + screens/support/goldens/macos/support_chat_page_sending.png +
    +
    + Chat-Nachricht wird gesendet +
    +
    + Der offene Chat, während eine Nachricht gesendet wird: Das Eingabefeld ist deaktiviert und die Sende-Schaltfläche zeigt statt des Pfeils eine Ladeanimation. Dieser Zustand erscheint, nachdem der Nutzer auf Senden getippt hat, solange die Nachricht übermittelt wird. +
    +
    +
    +
    + + + screens/support/goldens/macos/support_chat_page_closed.png +
    +
    + Chat eines geschlossenen Tickets +
    +
    + Der Chat eines geschlossenen Tickets: Anstelle des Eingabefelds steht am unteren Rand der graue Hinweis Ticket geschlossen. Der Nutzer kann den Verlauf weiterhin lesen, aber keine neuen Nachrichten mehr senden. +
    +
    +
    +
    + + + screens/support/goldens/macos/support_chat_page_error.png +
    +
    + Fehler beim Laden des Chats +
    +
    + Der Ticket-Chat im Fehlerzustand mit dem zentrierten Text Chat konnte nicht geladen werden. Diese Meldung erscheint, wenn der Chatverlauf eines Tickets nicht abgerufen werden konnte. +
    +
    +
    +
    + +
    + +
    + +
    +
    +

    76Debug-Auth — Adresse und Nachricht abrufen

    +
    test/goldens/screens/debug_auth/
    +
    +
    + 3 Screens + +
    +
    +
    +

    + Der Debug-Auth-Screen ist ein reines Entwickler-Werkzeug: Er ist per kDebugMode-Prüfung nur in Debug-Builds erreichbar und erscheint niemals im Release- oder Produktions-Build. In der ersten Phase trägt der Entwickler eine Wallet-Adresse ein und ruft dazu eine signierte Nachricht (Challenge) vom Server ab. +

    + +
    +
    +
    + + + screens/debug_auth/goldens/macos/debug_auth_page_default.png +
    +
    + Debug-Auth Startzustand mit leerem Adressfeld +
    +
    + Der Startzustand des Debug-Auth-Tools: sichtbar sind nur das leere Feld „Adresse" mit dem Platzhalter 0x... und der Textbutton „Signierte Nachricht abrufen". So sieht der Entwickler den Screen beim Öffnen, wenn keine Adresse gespeichert ist. +
    +
    +
    +
    + + + screens/debug_auth/goldens/macos/debug_auth_page_fetching.png +
    +
    + Nachricht wird geladen, Abruf-Button deaktiviert +
    +
    + Während die signierte Nachricht vom Server geladen wird (isLoading), ist der Button „Signierte Nachricht abrufen" ausgegraut und deaktiviert. Der Signatur-Block fehlt noch, weil bislang keine Nachricht vorliegt. +
    +
    +
    +
    + + + screens/debug_auth/goldens/macos/debug_auth_page_error.png +
    +
    + Rote Fehlerbox unter dem Abruf-Button +
    +
    + Schlägt der Abruf oder die Authentifizierung fehl, erscheint unter dem Button eine rote Fehlerbox mit dem Fehlertext, hier „Authentifizierung fehlgeschlagen (401).". Der Signatur-Block bleibt aus, da keine gültige Nachricht geladen wurde. +
    +
    +
    +
    + +
    + +
    + +
    +
    +

    77Debug-Auth — Signieren und Authentifizieren

    +
    test/goldens/screens/debug_auth/
    +
    +
    + 3 Screens + +
    +
    +
    +

    + Sobald die Challenge geladen ist, erscheint ein zweiter Block mit der Nachricht, einem Signatur-Feld und dem Authentifizieren-Button. Auch diese Phase gehört zum kDebugMode-Werkzeug und ist im Produktions-Build nicht vorhanden. +

    + +
    +
    +
    + + + screens/debug_auth/goldens/macos/debug_auth_page_sign_message.png +
    +
    + Signierte Nachricht, Signatur-Feld und Authentifizieren +
    +
    + Sobald die Nachricht geladen ist, erscheinen ein Trenner, die Überschrift „Signierte Nachricht:", die Challenge in einer grauen Box, das Feld „Signatur" und der blaue Button „Authentifizieren". Der Entwickler signiert die Nachricht extern und trägt die Signatur hier ein. +
    +
    +
    +
    + + + screens/debug_auth/goldens/macos/debug_auth_page_clipboard_snackbar.png +
    +
    + Grüne Snackbar: In die Zwischenablage kopiert +
    +
    + Ein Tippen auf die graue Nachrichtenbox kopiert den Text in die Zwischenablage und zeigt unten die grüne Snackbar „In die Zwischenablage kopiert". So lässt sich die Nachricht bequem in ein externes Signier-Tool übernehmen. +
    +
    +
    +
    + + + screens/debug_auth/goldens/macos/debug_auth_page_authenticating.png +
    +
    + Authentifizieren-Button im Ladezustand +
    +
    + Nach dem Tippen auf „Authentifizieren" wechselt der Button in den Ladezustand (hellblau mit Spinner), während die eingetragene Signatur gegen den Server geprüft wird. Adress- und Signatur-Feld bleiben dabei sichtbar. +
    +
    +
    +
    + +
    + +
    + +
    +
    +

    78Telefonnummer-Eingabe

    +
    test/goldens/widgets/form/
    +
    +
    + 1 Screen + +
    +
    +
    +

    + Das Telefonnummer-Feld kombiniert eine Vorwahl-Auswahl mit einem separaten Eingabefeld für die Rufnummer. Es wird bei der Registrierung und in Formularen verwendet, in denen eine Telefonnummer erfasst wird. +

    + +
    +
    +
    + + + widgets/form/goldens/macos/phone_number_field_default.png +
    +
    + Leeres Telefonnummer-Feld mit Vorwahl +41 +
    +
    + Zeigt den Ausgangszustand des Telefonnummer-Felds: die Überschrift Telefonnummer, links das Vorwahl-Dropdown mit der Standardvorwahl +41 (Schweiz, alternativ +49 für Deutschland) und rechts das noch leere Rufnummernfeld mit dem Platzhalter 1231234567. So sehen Nutzer das Feld, bevor sie eine Nummer eingeben; die Länge der Nummer wird erst serverseitig geprüft. +
    +
    +
    +
    + +
    + +
    + +
    +
    +

    WWeb · realunit.app

    +
    RealUnitCH/web · tests/__screenshots__/
    +
    +
    + Live aus web-Repo + +
    +
    +
    + +

    + Die öffentliche Website realunit.app + (Landingpage plus der Aktionariat-Adressbestätigungs-Flow unter + /confirm-aktionariat) hat ihre eigene Visual-Regression-Suite. + Jedes Bild hier ist eine Playwright-Baseline aus dem + RealUnitCH/web-Repo + (tests/__screenshots__/), aufgenommen im gepinnten + Linux-Container über drei Viewports — Desktop (Chromium), + Tablet (Chromium) und Mobile (Safari). Es gilt dieselbe + Eigenschaft wie bei den App-Screens oben: Driftet das Seitenrendering, + wird zuerst der Visual-Regression-Test im web-Repo rot, bevor die + Baseline sich ändert. Bei jedem Handbook-Build werden die aktuell + akzeptierten Baselines aus develop automatisch ins Image + gezogen — Single Source of Truth ist das web-Repo, dieses Handbook + spiegelt nur den aktuellen Stand. +

    + +

    Startseite

    +
    +
    +
    + + + desktop-chromium/home.png +
    +
    + Startseite — Desktop +
    +
    + Die Landingpage von realunit.app in der Desktop-Ansicht (Chromium). +
    +
    +
    +
    tablet-chromium/home.png @@ -2788,6 +8183,76 @@

    Adressbestätigung — ungültiger Link

    +
    +
    +
    + + + desktop-chromium/confirm-no-registration.png +
    +
    + Keine Wallet-Registrierung — Desktop (DE) +
    +
    + Zustand, wenn die E-Mail-Adresse bestätigt ist, aber keine + Wallet-Registrierung zum Link gefunden wurde — permanenter + Zustand ohne Retry, Desktop, Deutsch. +
    +
    +
    +
    + + + desktop-chromium/confirm-no-registration-en.png +
    +
    + No wallet registration found — Desktop (EN) +
    +
    + Derselbe Zustand auf Englisch. +
    +
    +
    +
    + + + tablet-chromium/confirm-no-registration.png +
    +
    + Keine Wallet-Registrierung — Tablet +
    +
    + Zustand im Tablet-Layout. +
    +
    +
    +
    + + + mobile-safari/confirm-no-registration.png +
    +
    + Keine Wallet-Registrierung — Mobile +
    +
    + Zustand in der Mobile-Ansicht. +
    +
    +
    +

    Adressbestätigung — Dienst nicht verfügbar

    @@ -3190,6 +8655,7 @@

    Apple App Store (de-DE)

    App-Name (max 30)
    RealUnit
    Untertitel (max 30)
    Sicher. Einfach. Unabhängig.
    +
    Werbetext (max 170)
    Kaufe, halte und verkaufe RealUnit Tokens sicher mit der RealUnit App
    Beschreibung (max 4000)
    Die offizielle App der RealUnit Schweiz AG – für den einfachen, sicheren Kauf und die bankenunabhängige Verwahrung der RealUnit Aktientoken. Direkt in Ihrer Hand.
     
     IHRE VORTEILE AUF EINEN BLICK
    diff --git a/docs/responsive-layout.md b/docs/responsive-layout.md
    new file mode 100644
    index 000000000..16ff8aeae
    --- /dev/null
    +++ b/docs/responsive-layout.md
    @@ -0,0 +1,112 @@
    +# Responsive layout & accessibility text
    +
    +## Goal
    +
    +Every standard phone (iOS mini/SE → Pro Max, Android compact → large) and every system text size (small → extreme accessibility) keeps **all primary actions tappable** and **all critical copy reachable** (scroll if needed).
    +
    +This is independent of line-coverage %: a device × text-scale matrix plus a living catalog of surfaces gates the "CTA outside hit bounds" bug class for every surface the catalog lists. The catalog does not prove every sticky-CTA surface in the app is covered — see docs/testing.md.
    +
    +## Production pattern
    +
    +```dart
    +ScrollableActionsLayout(
    +  body: /* illustration, titles, forms, hints */,
    +  actions: [
    +    AppFilledButton(...), // primary
    +    AppFilledButton(variant: secondary, ...), // optional
    +  ],
    +);
    +```
    +
    +Source: [`lib/widgets/scrollable_actions_layout.dart`](../lib/widgets/scrollable_actions_layout.dart).
    +
    +**Contract details**
    +
    +- **Bounded height required.** The host must give `ScrollableActionsLayout` a bounded height (bottom sheet, `Expanded`, or fixed-size `SizedBox`). Unbounded height throws a `FlutterError` in every build mode (debug and release) — fail-loud, not a silent broken layout.
    +- **`centerBody: true`** vertically centers `body` while it fits the viewport; once content outgrows the viewport, the body scrolls normally. Use this for screens that previously centered with `Spacer()` above and below.
    +- **`shrinkWrap: true`** sizes to content up to the incoming max height/cap instead of expanding to fill it; the body still scrolls past that cap and actions stay pinned below. Use for bottom sheets/dialogs that should not be forced to full height.
    +- **`Spacer()` is illegal inside the body.** The body lives in a `SingleChildScrollView` (unbounded main axis); a `Spacer` there throws a RenderFlex "unbounded" exception. Prefer `centerBody: true` instead.
    +
    +| Do | Don't |
    +|---|---|
    +| Scroll long body | `Column` + `Spacer` + buttons with no scroll |
    +| Sticky actions under the scroll view | Buttons as last children of an overflowing `Column` |
    +| Host with bounded height (sheet / `Expanded` / fixed `SizedBox`) | Unbounded height host (e.g. bare `Column(mainAxisSize: min)`) |
    +| `centerBody: true` for vertically centered content | `Spacer()` inside the scrollable body |
    +| `shrinkWrap: true` for content-sized sheets/dialogs | Force full-height layout on short bottom sheets |
    +| Fix/cap sheet height (e.g. a fraction of screen height) + scroll inside | Fixed height + non-scroll content that can exceed it |
    +| Cap large illustrations (`maxHeight`) | Always paint 200×200 art above multi-paragraph copy |
    +
    +## Test pattern
    +
    +```dart
    +for (final cell in kFullResponsiveMatrix) {
    +  testWidgets('mySheet · ${cell.id}', (tester) async {
    +    await withTargetPlatform(cell.device.platform, () async {
    +      await expectNoLayoutOverflow(tester, () async {
    +        await pumpClippedSheet(
    +          tester,
    +          widget: sheet,
    +          mediaQuery: cell.mediaQuery,
    +        );
    +      });
    +      await expectFullyTappable(
    +        tester,
    +        find.text('Bestätigen'),
    +        within: find.byType(MySheet),
    +      );
    +    });
    +  });
    +}
    +```
    +
    +Helpers:
    +
    +- [`test/helper/responsive_matrix.dart`](../test/helper/responsive_matrix.dart) — devices + scales
    +- [`test/helper/layout_assertions.dart`](../test/helper/layout_assertions.dart) — overflow + hit-test tap
    +- [`test/helper/responsive_surface_catalog.dart`](../test/helper/responsive_surface_catalog.dart) — surfaces under the gate
    +
    +## Rollout
    +
    +The migration is repo-wide. **29 surfaces** are on `ScrollableActionsLayout` and
    +registered in [`kResponsiveSurfaceCatalog`](../test/helper/responsive_surface_catalog.dart)
    +— the BitBox connect sheet plus dashboard, create wallet, verify pin, the three KYC status
    +pages, KYC account-merge / merge-processing / link-wallet, KYC financial-data questions,
    +onboarding completed, support create-ticket, five settings_user_data edit/status subpages,
    +verify seed phrase, restore wallet, buy, sell, setup PIN, and the two CTA-less KYC static
    +pages (failure / signature-unsupported). Four bottom sheets were also migrated with
    +`shrinkWrap: true` and are now catalogued: `sell_confirm_sheet`, `sell_executed_sheet`,
    +`forgot_pin_bottom_sheet`, and `enable_biometric_bottom_sheet`.
    +
    +A follow-up sweep of this branch found and fixed 3 surfaces the original grep
    +(`Spacer()` + `AppFilledButton`/`FilledButton`) had missed, because each either used a
    +custom button widget or had no CTA at all: `setup_pin_page`, `kyc_failure_page`, and
    +`kyc_signature_unsupported_page` (the last two ship with an empty `actions: []` — there is
    +no CTA, but the message now scrolls instead of clipping). A parallel workstream separately
    +migrated `verify_seed_page`, `restore_wallet_view`, `buy_page`, and `sell_page` off the bare
    +`Spacer()` shape. All 7 now have their own matrix test and are registered in
    +`kResponsiveSurfaceCatalog`.
    +
    +The catalog is a living list reviewed manually, not a proof that every sticky-CTA (or
    +overflow-prone) surface in the app is covered — the 3-surface miss above is exactly why.
    +As of this update, `grep -rl "Spacer()" lib/` still finds hits only in
    +`lib/widgets/scrollable_actions_layout.dart` (the widget's own doc comment, not a usage)
    +and `lib/screens/dashboard/widgets/transaction_row.dart` (a horizontal `Row` use —
    +legitimate, not this bug class, left unchanged). No sticky-CTA surface currently matches
    +this grep signal.
    +
    +That grep is not exhaustive either — it is the same limited signal that missed the 3
    +surfaces above in the first place. The previously-open candidates from that sweep — the
    +sell confirm/executed sheets and the two PIN bottom sheets — are now migrated
    +(`shrinkWrap: true`) and catalogued. `welcome_page.dart` was reviewed and found safe
    +(scrolls end-to-end with no separate sticky CTA); it is not a migration candidate. As of
    +this update, no further known un-catalogued candidates remain from the prior sweep. The
    +catalog and the `Spacer()` grep are still not a completeness proof — every new sticky-CTA
    +surface remains a review responsibility going forward.
    +
    +## Manual smoke (optional, not the gate)
    +
    +- Smallest phone + largest text in iOS/Android settings
    +- BitBox pairing with a real multi-token channel hash
    +
    +Automated matrix is the merge gate; hardware smoke is a release checklist item only.
    diff --git a/docs/screens.md b/docs/screens.md
    index 6ae4a355c..9784edae2 100644
    --- a/docs/screens.md
    +++ b/docs/screens.md
    @@ -13,91 +13,111 @@ Column meaning:
       **not a route**: it is shown inside a parent route (KYC steps, status
       sub-pages, disclaimer steps).
     - **Handbook** — the handbook screenshot slot number(s) that document the
    -  screen, or `—` if it is not in the handbook. Each slot is a Visual-Regression
    -  Golden under `test/goldens/screens/`, mapped to its handbook position by
    -  `scripts/assemble-handbook-screenshots.sh`. The handbook covers the
    -  new-user onboarding journey (sign-up → dashboard) plus the settings screens
    -  reachable from it plus the financial-state variants of Buy/Sell/Dashboard/
    -  TransactionHistory and the legal/KYC step widgets: **52 slots in total**,
    -  spread across **20 distinct routed pages**. The table below indexes only
    -  the 20 anchors; the remaining 32 slots are state-variant renderings of
    -  those same screens (a few inline in the 01–26 range — e.g. slot 05 is
    -  `CreateWalletPage` with the seed revealed and slot 09 is `SetupPinPage`
    -  in confirm mode — plus the full 27–52 range: Buy/Sell error banners,
    -  KYC loading/failure, Dashboard with-balance, Legal-Disclaimer steps,
    -  etc.). Slot ↔ Golden mapping in
    +  screen, or `—` if the screen has no Golden baseline. Each slot is a
    +  Visual-Regression Golden under `test/goldens/`, mapped to its handbook
    +  position by `scripts/assemble-handbook-screenshots.sh`. The handbook now
    +  covers **all 278 Golden baselines** — every screen **plus every tested
    +  state variant** (Default / Loading / Error / Snackbar / Dropdown /
    +  Validation / Confirm / Success / Failure …), including the areas that were
    +  previously absent: Support (email capture, tickets, chat), Settings
    +  User-Data and its edit sub-pages, Settings Security, Receive, the BitBox
    +  hardware-pairing flow (`ConnectBitboxPage`, `BitboxAddressRecoveryPage`),
    +  the Buy payment-details tabs, the full KYC detail states (registration
    +  personal/address/tax steps, nationality, e-mail, 2FA, ident, financial
    +  data, link-wallet, signature-unsupported, manual-review, account-merge)
    +  and `debugAuth` (a `kDebugMode`-only dev tool). Each row lists **all** the
    +  slots whose Golden renders that widget — a screen usually has several (its
    +  default plus its state variants), so most cells now carry a range rather
    +  than a single anchor. One Golden is a shared form widget rather than a
    +  screen: slot `268` is `PhoneNumberField` under `test/goldens/widgets/form/`.
    +  Only `WebViewPage` (no active Golden) and `KycPageManager` (the orchestrator
    +  has no Golden of its own — its states are the individual KYC pages) still
    +  carry `—`. Slot ↔ Golden mapping in
       `scripts/assemble-handbook-screenshots.sh`, slot ↔ HTML block in
       `docs/handbook/de/index.html`. See `docs/handbook/README.md`.
     
     | Area | Widget | Route | Path | Handbook |
     |---|---|---|---|---|
    -| Onboarding | `HomePage` | `home` | `/home` | `01` |
    -| Onboarding | `WelcomePage` | `welcome` | `/welcome` | `02`, `03` |
    -| Onboarding | `CreateWalletPage` | `createWallet` | `/createWallet` | `04`, `05` |
    -| Onboarding | `VerifySeedPage` | `verifySeed` | `/verifySeed` | `06` |
    -| Onboarding | `RestoreWalletPage` | `restoreWallet` | `/restoreWallet` | `25` |
    +| Onboarding | `HomePage` | `home` | `/home` | `01`, `11` |
    +| Onboarding | `WelcomePage` | `welcome` | `/welcome` | `02`, `03`, `62` |
    +| Onboarding | `CreateWalletPage` | `createWallet` | `/createWallet` | `04`, `05`, `63` |
    +| Onboarding | `VerifySeedPage` | `verifySeed` | `/verifySeed` | `06`, `34`, `64`, `65`, `66`, `67`, `68` |
    +| Onboarding | `RestoreWalletPage` | `restoreWallet` | `/restoreWallet` | `25`, `32`, `33`, `69`, `70`, `71`, `72` |
     | Onboarding | `OnboardingCompletedPage` | `onboardingCompleted` | `/onboardingComplete` | `07` |
    -| Onboarding | `DebugAuthPage` | `debugAuth` | `/debugAuth` | — |
    -| PIN & lock | `VerifyPinPage` | `pinGate` | `/pinGate` | `17` |
    -| PIN & lock | `SetupPinPage` | `setupPin` | `/setupPin` | `08`, `09` |
    -| PIN & lock | `VerifyPinPage` | `verifyPin` | `/verifyPin` | — |
    -| Dashboard & trading | `DashboardPage` | `dashboard` | `/dashboard` | `11` |
    -| Dashboard & trading | `TransactionHistoryPage` | `transactionHistory` | `/dashboard/transactionHistory` | — |
    -| Dashboard & trading | `BuyPage` | `buy` | `/buy` | — |
    -| Dashboard & trading | `SellPage` | `sell` | `/sell` | — |
    -| Dashboard & trading | `SellBitboxPage` | `sellBitbox` | `/sellBitbox` | — |
    -| Dashboard & trading | `SellBankAccountSelectionPage` | — | — | — |
    -| Dashboard & trading | `ReceivePage` | `receive` | `/receive` | — |
    +| Onboarding | `DebugAuthPage` | `debugAuth` | `/debugAuth` | `262`, `263`, `264`, `265`, `266`, `267` |
    +| PIN & lock | `VerifyPinPage` | `pinGate` | `/pinGate` | `17`, `76`, `77`, `78`, `79`, `80`, `81`, `82`, `83`, `84`, `85`, `88` |
    +| PIN & lock | `SetupPinPage` | `setupPin` | `/setupPin` | `08`, `09`, `10`, `73`, `74`, `75` |
    +| PIN & lock | `VerifyPinPage` | `verifyPin` | `/verifyPin` | `86`, `87` |
    +| Dashboard & trading | `DashboardPage` | `dashboard` | `/dashboard` | `35`, `89`, `90`, `91`, `92`, `93`, `94` |
    +| Dashboard & trading | `TransactionHistoryPage` | `transactionHistory` | `/dashboard/transactionHistory` | `36`, `95`, `96`, `97`, `98`, `99`, `100` |
    +| Dashboard & trading | `BuyPage` | `buy` | `/buy` | `44`, `45`, `46`, `47`, `48`, `103`, `104`, `105`, `106`, `107`, `108`, `109`, `110`, `111`, `112`, `113` |
    +| Dashboard & trading | `BuyPaymentDetailsPage` | `buyPaymentDetails` | `/buyPaymentDetails` | `53`, `114`, `115`, `116`, `117` |
    +| Dashboard & trading | `SellPage` | `sell` | `/sell` | `49`, `50`, `51`, `52`, `118`, `119`, `120`, `123`, `124`, `125` |
    +| Dashboard & trading | `SellBitboxPage` | `sellBitbox` | `/sellBitbox` | `126`, `127`, `128`, `129`, `130`, `131`, `132`, `133`, `134`, `135`, `136` |
    +| Dashboard & trading | `SellBankAccountSelectionPage` | — | — | `121`, `122` |
    +| Dashboard & trading | `ReceivePage` | `receive` | `/receive` | `101`, `102` |
    +| Dashboard & trading | `ConnectBitboxPage` | — | — | `137`, `138`, `139`, `140`, `141`, `142`, `143`, `144`, `145`, `146` |
    +| Dashboard & trading | `BitboxAddressRecoveryPage` | `bitboxAddressRecovery` | `/bitboxAddressRecovery` | `147` |
     | Dashboard & trading | `WebViewPage` | `webView` | `/webView` | — |
    -| Legal | `LegalDisclaimerPage` | `legalDisclaimer` | `/legalDisclaimer` | — |
    -| Legal | `LegalDocumentPage` | `legalDocument` | `/legalDocument` | — |
    +| Legal | `LegalDisclaimerPage` | `legalDisclaimer` | `/legalDisclaimer` | `242` |
    +| Legal | `LegalDocumentPage` | `legalDocument` | `/legalDocument` | `243`, `244`, `245` |
     | Legal | `LegalDocumentPage` | `terms` | `/termsOfUse` | `26` |
    -| Legal | `LegalDisclaimerStep` | — | — | — |
    -| Legal | `LegalDfxStep` | — | — | — |
    -| Legal | `LegalAktionariatStep` | — | — | — |
    -| Legal | `LegalDocumentsStep` | — | — | — |
    -| Settings | `SettingsPage` | `settings` | `/settings` | `12` |
    +| Legal | `LegalDisclaimerStep` | — | — | `27`, `28` |
    +| Legal | `LegalDfxStep` | — | — | `31` |
    +| Legal | `LegalAktionariatStep` | — | — | `30` |
    +| Legal | `LegalDocumentsStep` | — | — | `29` |
    +| Settings | `SettingsPage` | `settings` | `/settings` | `12`, `24`, `211`, `212` |
     | Settings | `SettingsAktionariatDocumentsPage` | `settingsAktionariatDocuments` | `/settings/aktionariatDocuments` | `21` |
     | Settings | `SettingsContactPage` | `settingsContact` | `/settings/contact` | `23` |
    -| Settings | `SettingsCurrenciesPage` | `settingsCurrencies` | `/settings/currencies` | `14` |
    +| Settings | `SettingsCurrenciesPage` | `settingsCurrencies` | `/settings/currencies` | `14`, `215`, `216` |
     | Settings | `SettingsDfxDocumentsPage` | `settingsDfxDocuments` | `/settings/dfxDocuments` | `22` |
    -| Settings | `SettingsLanguagePage` | `settingsLanguages` | `/settings/languages` | `13` |
    +| Settings | `SettingsLanguagePage` | `settingsLanguages` | `/settings/languages` | `13`, `213`, `214` |
     | Settings | `SettingsLegalDocumentsPage` | `settingsLegalDocuments` | `/settings/legalDocuments` | `20` |
    -| Settings | `SettingsNetworkPage` | `settingsNetwork` | `/settings/network` | `15` |
    -| Settings | `SettingsTaxReportPage` | `settingsTaxReport` | `/settings/taxReport` | — |
    -| Settings | `SettingsSeedPage` | `settingsSeed` | `/settings/seed` | `18`, `19` |
    +| Settings | `SettingsNetworkPage` | `settingsNetwork` | `/settings/network` | `15`, `217` |
    +| Settings | `SettingsSecurityPage` | `settingsSecurity` | `/settings/security` | `219`, `220`, `221`, `222`, `223` |
    +| Settings | `SettingsTaxReportPage` | `settingsTaxReport` | `/settings/taxReport` | `42`, `43`, `224`, `225`, `226` |
    +| Settings | `SettingsSeedPage` | `settingsSeed` | `/settings/seed` | `18`, `19`, `218` |
     | Settings | `SettingsWalletAddressPage` | `settingsWalletAddress` | `/settings/walletAddress` | `16` |
    -| Settings | `SettingsUserDataPage` | `settingsUserData` | `/settings/userData` | — |
    -| Settings | `SettingsEditNamePage` | `settingsEditName` | `/settings/userData/editName` | — |
    -| Settings | `SettingsEditAddressPage` | `settingsEditAddress` | `/settings/userData/editAddress` | — |
    -| Settings | `SettingsEditPhoneNumberPage` | `settingsEditPhone` | `/settings/userData/editPhoneNumber` | — |
    -| Settings | `SettingsEditLoadingPage` | — | — | — |
    -| Settings | `SettingsEditPendingPage` | — | — | — |
    -| Settings | `SettingsEditFailurePage` | — | — | — |
    -| Support | `SupportPage` | `support` | `/support` | — |
    -| Support | `SupportTicketsPage` | `supportTickets` | `/support/tickets` | — |
    -| Support | `SupportCreateTicketPage` | `supportCreateTicket` | `/support/create` | — |
    -| Support | `SupportChatPage` | `supportChat` | `/support/chat/:uid` | — |
    +| Settings | `SettingsUserDataPage` | `settingsUserData` | `/settings/userData` | `227`, `228`, `229`, `230`, `231`, `232`, `233`, `234`, `235` |
    +| Settings | `SettingsEditNamePage` | `settingsEditName` | `/settings/userData/editName` | `236` |
    +| Settings | `SettingsEditAddressPage` | `settingsEditAddress` | `/settings/userData/editAddress` | `238` |
    +| Settings | `SettingsEditPhoneNumberPage` | `settingsEditPhone` | `/settings/userData/editPhoneNumber` | `237` |
    +| Settings | `SettingsEditLoadingPage` | — | — | `239` |
    +| Settings | `SettingsEditPendingPage` | — | — | `240` |
    +| Settings | `SettingsEditFailurePage` | — | — | `241` |
    +| Support | `SupportPage` | `support` | `/support` | `246` |
    +| Support | `SupportEmailCapturePage` | `supportEmailCapture` | `/support/email` | `247`, `248`, `249` |
    +| Support | `SupportTicketsPage` | `supportTickets` | `/support/tickets` | `253`, `254`, `255`, `256` |
    +| Support | `SupportCreateTicketPage` | `supportCreateTicket` | `/support/create` | `250`, `251`, `252` |
    +| Support | `SupportChatPage` | `supportChat` | `/support/chat/:uid` | `257`, `258`, `259`, `260`, `261` |
     | KYC | `KycPageManager` | `kyc` | `/kyc` | — |
    -| KYC | `KycRegistrationPage` | — | — | — |
    -| KYC | `KycRegistrationPersonalStep` | — | — | — |
    -| KYC | `KycRegistrationAddressStep` | — | — | — |
    -| KYC | `KycNationalityPage` | — | — | — |
    -| KYC | `KycEmailPage` | — | — | — |
    -| KYC | `KycEmailVerificationPage` | — | — | — |
    -| KYC | `Kyc2faPage` | — | — | — |
    -| KYC | `KycIdentPage` | — | — | — |
    -| KYC | `KycFinancialDataPage` | — | — | — |
    -| KYC | `KycFinancialDataQuestionsPage` | — | — | — |
    -| KYC | `KycFinancialDataLoadingPage` | — | — | — |
    -| KYC | `KycFinancialDataFailurePage` | — | — | — |
    -| KYC | `KycLoadingPage` | — | — | — |
    -| KYC | `KycPendingPage` | — | — | — |
    -| KYC | `KycCompletedPage` | — | — | — |
    -| KYC | `KycFailurePage` | — | — | — |
    -| KYC | `KycAccountMergePage` | — | — | — |
    +| KYC | `KycRegistrationPage` | — | — | `161`, `162`, `166`, `168`, `169`, `170`, `171`, `172` |
    +| KYC | `KycRegistrationPersonalStep` | — | — | `40`, `54`, `163`, `164`, `165` |
    +| KYC | `KycRegistrationAddressStep` | — | — | `41`, `55`, `167` |
    +| KYC | `KycRegistrationTaxStep` | — | — | `56`, `57`, `58`, `59`, `60`, `61`, `61b`, `61c`, `61d`, `61e`, `61f`, `61g`, `61h`, `61i`, `61j`, `61k` |
    +| KYC | `KycNationalityPage` | — | — | `173`, `174`, `175`, `176`, `177`, `178`, `179` |
    +| KYC | `KycEmailPage` | — | — | `37`, `38`, `39`, `148`, `149`, `150` |
    +| KYC | `KycEmailVerificationPage` | — | — | `152`, `153`, `154`, `155` |
    +| KYC | `KycConfirmEmailPage` | — | — | `151` |
    +| KYC | `Kyc2faPage` | — | — | `156`, `157`, `158`, `159`, `160` |
    +| KYC | `KycIdentPage` | — | — | `193`, `194`, `195`, `196` |
    +| KYC | `KycLinkWalletPage` | — | — | `197`, `198`, `199`, `200`, `201`, `202` |
    +| KYC | `KycSignatureUnsupportedPage` | — | — | `203` |
    +| KYC | `KycFinancialDataPage` | — | — | `180`, `182`, `184` |
    +| KYC | `KycFinancialDataQuestionsPage` | — | — | `185`, `186`, `187`, `188`, `189`, `190`, `191`, `192` |
    +| KYC | `KycFinancialDataLoadingPage` | — | — | `181` |
    +| KYC | `KycFinancialDataFailurePage` | — | — | `183` |
    +| KYC | `KycLoadingPage` | — | — | `204` |
    +| KYC | `KycPendingPage` | — | — | `205` |
    +| KYC | `KycCompletedPage` | — | — | `206` |
    +| KYC | `KycFailurePage` | — | — | `207` |
    +| KYC | `KycManualReviewPage` | — | — | `208` |
    +| KYC | `KycAccountMergePage` | — | — | `209` |
    +| KYC | `KycMergeProcessingPage` | — | — | `210` |
    +| Shared widgets | `PhoneNumberField` | — | — | `268` |
     
    -65 screens — 40 routed (`GoRoute`) + 25 non-routed.
    +76 screens — 44 routed (`GoRoute`) + 32 non-routed. The table also carries
    +one shared form-widget baseline (`PhoneNumberField`), which is not a screen.
     
     ## Notes
     
    @@ -109,20 +129,33 @@ Column meaning:
     - **`debugAuth`** is registered only in debug builds (`kDebugMode`).
     - **Shared widgets.** `LegalDocumentPage` backs two routes (`legalDocument`,
       `terms`); `VerifyPinPage` backs two (`pinGate`, `verifyPin`) via different
    -  constructors. Handbook screenshot `17` shows `VerifyPinPage` in its `pinGate`
    -  use; the `verifyPin` app-lock use is not separately documented.
    -- **Handbook numbering.** Each of the 52 handbook slots is a Visual-Regression
    -  Golden under `test/goldens/screens/`, mapped to its handbook position by
    +  constructors. Both uses are now documented and the slots are split between
    +  the rows: `VerifyPinPage`'s `pinGate` use is slots `17`, `76`–`85`, `88`
    +  and its `verifyPin` app-lock use is slots `86`, `87`; `LegalDocumentPage`'s
    +  `terms` route is slot `26` and its generic `legalDocument` route is slots
    +  `243`–`245`. `SetupPinPage` also backs the `settingsChangePin` route
    +  (`/settings/security/changePin`) via a second constructor; that reuse has no
    +  separate Golden and is not given its own row.
    +- **Handbook numbering.** Each of the 278 handbook slots is a Visual-Regression
    +  Golden under `test/goldens/`, mapped to its handbook position by
       `scripts/assemble-handbook-screenshots.sh`. A parallel Tier-3 Maestro flow
    -  (`.maestro/handbook/NN-*.yaml`) covers the same screen for navigation/
    -  tap-routing smoke but does not produce the handbook image. Slot
    -  `10-biometric-prompt` documents the `EnableBiometricBottomSheet` modal — not
    -  a routed screen — so the table has no `10`.
    +  (`.maestro/handbook/NN-*.yaml`) covers navigation/tap-routing smoke for the
    +  routed screens but does not produce the handbook image. Modal sheets are
    +  attached to the screen they belong to: slot `10` (`biometric_prompt_sheet`,
    +  the `EnableBiometricBottomSheet`) sits on `SetupPinPage`, slot `88`
    +  (`forgot_pin_bottom_sheet`) on the `pinGate` `VerifyPinPage`, slots `24` and
    +  `212` (`settings_confirm_logout_wallet_sheet`) on `SettingsPage`, slot `122`
    +  (`sell_add_bank_account_sheet`) on `SellBankAccountSelectionPage`, and slots
    +  `123`–`125` (the sell confirm/executed sheets) on `SellPage`.
     - **`*Page` / `*View` pattern.** Several screens split into a `*Page` widget
       (provides the bloc/cubit) and a `*View` widget (builds the `Scaffold`):
       `CreateWalletView`, `RestoreWalletView`, `SettingsSeedView`, `DebugAuthView`.
       These are not separate screens — each belongs to its `*Page`.
    -- **Modal bottom sheets** are not screens and are out of scope: e.g.
    -  `EnableBiometricBottomSheet`; `ReceivePage` in `isBottomSheet` mode;
    +- **Modal presentation.** Several of these widgets are shown modally rather
    +  than as routed screens, but they still have Golden baselines, so they appear
    +  in the table attached to (or as) their nearest screen: `EnableBiometricBottomSheet`
    +  (slot `10`, on `SetupPinPage`) and the sell / logout / forgot-pin sheets.
       `ConnectBitboxPage` — despite its `Page` suffix it builds no `Scaffold` and is
    -  shown via `showModalBottomSheet`.
    +  shown via `showModalBottomSheet` — has enough Goldens (the full pairing flow,
    +  slots `137`–`146`) to warrant its own row. `ReceivePage` in `isBottomSheet`
    +  mode reuses the routed `ReceivePage` widget (slots `101`, `102`).
    diff --git a/docs/testing.md b/docs/testing.md
    index bbe005b8a..cbc931239 100644
    --- a/docs/testing.md
    +++ b/docs/testing.md
    @@ -44,7 +44,7 @@ Test layout mirrors `lib/`. Stack: [`flutter_test`](https://pub.dev/packages/flu
     | `test/screens//cubit(s)/**` and `test/screens//bloc/**` | Cubit/Bloc state-transition specs (in the activated surface for line-coverage) |
     | `test/screens//**/*_page_test.dart` | `testWidgets` view specs (cover the page, not the cubit logic) |
     | `test/integration/` | Cross-layer Tier-1 specs using `FakeBitboxCredentials` (e.g. `kyc_sign_flow_test.dart`) |
    -| `test/helper/` | Shared test infra: [`pump_app.dart`](../test/helper/pump_app.dart), [`fake_bitbox_credentials.dart`](../test/helper/fake_bitbox_credentials.dart) |
    +| `test/helper/` | Shared test infra: [`pump_app.dart`](../test/helper/pump_app.dart), [`fake_bitbox_credentials.dart`](../test/helper/fake_bitbox_credentials.dart), [`responsive_matrix.dart`](../test/helper/responsive_matrix.dart), [`layout_assertions.dart`](../test/helper/layout_assertions.dart), [`responsive_surface_catalog.dart`](../test/helper/responsive_surface_catalog.dart) |
     | `test/models/` | DTO / marshalling specs (`asset_test.dart`, `balance_test.dart`, `transaction_test.dart`, …) |
     | `test/setup/` | App lifecycle / bootstrap specs (`lifecycle_initializer_test.dart`) |
     | `test/styles/` | Currency / language fixtures (`currency_test.dart`, `language_test.dart`) |
    @@ -111,6 +111,37 @@ testWidgets('shows SnackBar if submitting fails', (tester) async {
     
     See `test/screens/kyc/steps/kyc_email_page_test.dart`.
     
    +### Responsive layout / sticky CTAs (required)
    +
    +**Bug class this gates:** content taller than the sheet/viewport (long locale, large accessibility text, small phone) pushes bottom buttons outside the clip → they paint or look tappable but **receive no hits**. Reproduced on BitBox pairing (`Bestätigen` on iOS).
    +
    +**Production contract**
    +
    +1. Sticky-CTA screens/sheets use [`ScrollableActionsLayout`](../lib/widgets/scrollable_actions_layout.dart): **scrollable body + actions outside the scroll view**.
    +2. Do **not** put a bare `Spacer()` above buttons and hope content fits. `Spacer()` inside the body is also illegal (unbounded main axis → RenderFlex crash); use `centerBody: true` to vertically center content that still fits the viewport.
    +3. Do **not** rely on a fixed fraction of screen height without scroll inside it.
    +4. Host must give the layout a **bounded height** (bottom sheet, `Expanded`, or fixed `SizedBox`). Unbounded height throws a `FlutterError` in every build mode — fail-loud, not a silent broken layout.
    +
    +**Test contract (gates every catalogued surface against this bug class)**
    +
    +| Piece | Role |
    +|---|---|
    +| [`responsive_matrix.dart`](../test/helper/responsive_matrix.dart) | Standard phones (iPhone SE → Pro Max, Android compact → large) × text scales `0.85…3.0` |
    +| [`layout_assertions.dart`](../test/helper/layout_assertions.dart) | `expectNoLayoutOverflow`, `expectFullyTappable` (**real** `tester.tap`, not `onPressed?.call()`; `within` is required and the hit-test path is verified against the target's own render objects) |
    +| [`responsive_surface_catalog.dart`](../test/helper/responsive_surface_catalog.dart) | Living list of surfaces that **must** have a matrix test and a production file that references `ScrollableActionsLayout`; catalog self-test fails if either file is missing or the reference regresses |
    +| Matrix specs (e.g. `connect_bitbox_responsive_matrix_test.dart`) | Full `kFullResponsiveMatrix` × every button-bearing state |
    +
    +The catalog is a living list, not a completeness proof: it does not automatically ensure every sticky-CTA surface in the app is registered. Authors must add new sticky-CTA surfaces to the catalog (with a matrix test); that is a review responsibility.
    +
    +Rules for PRs:
    +
    +- Any new bottom sheet / page with a bottom primary action **must** use `ScrollableActionsLayout` (or equivalent Expanded + scroll + sticky actions).
    +- Register the surface in `kResponsiveSurfaceCatalog` and add a matrix test **in the same PR**.
    +- Interactive widget tests must prefer `tester.tap` / `expectFullyTappable` over calling `onPressed` on the widget.
    +- Worst-case content: longest locale you ship (`de`), longest dynamic strings (e.g. real channel-hash shape), not golden-friendly short stubs alone.
    +
    +Reference implementation: `test/screens/hardware_connect_bitbox/connect_bitbox_responsive_matrix_test.dart` (7 devices × 5 text scales × 5 button states + focused regressions). 29 surfaces are catalogued in [`responsive_surface_catalog.dart`](../test/helper/responsive_surface_catalog.dart) (living list — not a completeness proof; see above).
    +
     ### Service + HTTP
     
     For services that hit the DFX API, swap in `MockClient` from [`http/testing`](https://pub.dev/packages/http) and a `_MockAppStore` that returns it via the `httpClient` getter.
    @@ -385,12 +416,15 @@ flutter analyze
     flutter test --coverage
     ```
     
    -The workflow runs three jobs:
    +The workflow runs four CI jobs:
     
     - **`Analyze & Test`** — the block above, plus a `lcov --extract` step that narrows `coverage/lcov.info` to the activated surface (`lib/packages/**`, `lib/screens/**/cubit(s)/**`, `lib/screens/**/bloc/**`), followed by `lcov --remove '*.g.dart'` to strip generator output (Drift schema mirror) before summarising. The filtered tracefile (`coverage-lcov`) and a one-line summary (`coverage-summary`) are uploaded as artifacts.
    -- **`Coverage Floor Gate`** — downloads `coverage-summary` and fails the build when scoped line/function coverage drops below the integers committed to `.coverage-floor-lines` and `.coverage-floor-functions`. Required status check on `develop` + `main` (ruleset `PRs` / id `11317379`) alongside `Analyze & Test` and `Visual Regression` — a coverage regression blocks the merge. Ratchet protocol is documented in `README.md`.
    +- **`Coverage Floor Gate`** — downloads `coverage-summary` and fails the build when scoped line/function coverage drops below the integers committed to `.coverage-floor-lines` and `.coverage-floor-functions`. Required status check on `develop` + `main` (ruleset `PRs` / id `11317379`) alongside `Analyze & Test` and `Visual Regression` — a coverage regression blocks the merge. Ratchet protocol is documented in `README.md`. The same job also runs `scripts/check-coverage-visibility.sh`, which fails the build when an in-scope file produced no coverage at all: a file that no test loads is absent from `lcov.info` and therefore invisible to the scoped %, so the floor number alone cannot catch it. Files with genuinely no coverable lines (interfaces/ports, barrels) are ratcheted in `.coverage-visibility-allowlist`.
    +- **`Visual Regression`** — validates committed golden baselines on the deterministic self-hosted macOS runner and uploads render diffs on failure.
     - **`BitBox quirks audit`** — runs `bitbox-audit` against the diff and inlines its report into the workflow run summary; uploaded as `bitbox-audit-report`.
     
    +Staging deduplication lives in the separate `Staging CI Router` workflow (`staging-ci-fallback.yaml`). On each staging push it verifies that an open, non-draft, same-repository `staging → develop` PR already points at the pushed SHA. That PR's `synchronize` run owns the canonical required checks. If the PR is missing, draft, stale, or cannot be queried, the router dispatches `RealUnit Build` on `staging` as a fail-safe. The router has its own concurrency group and never cancels a PR run, so cancelled checks cannot be attached to the current promotion-PR head.
    +
     Tier 3 runs separately under `tier3-handbook.yaml` (push to `develop`, manual, or any PR labelled `tier3:full` except PRs targeting `main`). Its only artifact is `handbook-captures`, the per-flow diagnostic recordings — coverage data is owned by `Analyze & Test` instead.
     
     The Tier 0/1 coverage artifacts (`coverage-lcov`, `coverage-summary`) are emitted by the `Analyze & Test` job in `pull-request.yaml` (see [#323](https://github.com/RealUnitCH/app/pull/323)) and consumed by the `Coverage Floor Gate`. The repo holds a [100 % coverage rule](https://github.com/RealUnitCH/app/pull/322) for new code on the activated surface; the committed floor lives in `.coverage-floor-lines` and `.coverage-floor-functions` and is ratcheted upward per PR — drop the threshold only with reviewer sign-off and a written reason (`coverage:lower-floor` label).
    @@ -403,6 +437,9 @@ Some files are deliberately uncovered today because exercising them would change
     |---|---|---|
     | `lib/screens/*/`-Page widgets that call `getIt()` directly (Dashboard, Receive, Settings sub-pages) | Service locator usage inside `build` makes the cubits the page wires up impossible to swap | Move the `BlocProvider(create: (_) => Cubit(getIt()))` lookup up one layer so tests can `BlocProvider.value` a mock |
     | `lib/widgets/chain_asset_icon.dart`, `lib/widgets/image_picker_sheet.dart` | `Image.asset` / `ImagePicker` need a real asset bundle / platform channel | Mock the asset loader / use the platform-interface fake |
    +| `lib/setup/di.dart` `setupEssentials()` — the SQLCipher encryption-key lifecycle (create-on-first-boot / read-existing / "db present but key missing" guard) | It constructs `const SecureStorage()` inline and calls `AppDatabase.getDatabasePath()` + `SharedPreferences.getInstance()`, wiring everything into the global `getIt`; none of the three is injectable, so a test can neither swap the keystore nor observe the key branch without booting the real locator | Parameterise `setupEssentials` on an injectable `SecureStorage` (it already ships `@visibleForTesting SecureStorage.withStorage`) and a directory port, and reset `getIt` per test. This is an injection-point change to boot code that touches the wallet encryption key, so it wants its own reviewed PR rather than riding along here |
    +
    +The boot-time security-flag migration next to it is already covered: `lib/setup/di.dart`'s `_migrateSecurityFlags` was promoted to `@visibleForTesting migrateSecurityFlags(prefs, secureStorage)`, and `test/setup/di_test.dart` drives it against `SharedPreferences.setMockInitialValues` + `SecureStorage.withStorage(mock)` — covering the biometric / lockout-counter / lock-until migrations, the unparseable-timestamp branch, and the `isPinEnabled` cleanup. Only the encryption-key lifecycle in the row above still needs the injection-point work.
     
     Drift-backed repositories under `lib/packages/repository/*` are fully covered: `AppDatabase.forTesting` (`lib/packages/storage/database.dart`) accepts `NativeDatabase.memory()`, and `test/packages/repository/{asset,balance,cache,transaction,wallet}_repository_test.dart` exercise every wrapper. The SharedPreferences-backed `SettingsRepository` and the service-backed `SupportedFiat`/`SupportedLanguage` repositories have specs alongside them too.
     
    diff --git a/ios/fastlane/metadata/de-DE/promotional_text.txt b/ios/fastlane/metadata/de-DE/promotional_text.txt
    index e69de29bb..ee940f1ee 100644
    --- a/ios/fastlane/metadata/de-DE/promotional_text.txt
    +++ b/ios/fastlane/metadata/de-DE/promotional_text.txt
    @@ -0,0 +1 @@
    +Kaufe, halte und verkaufe RealUnit Tokens sicher mit der RealUnit App
    diff --git a/lib/main.dart b/lib/main.dart
    index b99170254..996c5ed0a 100644
    --- a/lib/main.dart
    +++ b/lib/main.dart
    @@ -13,10 +13,8 @@ import 'package:realunit_wallet/screens/settings/bloc/settings_bloc.dart';
     import 'package:realunit_wallet/setup/di.dart';
     import 'package:realunit_wallet/setup/error_handling/realunit_error_view.dart';
     import 'package:realunit_wallet/setup/lifecycle_initializer.dart';
    +import 'package:realunit_wallet/setup/routing/boot_navigation.dart';
     import 'package:realunit_wallet/setup/routing/router_config.dart';
    -import 'package:realunit_wallet/setup/routing/routes/app_routes.dart';
    -import 'package:realunit_wallet/setup/routing/routes/onboarding_routes.dart';
    -import 'package:realunit_wallet/setup/routing/routes/pin_routes.dart';
     import 'package:realunit_wallet/styles/themes.dart';
     
     Future main() async {
    @@ -136,34 +134,28 @@ class _WalletAppState extends State {
     
       void _navigate() {
         final homeState = getIt().state;
    -    final pinState = getIt().state;
    -
    -    if (homeState.isLoadingWallet) return;
    -
    -    String targetRoute;
    -    if (!homeState.softwareTermsAccepted) {
    -      targetRoute = AppRoutes.home;
    -    } else if (!homeState.hasWallet) {
    -      targetRoute = OnboardingRoutes.welcome;
    -    } else if (!homeState.onboardingCompleted) {
    -      targetRoute = OnboardingRoutes.completed;
    -    } else if (!pinState.isPinSetup) {
    -      targetRoute = PinRoutes.setup;
    -    } else if (!pinState.isPinVerified) {
    -      targetRoute = PinRoutes.verify;
    -    } else if (homeState.bitboxAddressRecoveryNeeded) {
    -      // A BitBox wallet was persisted with an empty/invalid address — divert to
    -      // the re-pairing recovery flow instead of loading it into the dashboard
    -      // (which would crash the build via `EthereumAddress.fromHex("")`).
    -      targetRoute = AppRoutes.bitboxAddressRecovery;
    -    } else if (homeState.openWallet == null) {
    -      // Wallet not loaded yet — trigger load and wait for HomeBloc update
    -      _loadWalletIfNeeded();
    -      return;
    -    } else {
    -      targetRoute = AppRoutes.dashboard;
    -    }
    +    final pin = getIt();
    +    final pinState = pin.state;
    +    final current = effectiveLocation(routerConfig.routerDelegate.currentConfiguration);
    +
    +    final action = resolveBootNavigation(
    +      isLoadingWallet: homeState.isLoadingWallet,
    +      softwareTermsAccepted: homeState.softwareTermsAccepted,
    +      hasWallet: homeState.hasWallet,
    +      onboardingCompleted: homeState.onboardingCompleted,
    +      isPinSetup: pinState.isPinSetup,
    +      isPinVerified: pinState.isPinVerified,
    +      bitboxAddressRecoveryNeeded: homeState.bitboxAddressRecoveryNeeded,
    +      walletLoaded: homeState.openWallet != null,
    +      currentLocation: current,
    +      resumeLocation: pin.peekResumeLocation(),
    +    );
     
    -    routerConfig.goNamed(targetRoute);
    +    applyBootNavAction(
    +      action,
    +      routerConfig,
    +      onLoadWallet: _loadWalletIfNeeded,
    +      onClearResume: pin.clearResumeLocation,
    +    );
       }
     }
    diff --git a/lib/packages/hardware_wallet/bitbox_credentials.dart b/lib/packages/hardware_wallet/bitbox_credentials.dart
    index 61f8b8927..f070ab346 100644
    --- a/lib/packages/hardware_wallet/bitbox_credentials.dart
    +++ b/lib/packages/hardware_wallet/bitbox_credentials.dart
    @@ -104,11 +104,12 @@ class BitboxCredentials extends CredentialsWithKnownAddress {
       // call requires an awaitable BLE/USB round-trip. The sync overrides exist
       // only to satisfy the interface and surface as `UnimplementedError` if a
       // future refactor wires a sync caller onto BitBox credentials by accident.
    -  // coverage:ignore-start
    +  // Covered by a `throwsA(isA())` test rather than hidden
    +  // behind `coverage:ignore` — same convention as `_DebugCredentials` in
    +  // `wallet.dart`, whose sync entry points are also unit-tested.
       @override
       MsgSignature signToEcSignature(Uint8List payload, {int? chainId, bool isEIP1559 = false}) =>
           throw UnimplementedError('EvmLedgerCredentials.signToEcSignature');
    -  // coverage:ignore-end
     
       @override
       Future signToSignature(
    @@ -146,14 +147,13 @@ class BitboxCredentials extends CredentialsWithKnownAddress {
     
           var truncChainId = chainId ?? 1;
           // Truncate chainIds wider than 32 bits down to the low byte for the
    -      // EIP-155 parity check — defensive against future >2^32 chainIds. Every
    -      // chain we currently target (Mainnet=1, Polygon=137, Citrea, …) is
    -      // <2^32, so this loop never iterates in test or production today.
    -      // coverage:ignore-start
    +      // EIP-155 parity check — defensive against future >2^32 chainIds. No
    +      // chain we currently target (Mainnet=1, Polygon=137, Citrea, …) reaches
    +      // 2^32, so this loop does not iterate in production today, but it is real
    +      // testable arithmetic and is exercised by a >2^32-chainId unit test.
           while (truncChainId.bitLength > 32) {
             truncChainId >>= 8;
           }
    -      // coverage:ignore-end
     
           final truncTarget = truncChainId * 2 + 35;
     
    @@ -189,12 +189,10 @@ class BitboxCredentials extends CredentialsWithKnownAddress {
       // See the block comment on `signToEcSignature` above — same rationale: the
       // synchronous variant is never used on a Bitbox, every sign path goes
       // through the awaitable `signPersonalMessage` because the native call
    -  // crosses the BLE/USB transport.
    -  // coverage:ignore-start
    +  // crosses the BLE/USB transport. Covered by a unit test, not ignored.
       @override
       Uint8List signPersonalMessageToUint8List(Uint8List payload, {int? chainId}) =>
           throw UnimplementedError('EvmLedgerCredentials.signPersonalMessageToUint8List');
    -  // coverage:ignore-end
     
       Future signTypedDataV4(int chainId, String jsonData) {
         return _synchronizeBoundedSign(() async {
    diff --git a/lib/packages/service/dfx/exceptions/registration_rejected_exception.dart b/lib/packages/service/dfx/exceptions/registration_rejected_exception.dart
    new file mode 100644
    index 000000000..0ac52f272
    --- /dev/null
    +++ b/lib/packages/service/dfx/exceptions/registration_rejected_exception.dart
    @@ -0,0 +1,21 @@
    +import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart';
    +
    +/// A content-level rejection of the registration submit itself.
    +///
    +/// Thrown exclusively by `RealUnitRegistrationService` for a non-auth 4xx
    +/// response of `register/complete`, so the UI can safely attribute the server
    +/// reason to the user's entries ("check your entries and submit again").
    +/// Auth (401/403) and rate-limit (429) responses, 5xx, transport errors, and
    +/// failures of any other call stay plain [ApiException]s — for those, that
    +/// instruction would be wrong.
    +class RegistrationRejectedException extends ApiException {
    +  const RegistrationRejectedException({
    +    super.statusCode,
    +    required super.code,
    +    required super.message,
    +  });
    +
    +  @override
    +  String toString() =>
    +      'RegistrationRejectedException: $message (code: $code, statusCode: $statusCode)';
    +}
    diff --git a/lib/packages/service/dfx/models/legal/dto/real_unit_legal_agreement_status_dto.dart b/lib/packages/service/dfx/models/legal/dto/real_unit_legal_agreement_status_dto.dart
    new file mode 100644
    index 000000000..ffad5c982
    --- /dev/null
    +++ b/lib/packages/service/dfx/models/legal/dto/real_unit_legal_agreement_status_dto.dart
    @@ -0,0 +1,28 @@
    +import 'package:realunit_wallet/packages/service/dfx/models/legal/real_unit_legal_agreement.dart';
    +
    +/// Per-agreement acceptance status as reported by `GET /v1/realunit/legal`.
    +/// `accepted` is the API's own verdict (current version vs. accepted version) —
    +/// the app renders it 1:1 and never re-derives it from the version fields. The
    +/// version fields are carried for completeness/display only.
    +class RealUnitLegalAgreementStatusDto {
    +  final RealUnitLegalAgreement agreement;
    +  final String currentVersion;
    +  final String? acceptedVersion;
    +  final bool accepted;
    +
    +  const RealUnitLegalAgreementStatusDto({
    +    required this.agreement,
    +    required this.currentVersion,
    +    this.acceptedVersion,
    +    required this.accepted,
    +  });
    +
    +  factory RealUnitLegalAgreementStatusDto.fromJson(Map json) {
    +    return RealUnitLegalAgreementStatusDto(
    +      agreement: RealUnitLegalAgreementExtension.fromValue(json['agreement'] as String),
    +      currentVersion: json['currentVersion'] as String,
    +      acceptedVersion: json['acceptedVersion'] as String?,
    +      accepted: json['accepted'] as bool,
    +    );
    +  }
    +}
    diff --git a/lib/packages/service/dfx/models/legal/dto/real_unit_legal_info_dto.dart b/lib/packages/service/dfx/models/legal/dto/real_unit_legal_info_dto.dart
    new file mode 100644
    index 000000000..6cdb2bc45
    --- /dev/null
    +++ b/lib/packages/service/dfx/models/legal/dto/real_unit_legal_info_dto.dart
    @@ -0,0 +1,31 @@
    +import 'package:realunit_wallet/packages/service/dfx/models/legal/dto/real_unit_legal_agreement_status_dto.dart';
    +import 'package:realunit_wallet/packages/service/dfx/models/legal/real_unit_legal_agreement.dart';
    +
    +/// Server-side legal-acceptance state for the current wallet, returned by both
    +/// `GET` and `PUT /v1/realunit/legal`. `allAccepted` is the authoritative gate
    +/// for the legal disclaimer in `KycCubit` — see CONTRIBUTING.md "API as
    +/// Decision Authority".
    +class RealUnitLegalInfoDto {
    +  final List agreements;
    +  final bool allAccepted;
    +
    +  const RealUnitLegalInfoDto({
    +    required this.agreements,
    +    required this.allAccepted,
    +  });
    +
    +  /// The agreements the user has not yet accepted at the current version.
    +  /// Forwarded verbatim to `PUT /v1/realunit/legal` so the app records exactly
    +  /// what the server still needs.
    +  List get outstandingAgreements =>
    +      agreements.where((a) => !a.accepted).map((a) => a.agreement).toList();
    +
    +  factory RealUnitLegalInfoDto.fromJson(Map json) {
    +    return RealUnitLegalInfoDto(
    +      agreements: (json['agreements'] as List)
    +          .map((e) => RealUnitLegalAgreementStatusDto.fromJson(e as Map))
    +          .toList(),
    +      allAccepted: json['allAccepted'] as bool,
    +    );
    +  }
    +}
    diff --git a/lib/packages/service/dfx/models/legal/real_unit_legal_agreement.dart b/lib/packages/service/dfx/models/legal/real_unit_legal_agreement.dart
    new file mode 100644
    index 000000000..e25d79acd
    --- /dev/null
    +++ b/lib/packages/service/dfx/models/legal/real_unit_legal_agreement.dart
    @@ -0,0 +1,51 @@
    +/// Mirror of the server-side `RealUnitLegalAgreement` enum on
    +/// `GET`/`PUT /v1/realunit/legal`. The API owns which agreements exist and
    +/// which are still outstanding for a wallet; the app forwards them 1:1 — see
    +/// CONTRIBUTING.md "API as Decision Authority". Mirrors the `.value` /
    +/// `fromValue` shape of `KycStepName` in `models/kyc/kyc_level.dart`.
    +enum RealUnitLegalAgreement {
    +  residenceConfirmation,
    +  taxDomicileSelfCertification,
    +  realUnitPrivacyPolicy,
    +  realUnitRegistrationAgreement,
    +  aktionariatTermsOfService,
    +  dfxTermsAndConditions,
    +}
    +
    +extension RealUnitLegalAgreementExtension on RealUnitLegalAgreement {
    +  String get value {
    +    switch (this) {
    +      case RealUnitLegalAgreement.residenceConfirmation:
    +        return 'ResidenceConfirmation';
    +      case RealUnitLegalAgreement.taxDomicileSelfCertification:
    +        return 'TaxDomicileSelfCertification';
    +      case RealUnitLegalAgreement.realUnitPrivacyPolicy:
    +        return 'RealUnitPrivacyPolicy';
    +      case RealUnitLegalAgreement.realUnitRegistrationAgreement:
    +        return 'RealUnitRegistrationAgreement';
    +      case RealUnitLegalAgreement.aktionariatTermsOfService:
    +        return 'AktionariatTermsOfService';
    +      case RealUnitLegalAgreement.dfxTermsAndConditions:
    +        return 'DfxTermsAndConditions';
    +    }
    +  }
    +
    +  static RealUnitLegalAgreement fromValue(String value) {
    +    switch (value) {
    +      case 'ResidenceConfirmation':
    +        return RealUnitLegalAgreement.residenceConfirmation;
    +      case 'TaxDomicileSelfCertification':
    +        return RealUnitLegalAgreement.taxDomicileSelfCertification;
    +      case 'RealUnitPrivacyPolicy':
    +        return RealUnitLegalAgreement.realUnitPrivacyPolicy;
    +      case 'RealUnitRegistrationAgreement':
    +        return RealUnitLegalAgreement.realUnitRegistrationAgreement;
    +      case 'AktionariatTermsOfService':
    +        return RealUnitLegalAgreement.aktionariatTermsOfService;
    +      case 'DfxTermsAndConditions':
    +        return RealUnitLegalAgreement.dfxTermsAndConditions;
    +      default:
    +        throw ArgumentError('Unknown RealUnitLegalAgreement value: $value');
    +    }
    +  }
    +}
    diff --git a/lib/packages/service/dfx/models/payment/payment_info_error.dart b/lib/packages/service/dfx/models/payment/payment_info_error.dart
    index 810b513bd..42409bbe6 100644
    --- a/lib/packages/service/dfx/models/payment/payment_info_error.dart
    +++ b/lib/packages/service/dfx/models/payment/payment_info_error.dart
    @@ -2,6 +2,7 @@ enum PaymentInfoError {
       registrationRequired,
       kycRequired,
       primaryEmailRequired,
    +  primaryEmailNotConfirmed,
       minAmountNotMet,
       bitboxDisconnected,
       priceSourceUnavailable,
    diff --git a/lib/packages/service/dfx/models/wallet/real_unit_registration_info_dto.dart b/lib/packages/service/dfx/models/wallet/real_unit_registration_info_dto.dart
    index b1fc600b3..1a83a9a16 100644
    --- a/lib/packages/service/dfx/models/wallet/real_unit_registration_info_dto.dart
    +++ b/lib/packages/service/dfx/models/wallet/real_unit_registration_info_dto.dart
    @@ -11,32 +11,47 @@ class RealUnitRegistrationInfoDto {
       final RealUnitUserDataDto? realUnitUserDataDto;
     
       /// Whether the API has confirmed the account e-mail address for this wallet.
    -  /// Nullable on purpose: absent on pre-rollout backends and for grandfathered
    -  /// accounts. `KycCubit` treats `null` as "no confirmation gate" and proceeds
    -  /// as before — only an explicit `false` routes to the confirm step. See
    -  /// CONTRIBUTING.md "API as Decision Authority" (legacy backend tolerance).
    +  /// Nullable on purpose: `null` means a pre-rollout backend (the field does
    +  /// not exist yet) or no registration at all — grandfathered accounts (in
    +  /// existence before the rollout) report an explicit `true`, never `null`.
    +  /// `KycCubit` treats `null` as "no confirmation gate" and proceeds as before —
    +  /// only an explicit `false` routes to the confirm step. See CONTRIBUTING.md
    +  /// "API as Decision Authority" (legacy backend tolerance).
       final bool? emailConfirmed;
     
       /// Timestamp of the confirmation, when the API reports one. Not consumed for
       /// routing (that is [emailConfirmed] alone); carried for completeness/display.
       final DateTime? confirmedDate;
     
    +  /// Whether this wallet's RealUnit registration is parked in manual review —
    +  /// the Aktionariat forward failed and it awaits a manual re-forward by staff.
    +  /// `true` only for the current wallet (`alreadyRegistered`); `false`/absent
    +  /// otherwise. Nullable on purpose: `null` means a pre-rollout backend (the
    +  /// field does not exist yet). `KycCubit` treats `null`/`false` as "no
    +  /// manual-review gate" and proceeds as before — only an explicit `true` routes
    +  /// to the review screen. See CONTRIBUTING.md "API as Decision Authority"
    +  /// (legacy backend tolerance).
    +  final bool? manualReview;
    +
       RealUnitRegistrationInfoDto({
         required this.state,
         this.realUnitUserDataDto,
         this.emailConfirmed,
         this.confirmedDate,
    +    this.manualReview,
       });
     
       factory RealUnitRegistrationInfoDto.fromJson(Map json) {
    -    final confirmedDateRaw = json['confirmedDate'] as String?;
         return RealUnitRegistrationInfoDto(
           state: RealUnitRegistrationState.fromJson(json['state'] as String),
           realUnitUserDataDto: json['userData'] != null
               ? RealUnitUserDataDto.fromJson(json['userData'] as Map)
               : null,
           emailConfirmed: json['emailConfirmed'] as bool?,
    -      confirmedDate: confirmedDateRaw != null ? DateTime.tryParse(confirmedDateRaw) : null,
    +      confirmedDate: json['confirmedDate'] != null
    +          ? DateTime.parse(json['confirmedDate'] as String)
    +          : null,
    +      manualReview: json['manualReview'] as bool?,
         );
       }
     }
    diff --git a/lib/packages/service/dfx/real_unit_legal_service.dart b/lib/packages/service/dfx/real_unit_legal_service.dart
    new file mode 100644
    index 000000000..ff8fc8979
    --- /dev/null
    +++ b/lib/packages/service/dfx/real_unit_legal_service.dart
    @@ -0,0 +1,54 @@
    +import 'dart:convert';
    +
    +import 'package:realunit_wallet/packages/config/api_config.dart';
    +import 'package:realunit_wallet/packages/service/dfx/dfx_auth_service.dart';
    +import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart';
    +import 'package:realunit_wallet/packages/service/dfx/models/legal/dto/real_unit_legal_info_dto.dart';
    +import 'package:realunit_wallet/packages/service/dfx/models/legal/real_unit_legal_agreement.dart';
    +
    +class RealUnitLegalService extends DFXAuthService {
    +  RealUnitLegalService(super.appStore, super.walletService);
    +
    +  static const _legalPath = '/v1/realunit/legal';
    +
    +  /// Fetches the API-side legal-acceptance state for the current wallet. The
    +  /// backend owns which agreements exist, their current version, and whether
    +  /// each is already accepted; the disclaimer gate in `KycCubit` renders
    +  /// `allAccepted` 1:1 instead of a local session flag — see CONTRIBUTING.md
    +  /// "API as Decision Authority".
    +  Future getLegalInfo() async {
    +    final uri = buildUri(host, _legalPath);
    +    final response = await authenticatedGet(
    +      uri,
    +      headers: {'Content-Type': 'application/json'},
    +    );
    +
    +    if (response.statusCode != 200) {
    +      final errorJson = jsonDecode(response.body) as Map;
    +      throw ApiException.fromJson(errorJson, httpStatusCode: response.statusCode);
    +    }
    +
    +    return RealUnitLegalInfoDto.fromJson(jsonDecode(response.body));
    +  }
    +
    +  /// Durably records acceptance of [agreements] for the current wallet. The
    +  /// backend stamps the current version and returns the refreshed info; the
    +  /// call is idempotent.
    +  Future acceptLegal(List agreements) async {
    +    final uri = buildUri(host, _legalPath);
    +    final response = await authenticatedPut(
    +      uri,
    +      headers: {'Content-Type': 'application/json'},
    +      body: jsonEncode({
    +        'agreements': agreements.map((a) => a.value).toList(),
    +      }),
    +    );
    +
    +    if (response.statusCode != 200 && response.statusCode != 201) {
    +      final errorJson = jsonDecode(response.body) as Map;
    +      throw ApiException.fromJson(errorJson, httpStatusCode: response.statusCode);
    +    }
    +
    +    return RealUnitLegalInfoDto.fromJson(jsonDecode(response.body));
    +  }
    +}
    diff --git a/lib/packages/service/dfx/real_unit_registration_service.dart b/lib/packages/service/dfx/real_unit_registration_service.dart
    index 3e3d1139a..ef0bd1400 100644
    --- a/lib/packages/service/dfx/real_unit_registration_service.dart
    +++ b/lib/packages/service/dfx/real_unit_registration_service.dart
    @@ -1,9 +1,9 @@
     import 'dart:convert';
     
    -import 'package:intl/intl.dart';
     import 'package:realunit_wallet/packages/config/api_config.dart';
     import 'package:realunit_wallet/packages/service/dfx/dfx_auth_service.dart';
     import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart';
    +import 'package:realunit_wallet/packages/service/dfx/exceptions/registration_rejected_exception.dart';
     import 'package:realunit_wallet/packages/service/dfx/models/registration/dto/real_unit_registration_email_request_dto.dart';
     import 'package:realunit_wallet/packages/service/dfx/models/registration/dto/real_unit_registration_email_response_dto.dart';
     import 'package:realunit_wallet/packages/service/dfx/models/registration/dto/real_unit_registration_register_wallet_request_dto.dart';
    @@ -22,6 +22,7 @@ class RealUnitRegistrationService extends DFXAuthService {
       RealUnitRegistrationService(super.appStore, super.walletService);
     
       static const _registrationInfoPath = '/v1/realunit/registration';
    +  static const _registerDatePath = '/v1/realunit/register/date';
       static const _registerEmailPath = '/v1/realunit/register/email';
       static const _registerCompletionPath = '/v1/realunit/register/complete';
       static const _registerWalletPath = '/v1/realunit/register/wallet';
    @@ -48,6 +49,35 @@ class RealUnitRegistrationService extends DFXAuthService {
         return RealUnitRegistrationInfoDto.fromJson(jsonDecode(response.body));
       }
     
    +  /// Fetches the registration date to sign from the API. `registrationDate` is
    +  /// part of the EIP-712 signed registration envelope and the backend validates
    +  /// it against its own clock, so the value must be the server's date — never
    +  /// the device's local date. A device in a timezone ahead of UTC would
    +  /// otherwise sign tomorrow's date and be rejected by the backend. Fetched
    +  /// immediately before signing so it is always fresh.
    +  Future getRegistrationDate() async {
    +    final uri = buildUri(host, _registerDatePath);
    +    final response = await authenticatedGet(
    +      uri,
    +      headers: {'Content-Type': 'application/json'},
    +    );
    +
    +    if (response.statusCode != 200) {
    +      final errorJson = jsonDecode(response.body) as Map;
    +      throw ApiException.fromJson(errorJson, httpStatusCode: response.statusCode);
    +    }
    +
    +    final date = (jsonDecode(response.body) as Map)['date'];
    +    if (date is! String) {
    +      throw ApiException(
    +        statusCode: response.statusCode,
    +        code: 'UNKNOWN',
    +        message: 'Missing registration date in API response',
    +      );
    +    }
    +    return date;
    +  }
    +
       /// registers an email on the wallet. Should always be called first when registering
       Future registerEmail(String email) async {
         final uri = buildUri(host, _registerEmailPath);
    @@ -73,19 +103,23 @@ class RealUnitRegistrationService extends DFXAuthService {
     
       /// registers a wallet and and adds the wallet to the new user
       Future completeRegistration(Registration registration) async {
    +    // Fetch the server-provided signing date BEFORE unlocking the wallet: it
    +    // only needs the JWT, not the private key, so a slow or failing fetch must
    +    // not hold the decrypted key resident (and a failure aborts before unlock).
    +    final registrationDate = await getRegistrationDate();
         // EIP-712 registration signature requires the private key — promote the
         // view-wallet (if any) to a fully unlocked SoftwareWallet first, then
         // lock back down in the finally so a throw mid-sequence doesn't leave the
         // key resident.
         await walletService.ensureCurrentWalletUnlocked();
         try {
    -      return await _completeRegistration(registration);
    +      return await _completeRegistration(registration, registrationDate);
         } finally {
           await walletService.lockCurrentWallet();
         }
       }
     
    -  Future _completeRegistration(Registration registration) async {
    +  Future _completeRegistration(Registration registration, String registrationDate) async {
         final credentials = appStore.wallet.primaryAccount.primaryAddress;
         // BitBox firmware rejects non-ASCII bytes in EIP-712 string fields.
         // Transliterate everything that goes into the signed envelope AND the
    @@ -103,7 +137,6 @@ class RealUnitRegistrationService extends DFXAuthService {
         );
         final addressPostalCode = toBitboxSafeAscii(registration.addressPostalCode);
         final addressCity = toBitboxSafeAscii(registration.addressCity);
    -    final registrationDate = DateFormat('yyyy-MM-dd').format(DateTime.now());
         final signature = await Eip712Signer.signRegistration(
           credentials: credentials,
           chainId: _chainId,
    @@ -164,7 +197,27 @@ class RealUnitRegistrationService extends DFXAuthService {
     
         if (response.statusCode != 201 && response.statusCode != 202) {
           final errorJson = jsonDecode(response.body) as Map;
    -      throw ApiException.fromJson(errorJson, httpStatusCode: response.statusCode);
    +      final error = ApiException.fromJson(errorJson, httpStatusCode: response.statusCode);
    +      final status = error.statusCode;
    +      // A non-auth 4xx of register/complete itself is a content-level
    +      // rejection of exactly this submit — typed so the UI can attribute it
    +      // ("check your entries"). The runtimeType check keeps code-specific
    +      // subclasses (KYC_LEVEL_REQUIRED, REGISTRATION_REQUIRED) intact; auth
    +      // (401/403) and rate-limit (429) responses plus 5xx stay plain.
    +      if (error.runtimeType == ApiException &&
    +          status != null &&
    +          status >= 400 &&
    +          status < 500 &&
    +          status != 401 &&
    +          status != 403 &&
    +          status != 429) {
    +        throw RegistrationRejectedException(
    +          statusCode: status,
    +          code: error.code,
    +          message: error.message,
    +        );
    +      }
    +      throw error;
         }
     
         final responseDto = RealUnitRegistrationResponseDto.fromJson(jsonDecode(response.body));
    @@ -175,17 +228,18 @@ class RealUnitRegistrationService extends DFXAuthService {
       Future registerWallet(
         RealUnitUserDataDto userData,
       ) async {
    +    // Fetch the signed date before unlocking — see completeRegistration.
    +    final registrationDate = await getRegistrationDate();
         await walletService.ensureCurrentWalletUnlocked();
         try {
    -      return await _registerWallet(userData);
    +      return await _registerWallet(userData, registrationDate);
         } finally {
           await walletService.lockCurrentWallet();
         }
       }
     
    -  Future _registerWallet(RealUnitUserDataDto userData) async {
    +  Future _registerWallet(RealUnitUserDataDto userData, String registrationDate) async {
         final credentials = appStore.wallet.primaryAccount.primaryAddress;
    -    final registrationDate = DateFormat('yyyy-MM-dd').format(DateTime.now());
         // Same ASCII guard as completeRegistration — see comment there.
         final signature = await Eip712Signer.signRegistration(
           credentials: credentials,
    diff --git a/lib/packages/utils/fuck_firebase.dart b/lib/packages/utils/fuck_firebase.dart
    index f3fc54d7e..15280e1bd 100644
    --- a/lib/packages/utils/fuck_firebase.dart
    +++ b/lib/packages/utils/fuck_firebase.dart
    @@ -1,6 +1,8 @@
     import 'dart:io';
     
    -import 'package:path_provider/path_provider.dart';
    +import 'package:flutter/foundation.dart';
    +import 'package:realunit_wallet/packages/io/documents_directory_port.dart';
    +import 'package:realunit_wallet/packages/io/path_provider_adapter.dart';
     
     // https://github.com/juliansteenbakker/mobile_scanner/issues/553
     // Basically we got a fucking telemetry because google
    @@ -9,12 +11,34 @@ import 'package:path_provider/path_provider.dart';
     // Now instead of a request we get an error:
     // android.database.sqlite.SQLiteDatabaseCorruptException: file is not a database (code 26 SQLITE_NOTADB)
     // Which is fine, fuck google.
    -Future fuckFirebase() async {
    -  if (Platform.isAndroid) {
    -    final dir = await getApplicationDocumentsDirectory();
    -    final path = dir.parent.path;
    -    final file = File('$path/databases/com.google.android.datatransport.events')
    -      ..createSync(recursive: true);
    -    await file.writeAsString('Fake');
    -  }
    +//
    +// The Android-only branch (the platform gate + the path_provider round-trip) is
    +// a 1:1 platform seam and carries the same `coverage:ignore` treatment as the
    +// DocumentsDirectoryPort adapter forwarders. The file write itself is factored
    +// into [writeFirebaseTelemetryStub] so it stays unit-tested on the host without
    +// an Android platform gate.
    +//
    +// @no-integration-test: the Android-only branch is a file-system side effect
    +// behind a platform gate + path_provider round-trip that cannot run in a host
    +// test; the covered seam is [writeFirebaseTelemetryStub].
    +Future fuckFirebase({
    +  DocumentsDirectoryPort documentsDirectory = const PathProviderAdapter(),
    +}) async {
    +  if (!Platform.isAndroid) return;
    +  // coverage:ignore-start
    +  final dir = await documentsDirectory.getApplicationDocumentsDirectory();
    +  await writeFirebaseTelemetryStub(dir.parent.path);
    +  // coverage:ignore-end
    +}
    +
    +/// Writes the sentinel "not a database" file under `/databases/`
    +/// that makes Android's MLKit / Firebase telemetry DB open fail with
    +/// `SQLITE_NOTADB` instead of phoning home (see the block comment on
    +/// [fuckFirebase]). Factored out of [fuckFirebase] so the file-writing logic is
    +/// unit-testable on the host without the Android platform gate.
    +@visibleForTesting
    +Future writeFirebaseTelemetryStub(String appDataRoot) async {
    +  final file = File('$appDataRoot/databases/com.google.android.datatransport.events')
    +    ..createSync(recursive: true);
    +  await file.writeAsString('Fake');
     }
    diff --git a/lib/screens/buy/buy_page.dart b/lib/screens/buy/buy_page.dart
    index 9a6b09b77..2d827bf91 100644
    --- a/lib/screens/buy/buy_page.dart
    +++ b/lib/screens/buy/buy_page.dart
    @@ -9,6 +9,7 @@ import 'package:realunit_wallet/screens/buy/widgets/payment_action_button.dart';
     import 'package:realunit_wallet/screens/buy/widgets/payment_converter.dart';
     import 'package:realunit_wallet/screens/buy/widgets/payment_information.dart';
     import 'package:realunit_wallet/setup/di.dart';
    +import 'package:realunit_wallet/widgets/scrollable_actions_layout.dart';
     
     class BuyPage extends StatelessWidget {
       const BuyPage({super.key});
    @@ -65,36 +66,30 @@ class _BuyViewState extends State {
             builder: (context, state) {
               return GestureDetector(
                 onTap: () => FocusManager.instance.primaryFocus?.unfocus(),
    -            child: LayoutBuilder(
    -              builder: (context, constraint) {
    -                return SingleChildScrollView(
    -                  child: ConstrainedBox(
    -                    constraints: BoxConstraints(minHeight: constraint.maxHeight),
    -                    child: IntrinsicHeight(
    -                      child: Padding(
    -                        padding: const .symmetric(horizontal: 20.0),
    -                        child: SafeArea(
    -                          child: Column(
    -                            crossAxisAlignment: .start,
    -                            children: [
    -                              PaymentConverter(
    -                                amountController: _amountController,
    -                                resultController: _resultController,
    -                              ),
    -                              const SizedBox(height: 32),
    -                              const PaymentInformation(),
    -                              const Spacer(),
    -                              PaymentActionButton(
    -                                amountController: _amountController,
    -                              ),
    -                            ],
    -                          ),
    -                        ),
    +            child: Padding(
    +              padding: const .symmetric(horizontal: 20.0),
    +              child: SafeArea(
    +                child: ScrollableActionsLayout(
    +                  centerBody: false,
    +                  body: Column(
    +                    crossAxisAlignment: .start,
    +                    mainAxisAlignment: .start,
    +                    children: [
    +                      PaymentConverter(
    +                        amountController: _amountController,
    +                        resultController: _resultController,
                           ),
    -                    ),
    +                      const SizedBox(height: 32),
    +                      const PaymentInformation(),
    +                    ],
                       ),
    -                );
    -              },
    +                  actions: [
    +                    PaymentActionButton(
    +                      amountController: _amountController,
    +                    ),
    +                  ],
    +                ),
    +              ),
                 ),
               );
             },
    diff --git a/lib/screens/buy/cubits/buy_confirm/buy_confirm_cubit.dart b/lib/screens/buy/cubits/buy_confirm/buy_confirm_cubit.dart
    index 2e49b4c72..8f1c51d15 100644
    --- a/lib/screens/buy/cubits/buy_confirm/buy_confirm_cubit.dart
    +++ b/lib/screens/buy/cubits/buy_confirm/buy_confirm_cubit.dart
    @@ -12,6 +12,11 @@ part 'buy_confirm_state.dart';
     // message text — the message is Aktionariat's and may change.
     const String _errorCodeAmountTooLow = 'AmountTooLow';
     
    +// Backend error code for a confirm rejected upstream because the buyer's
    +// primary email is missing or not yet confirmed at the share register
    +// (HTTP 400). Dispatch on the code, not the message text.
    +const String _errorCodePrimaryEmailRequired = 'PrimaryEmailRequired';
    +
     class BuyConfirmCubit extends Cubit {
       final RealUnitBuyPaymentInfoService _buyPaymentInfoService;
     
    @@ -36,6 +41,8 @@ class BuyConfirmCubit extends Cubit {
               ? BuyConfirmError.aktionariat
               : e.code == _errorCodeAmountTooLow
               ? BuyConfirmError.amountTooLow
    +          : e.code == _errorCodePrimaryEmailRequired
    +          ? BuyConfirmError.primaryEmailRequired
               : BuyConfirmError.unknown;
           emit(BuyConfirmFailure(error));
         } catch (e) {
    diff --git a/lib/screens/buy/cubits/buy_confirm/buy_confirm_state.dart b/lib/screens/buy/cubits/buy_confirm/buy_confirm_state.dart
    index daa893ca7..1c36f33b0 100644
    --- a/lib/screens/buy/cubits/buy_confirm/buy_confirm_state.dart
    +++ b/lib/screens/buy/cubits/buy_confirm/buy_confirm_state.dart
    @@ -1,6 +1,6 @@
     part of 'buy_confirm_cubit.dart';
     
    -enum BuyConfirmError { aktionariat, amountTooLow, unknown }
    +enum BuyConfirmError { aktionariat, amountTooLow, primaryEmailRequired, unknown }
     
     abstract class BuyConfirmState extends Equatable {
       const BuyConfirmState();
    diff --git a/lib/screens/buy/cubits/buy_payment_info/buy_payment_info_cubit.dart b/lib/screens/buy/cubits/buy_payment_info/buy_payment_info_cubit.dart
    index 4ed997056..0eb8891f2 100644
    --- a/lib/screens/buy/cubits/buy_payment_info/buy_payment_info_cubit.dart
    +++ b/lib/screens/buy/cubits/buy_payment_info/buy_payment_info_cubit.dart
    @@ -27,6 +27,12 @@ const String _quoteErrorAmountTooLow = 'AmountTooLow';
     // instead of reacting to a post-submit 400 and losing the flow.
     const String _quoteErrorPrimaryEmailRequired = 'PrimaryEmailRequired';
     
    +// Backend QuoteError code for the "buyer has a primary email on record but
    +// has not yet confirmed it" case. This is distinct from having no email at
    +// all, so it routes to the existing email-confirmation flow (via the KYC
    +// page), not to the email-capture flow.
    +const String _quoteErrorPrimaryEmailNotConfirmed = 'PrimaryEmailNotConfirmed';
    +
     class BuyPaymentInfoCubit extends Cubit {
       final RealUnitBuyPaymentInfoService _buyPaymentInfoService;
       CancelableOperation? _completer;
    @@ -72,6 +78,9 @@ class BuyPaymentInfoCubit extends Cubit {
             if (paymentInfo.error == _quoteErrorPrimaryEmailRequired) {
               return const BuyPaymentInfoFailure(PaymentInfoError.primaryEmailRequired);
             }
    +        if (paymentInfo.error == _quoteErrorPrimaryEmailNotConfirmed) {
    +          return const BuyPaymentInfoFailure(PaymentInfoError.primaryEmailNotConfirmed);
    +        }
             return const BuyPaymentInfoFailure(PaymentInfoError.unknown);
           }
           return BuyPaymentInfoSuccess(paymentInfo);
    diff --git a/lib/screens/buy/widgets/buy_confirm_button.dart b/lib/screens/buy/widgets/buy_confirm_button.dart
    index c4481b653..f3702ff13 100644
    --- a/lib/screens/buy/widgets/buy_confirm_button.dart
    +++ b/lib/screens/buy/widgets/buy_confirm_button.dart
    @@ -65,6 +65,7 @@ class BuyConfirmButtonView extends StatelessWidget {
               final text = switch (state.error) {
                 BuyConfirmError.aktionariat => S.of(context).buyPaymentConfirmFailedAktionariat,
                 BuyConfirmError.amountTooLow => S.of(context).buyPaymentConfirmFailedAmountTooLow,
    +            BuyConfirmError.primaryEmailRequired => S.of(context).buyPaymentConfirmFailedAktionariat,
                 BuyConfirmError.unknown => S.of(context).buyPaymentConfirmFailed,
               };
               ScaffoldMessenger.of(context).showSnackBar(
    diff --git a/lib/screens/buy/widgets/payment_action_button.dart b/lib/screens/buy/widgets/payment_action_button.dart
    index 34d7c6b94..f334f9cf3 100644
    --- a/lib/screens/buy/widgets/payment_action_button.dart
    +++ b/lib/screens/buy/widgets/payment_action_button.dart
    @@ -119,6 +119,27 @@ class PaymentActionButton extends StatelessWidget {
                   ),
                 );
               }
    +          if (paymentState.error == PaymentInfoError.primaryEmailNotConfirmed) {
    +            // The buyer has a primary email on record but has not yet confirmed it.
    +            // The KYC page auto-routes to its confirm-email step in this case, so
    +            // route there (no `extra`) instead of to email capture, then re-fetch
    +            // the quote so a now-confirmed email surfaces the binding-buy CTA.
    +            return Padding(
    +              padding: const EdgeInsets.symmetric(vertical: 20),
    +              child: AppFilledButton(
    +                onPressed: () async {
    +                  await context.pushNamed(AppRoutes.kyc);
    +                  if (context.mounted) {
    +                    context.read().getPaymentInfo(
    +                      amount: amountController.text,
    +                      currency: context.read().state.currency,
    +                    );
    +                  }
    +                },
    +                label: S.of(context).next,
    +              ),
    +            );
    +          }
               if (paymentState.error == PaymentInfoError.bitboxDisconnected) {
                 return Padding(
                   padding: const EdgeInsets.symmetric(vertical: 20),
    diff --git a/lib/screens/create_wallet/create_wallet_view.dart b/lib/screens/create_wallet/create_wallet_view.dart
    index 9f6d1bd4c..9cf176fa2 100644
    --- a/lib/screens/create_wallet/create_wallet_view.dart
    +++ b/lib/screens/create_wallet/create_wallet_view.dart
    @@ -9,6 +9,7 @@ import 'package:realunit_wallet/screens/create_wallet/bloc/create_wallet_cubit.d
     import 'package:realunit_wallet/setup/routing/routes/onboarding_routes.dart';
     import 'package:realunit_wallet/styles/colors.dart';
     import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart';
    +import 'package:realunit_wallet/widgets/scrollable_actions_layout.dart';
     import 'package:realunit_wallet/widgets/seed_blur_card.dart';
     
     class CreateWalletView extends StatefulWidget {
    @@ -35,60 +36,53 @@ class _CreateWalletViewState extends State {
             child: BlocBuilder(
               builder: (context, state) {
                 if (state.wallet != null) {
    -              return LayoutBuilder(
    -                builder: (context, constraint) {
    -                  return SingleChildScrollView(
    -                    child: ConstrainedBox(
    -                      constraints: BoxConstraints(minHeight: constraint.maxHeight),
    -                      child: IntrinsicHeight(
    -                        child: Column(
    -                          mainAxisAlignment: .start,
    -                          spacing: 28.0,
    -                          children: [
    -                            SvgPicture.asset(
    -                              'assets/images/illustrations/backup_wallet.svg',
    -                              width: 124,
    -                            ),
    -                            Column(
    -                              spacing: 8.0,
    -                              children: [
    -                                Text(
    -                                  S.of(context).createWalletTitle,
    -                                  style: Theme.of(context).textTheme.headlineSmall?.copyWith(
    -                                    fontWeight: .bold,
    -                                  ),
    -                                ),
    -                                Text(
    -                                  S.of(context).createWalletSubtitle,
    -                                  textAlign: .center,
    -                                  style: Theme.of(context).textTheme.bodyMedium?.copyWith(
    -                                    color: RealUnitColors.neutral500,
    -                                  ),
    -                                ),
    -                              ],
    -                            ),
    -                            SeedBlurCard(
    -                              seed: state.wallet!.seed,
    -                              onTap: context.read().toggleShowSeed,
    -                              blur: state.hideSeed,
    -                            ),
    -                            const Spacer(),
    -                            Padding(
    -                              padding: const .symmetric(vertical: 20),
    -                              child: AppFilledButton(
    -                                label: S.of(context).createWalletConfirm,
    -                                onPressed: () => context.pushNamed(
    -                                  OnboardingRoutes.verifySeed,
    -                                  extra: state.wallet,
    -                                ),
    -                              ),
    -                            ),
    -                          ],
    +              return ScrollableActionsLayout(
    +                centerBody: false,
    +                body: Column(
    +                  mainAxisAlignment: .start,
    +                  spacing: 28.0,
    +                  children: [
    +                    SvgPicture.asset(
    +                      'assets/images/illustrations/backup_wallet.svg',
    +                      width: 124,
    +                    ),
    +                    Column(
    +                      spacing: 8.0,
    +                      children: [
    +                        Text(
    +                          S.of(context).createWalletTitle,
    +                          style: Theme.of(context).textTheme.headlineSmall?.copyWith(
    +                            fontWeight: .bold,
    +                          ),
    +                        ),
    +                        Text(
    +                          S.of(context).createWalletSubtitle,
    +                          textAlign: .center,
    +                          style: Theme.of(context).textTheme.bodyMedium?.copyWith(
    +                            color: RealUnitColors.neutral500,
    +                          ),
                             ),
    +                      ],
    +                    ),
    +                    SeedBlurCard(
    +                      seed: state.wallet!.seed,
    +                      onTap: context.read().toggleShowSeed,
    +                      blur: state.hideSeed,
    +                    ),
    +                  ],
    +                ),
    +                actions: [
    +                  Padding(
    +                    padding: const .symmetric(vertical: 20),
    +                    child: AppFilledButton(
    +                      label: S.of(context).createWalletConfirm,
    +                      onPressed: () => context.pushNamed(
    +                        OnboardingRoutes.verifySeed,
    +                        extra: state.wallet,
                           ),
                         ),
    -                  );
    -                },
    +                  ),
    +                ],
                   );
                 }
                 return const Center(
    diff --git a/lib/screens/dashboard/dashboard_page.dart b/lib/screens/dashboard/dashboard_page.dart
    index e7c069339..1fc5af521 100644
    --- a/lib/screens/dashboard/dashboard_page.dart
    +++ b/lib/screens/dashboard/dashboard_page.dart
    @@ -24,6 +24,7 @@ import 'package:realunit_wallet/setup/routing/routes/settings_routes.dart';
     import 'package:realunit_wallet/styles/colors.dart';
     import 'package:realunit_wallet/styles/icons.dart';
     import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart';
    +import 'package:realunit_wallet/widgets/scrollable_actions_layout.dart';
     
     class DashboardPage extends StatelessWidget {
       const DashboardPage({super.key});
    @@ -146,31 +147,35 @@ class DashboardView extends StatelessWidget {
                             ),
                           )
                         else
    -                      Padding(
    +                      ScrollableActionsLayout(
                             padding: const .symmetric(
                               horizontal: 20.0,
                               vertical: 24.0,
                             ),
    -                        child: Column(
    +                        centerBody: true,
    +                        body: Column(
                               crossAxisAlignment: .stretch,
    +                          spacing: context.watch().state.isEmpty
    +                              ? 0.0
    +                              : 24.0,
                               children: [
                                 const DashboardPendingTransactionsView(),
    -                            const Spacer(),
                                 SvgPicture.asset(
                                   'assets/images/illustrations/realu_token.svg',
                                   width: 165,
                                   height: 165,
                                 ),
    -                            const Spacer(),
    -                            Padding(
    -                              padding: const .symmetric(vertical: 20),
    -                              child: AppFilledButton(
    -                                onPressed: () => context.pushNamed(AppRoutes.buy),
    -                                label: S.of(context).buyRealUnit,
    -                              ),
    -                            ),
                               ],
                             ),
    +                        actions: [
    +                          Padding(
    +                            padding: const .symmetric(vertical: 20),
    +                            child: AppFilledButton(
    +                              onPressed: () => context.pushNamed(AppRoutes.buy),
    +                              label: S.of(context).buyRealUnit,
    +                            ),
    +                          ),
    +                        ],
                           ),
                       ],
                     ),
    diff --git a/lib/screens/dashboard/widgets/pending_transaction_row.dart b/lib/screens/dashboard/widgets/pending_transaction_row.dart
    index 04910e782..6fb45f789 100644
    --- a/lib/screens/dashboard/widgets/pending_transaction_row.dart
    +++ b/lib/screens/dashboard/widgets/pending_transaction_row.dart
    @@ -56,24 +56,31 @@ class PendingTransactionRow extends StatelessWidget {
                   ],
                 ),
               ),
    -          Column(
    -            crossAxisAlignment: .end,
    -            children: [
    -              if (transaction.inputAmount != null && transaction.inputAsset != null)
    -                Text(
    -                  '${_formatAmount(transaction.inputAmount!)} ${transaction.inputAsset}',
    -                  style: Theme.of(context).textTheme.bodyLarge?.copyWith(
    -                    fontWeight: .w600,
    +          Flexible(
    +            child: Column(
    +              crossAxisAlignment: .end,
    +              children: [
    +                if (transaction.inputAmount != null && transaction.inputAsset != null)
    +                  Text(
    +                    '${_formatAmount(transaction.inputAmount!)} ${transaction.inputAsset}',
    +                    textAlign: .end,
    +                    maxLines: 2,
    +                    style: Theme.of(context).textTheme.bodyLarge?.copyWith(
    +                      fontWeight: .w600,
    +                    ),
                       ),
    -                ),
    -              if (transaction.date != null)
    -                Text(
    -                  DateFormat('MMM dd, yyyy').format(transaction.date!),
    -                  style: Theme.of(context).textTheme.bodySmall?.copyWith(
    -                    color: RealUnitColors.neutral500,
    +                if (transaction.date != null)
    +                  Text(
    +                    DateFormat('MMM dd, yyyy').format(transaction.date!),
    +                    textAlign: .end,
    +                    maxLines: 1,
    +                    overflow: .ellipsis,
    +                    style: Theme.of(context).textTheme.bodySmall?.copyWith(
    +                      color: RealUnitColors.neutral500,
    +                    ),
                       ),
    -                ),
    -            ],
    +              ],
    +            ),
               ),
             ],
           ),
    diff --git a/lib/screens/dashboard/widgets/sections/dashboard_price_widget.dart b/lib/screens/dashboard/widgets/sections/dashboard_price_widget.dart
    index 859ceab8b..c76118f76 100644
    --- a/lib/screens/dashboard/widgets/sections/dashboard_price_widget.dart
    +++ b/lib/screens/dashboard/widgets/sections/dashboard_price_widget.dart
    @@ -58,11 +58,15 @@ class DashboardPriceWidgetView extends StatelessWidget {
                   children: [
                     Row(
                       children: [
    -                    Text(
    -                      S.of(context).realunitStockprice,
    -                      style: Theme.of(context).textTheme.bodyLarge?.copyWith(
    -                        fontWeight: .w600,
    -                        color: RealUnitColors.basic.black,
    +                    Expanded(
    +                      child: Text(
    +                        S.of(context).realunitStockprice,
    +                        style: Theme.of(context).textTheme.bodyLarge?.copyWith(
    +                          fontWeight: .w600,
    +                          color: RealUnitColors.basic.black,
    +                        ),
    +                        maxLines: 1,
    +                        overflow: .ellipsis,
                           ),
                         ),
                       ],
    @@ -78,9 +82,13 @@ class DashboardPriceWidgetView extends StatelessWidget {
                             );
                           },
                         ),
    -                    Text(
    -                      price == BigInt.zero ? '--.--' : formatFixed(price, 2, trimZeros: false),
    -                      style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: .w600),
    +                    Flexible(
    +                      child: Text(
    +                        price == BigInt.zero ? '--.--' : formatFixed(price, 2, trimZeros: false),
    +                        style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: .w600),
    +                        maxLines: 1,
    +                        overflow: .ellipsis,
    +                      ),
                         ),
                       ],
                     ),
    diff --git a/lib/screens/hardware_connect_bitbox/connect_bitbox_view.dart b/lib/screens/hardware_connect_bitbox/connect_bitbox_view.dart
    index 7f6731330..b7be518a3 100644
    --- a/lib/screens/hardware_connect_bitbox/connect_bitbox_view.dart
    +++ b/lib/screens/hardware_connect_bitbox/connect_bitbox_view.dart
    @@ -23,184 +23,193 @@ class ConnectBitboxView extends StatelessWidget {
       final VoidCallback? onCancel;
     
       @override
    -  Widget build(BuildContext context) => SafeArea(
    -    child: SizedBox(
    -      height: MediaQuery.of(context).size.height * 0.8,
    -      width: .infinity,
    -      child: BlocListener(
    -        listener: (context, state) async {
    -          if (state is BitboxFinishSetup) {
    -            onFinish(state.wallet);
    -          }
    -          if (state is BitboxNotConnected) {
    -            if (context.mounted) {
    -              ScaffoldMessenger.of(context).showSnackBar(
    -                SnackBar(
    -                  content: Text(S.of(context).connectBitboxFailed),
    -                ),
    -              );
    +  Widget build(BuildContext context) {
    +    // Fix the sheet height at 90% of screen height (clamped by the host, e.g.
    +    // SafeArea) — this is not a cap, the sheet is always exactly this tall.
    +    // The body still scrolls inside [ConnectContent] / [ScrollableActionsLayout]
    +    // so long DE copy + accessibility text never pushes CTAs off-screen
    +    // (pairing regression).
    +    final maxHeight = MediaQuery.sizeOf(context).height * 0.9;
    +
    +    return SafeArea(
    +      child: SizedBox(
    +        height: maxHeight,
    +        width: .infinity,
    +        child: BlocListener(
    +          listener: (context, state) async {
    +            if (state is BitboxFinishSetup) {
    +              onFinish(state.wallet);
                 }
    -          }
    -        },
    -        child: Padding(
    -          padding: const .symmetric(horizontal: 20.0),
    -          child: Column(
    -            children: [
    -              Handlebars.horizontal(context, margin: const .only(top: 5), width: 36),
    -              Expanded(
    -                child: BlocBuilder(
    -                  builder: (context, state) => switch (state) {
    -                    BitboxConnecting() => ConnectContent(
    -                      title: S.of(context).connectBitboxTitle,
    -                      imagePath: 'assets/images/illustrations/bitbox_connect.svg',
    -                      child: Column(
    -                        spacing: 40,
    -                        children: [
    -                          Text(
    -                            S.of(context).connectBitboxConnecting,
    -                            textAlign: .center,
    -                            style: Theme.of(context).textTheme.bodyMedium?.copyWith(
    -                              color: RealUnitColors.neutral500,
    -                            ),
    +            if (state is BitboxNotConnected) {
    +              if (context.mounted) {
    +                ScaffoldMessenger.of(context).showSnackBar(
    +                  SnackBar(
    +                    content: Text(S.of(context).connectBitboxFailed),
    +                  ),
    +                );
    +              }
    +            }
    +          },
    +          child: Padding(
    +            padding: const .symmetric(horizontal: 20.0),
    +            child: Column(
    +              children: [
    +                Handlebars.horizontal(context, margin: const .only(top: 5), width: 36),
    +                Expanded(
    +                  child: BlocBuilder(
    +                    builder: (context, state) => switch (state) {
    +                  BitboxConnecting() => ConnectContent(
    +                    title: S.of(context).connectBitboxTitle,
    +                    imagePath: 'assets/images/illustrations/bitbox_connect.svg',
    +                    child: Column(
    +                      spacing: 40,
    +                      children: [
    +                        Text(
    +                          S.of(context).connectBitboxConnecting,
    +                          textAlign: .center,
    +                          style: Theme.of(context).textTheme.bodyMedium?.copyWith(
    +                            color: RealUnitColors.neutral500,
                               ),
    -                          const CupertinoActivityIndicator(),
    -                        ],
    -                      ),
    +                        ),
    +                        const CupertinoActivityIndicator(),
    +                      ],
                         ),
    -                    BitboxCheckHash(:final channelHash) => ConnectContent(
    -                      title: S.of(context).connectBitboxTitle,
    -                      imagePath: 'assets/images/illustrations/bitbox_connected.svg',
    -                      onConfirm: context.read().confirmPairing,
    -                      onCancel: onCancel ?? context.pop,
    -                      child: Column(
    -                        spacing: 16,
    -                        children: [
    -                          Text(
    -                            S.of(context).connectBitboxCheckPairingCode,
    -                            textAlign: .center,
    -                            style: Theme.of(context).textTheme.bodyMedium?.copyWith(
    -                              color: RealUnitColors.neutral500,
    -                            ),
    +                  ),
    +                  BitboxCheckHash(:final channelHash) => ConnectContent(
    +                    title: S.of(context).connectBitboxTitle,
    +                    imagePath: 'assets/images/illustrations/bitbox_connected.svg',
    +                    onConfirm: context.read().confirmPairing,
    +                    onCancel: onCancel ?? context.pop,
    +                    child: Column(
    +                      spacing: 16,
    +                      children: [
    +                        Text(
    +                          S.of(context).connectBitboxCheckPairingCode,
    +                          textAlign: .center,
    +                          style: Theme.of(context).textTheme.bodyMedium?.copyWith(
    +                            color: RealUnitColors.neutral500,
                               ),
    -                          Container(
    -                            padding: const .all(16),
    -                            decoration: BoxDecoration(
    -                              color: RealUnitColors.realUnitBlue.withValues(alpha: 0.1),
    -                              borderRadius: .circular(12),
    -                            ),
    -                            child: Text(
    -                              channelHash,
    -                              textAlign: .center,
    -                              style: Theme.of(context).textTheme.headlineSmall,
    -                            ),
    +                        ),
    +                        Container(
    +                          padding: const .all(16),
    +                          decoration: BoxDecoration(
    +                            color: RealUnitColors.realUnitBlue.withValues(alpha: 0.1),
    +                            borderRadius: .circular(12),
                               ),
    -                          Text(
    -                            S.of(context).connectBitboxSignInHint,
    +                          child: Text(
    +                            channelHash,
                                 textAlign: .center,
    -                            style: Theme.of(context).textTheme.bodySmall?.copyWith(
    -                              color: RealUnitColors.neutral500,
    -                            ),
    +                            style: Theme.of(context).textTheme.headlineSmall,
                               ),
    -                        ],
    -                      ),
    -                    ),
    -                    BitboxPairing() => ConnectContent(
    -                      title: S.of(context).connectBitboxTitle,
    -                      imagePath: 'assets/images/illustrations/bitbox_connected.svg',
    -                      child: Column(
    -                        spacing: 40,
    -                        children: [
    -                          Text(
    -                            S.of(context).connectBitboxCheckPairingCode,
    -                            textAlign: .center,
    -                            style: Theme.of(context).textTheme.bodyMedium?.copyWith(
    -                              color: RealUnitColors.neutral500,
    -                            ),
    +                        ),
    +                        Text(
    +                          S.of(context).connectBitboxSignInHint,
    +                          textAlign: .center,
    +                          style: Theme.of(context).textTheme.bodySmall?.copyWith(
    +                            color: RealUnitColors.neutral500,
                               ),
    -                          const CupertinoActivityIndicator(),
    -                        ],
    -                      ),
    -                    ),
    -                    BitboxNotInitialized() => ConnectContent(
    -                      title: S.of(context).connectBitboxNotInitializedTitle,
    -                      imagePath: 'assets/images/illustrations/bitbox_connect.svg',
    -                      onConfirm: () => context.read().recheckDeviceStatus(),
    -                      onCancel: onCancel ?? context.pop,
    -                      confirmLabel: S.of(context).retry,
    -                      child: Text(
    -                        S.of(context).connectBitboxNotInitialized,
    -                        textAlign: .center,
    -                        style: Theme.of(context).textTheme.bodyMedium?.copyWith(
    -                          color: RealUnitColors.neutral500,
                             ),
    -                      ),
    +                      ],
                         ),
    -                    BitboxCapturingSignature() => ConnectContent(
    -                      title: S.of(context).connectBitboxSignatureCapturingTitle,
    -                      imagePath: 'assets/images/illustrations/bitbox_connected.svg',
    -                      child: Column(
    -                        spacing: 40,
    -                        children: [
    -                          Text(
    -                            S.of(context).connectBitboxSignatureCapturing,
    -                            textAlign: .center,
    -                            style: Theme.of(context).textTheme.bodyMedium?.copyWith(
    -                              color: RealUnitColors.neutral500,
    -                            ),
    +                  ),
    +                  BitboxPairing() => ConnectContent(
    +                    title: S.of(context).connectBitboxTitle,
    +                    imagePath: 'assets/images/illustrations/bitbox_connected.svg',
    +                    child: Column(
    +                      spacing: 40,
    +                      children: [
    +                        Text(
    +                          S.of(context).connectBitboxCheckPairingCode,
    +                          textAlign: .center,
    +                          style: Theme.of(context).textTheme.bodyMedium?.copyWith(
    +                            color: RealUnitColors.neutral500,
                               ),
    -                          const CupertinoActivityIndicator(),
    -                        ],
    +                        ),
    +                        const CupertinoActivityIndicator(),
    +                      ],
    +                    ),
    +                  ),
    +                  BitboxNotInitialized() => ConnectContent(
    +                    title: S.of(context).connectBitboxNotInitializedTitle,
    +                    imagePath: 'assets/images/illustrations/bitbox_connect.svg',
    +                    onConfirm: () => context.read().recheckDeviceStatus(),
    +                    onCancel: onCancel ?? context.pop,
    +                    confirmLabel: S.of(context).retry,
    +                    child: Text(
    +                      S.of(context).connectBitboxNotInitialized,
    +                      textAlign: .center,
    +                      style: Theme.of(context).textTheme.bodyMedium?.copyWith(
    +                        color: RealUnitColors.neutral500,
                           ),
                         ),
    -                    BitboxSignatureFailed() => ConnectContent(
    -                      title: S.of(context).connectBitboxSignatureFailedTitle,
    -                      imagePath: 'assets/images/illustrations/bitbox_connected.svg',
    -                      onConfirm: () => context.read().retrySignatureCapture(),
    -                      onCancel: () =>
    -                          context.read().continueWithoutSignature(),
    -                      confirmLabel: S.of(context).retry,
    -                      cancelLabel: S.of(context).continueAnyway,
    -                      child: Text(
    -                        S.of(context).connectBitboxSignatureFailed,
    -                        textAlign: .center,
    -                        style: Theme.of(context).textTheme.bodyMedium?.copyWith(
    -                          color: RealUnitColors.neutral500,
    +                  ),
    +                  BitboxCapturingSignature() => ConnectContent(
    +                    title: S.of(context).connectBitboxSignatureCapturingTitle,
    +                    imagePath: 'assets/images/illustrations/bitbox_connected.svg',
    +                    child: Column(
    +                      spacing: 40,
    +                      children: [
    +                        Text(
    +                          S.of(context).connectBitboxSignatureCapturing,
    +                          textAlign: .center,
    +                          style: Theme.of(context).textTheme.bodyMedium?.copyWith(
    +                            color: RealUnitColors.neutral500,
    +                          ),
                             ),
    +                        const CupertinoActivityIndicator(),
    +                      ],
    +                    ),
    +                  ),
    +                  BitboxSignatureFailed() => ConnectContent(
    +                    title: S.of(context).connectBitboxSignatureFailedTitle,
    +                    imagePath: 'assets/images/illustrations/bitbox_connected.svg',
    +                    onConfirm: () => context.read().retrySignatureCapture(),
    +                    onCancel: () =>
    +                        context.read().continueWithoutSignature(),
    +                    confirmLabel: S.of(context).retry,
    +                    cancelLabel: S.of(context).continueAnyway,
    +                    child: Text(
    +                      S.of(context).connectBitboxSignatureFailed,
    +                      textAlign: .center,
    +                      style: Theme.of(context).textTheme.bodyMedium?.copyWith(
    +                        color: RealUnitColors.neutral500,
                           ),
                         ),
    -                    BitboxConnected() => ConnectContent(
    -                      title: S.of(context).connected,
    -                      imagePath: 'assets/images/illustrations/bitbox_connected.svg',
    -                      onConfirm: () => context.read().finishSetup(),
    -                      child: Text(
    -                        S.of(context).connectedBitboxContent,
    -                        textAlign: .center,
    -                        style: Theme.of(context).textTheme.bodyMedium?.copyWith(
    -                          color: RealUnitColors.neutral500,
    -                        ),
    +                  ),
    +                  BitboxConnected() => ConnectContent(
    +                    title: S.of(context).connected,
    +                    imagePath: 'assets/images/illustrations/bitbox_connected.svg',
    +                    onConfirm: () => context.read().finishSetup(),
    +                    child: Text(
    +                      S.of(context).connectedBitboxContent,
    +                      textAlign: .center,
    +                      style: Theme.of(context).textTheme.bodyMedium?.copyWith(
    +                        color: RealUnitColors.neutral500,
                           ),
                         ),
    -                    _ => ConnectContent(
    -                      title: S.of(context).connectBitboxTitle,
    -                      imagePath: 'assets/images/illustrations/bitbox_connect.svg',
    -                      onCancel: onCancel ?? context.pop,
    -                      child: Text(
    -                        DeviceInfo.instance.isIOS
    -                            ? S.of(context).connectBitboxContentIos
    -                            : S.of(context).connectBitboxContent,
    -                        textAlign: .center,
    -                        style: Theme.of(context).textTheme.bodyMedium?.copyWith(
    -                          color: RealUnitColors.neutral500,
    -                        ),
    +                  ),
    +                  _ => ConnectContent(
    +                    title: S.of(context).connectBitboxTitle,
    +                    imagePath: 'assets/images/illustrations/bitbox_connect.svg',
    +                    onCancel: onCancel ?? context.pop,
    +                    child: Text(
    +                      DeviceInfo.instance.isIOS
    +                          ? S.of(context).connectBitboxContentIos
    +                          : S.of(context).connectBitboxContent,
    +                      textAlign: .center,
    +                      style: Theme.of(context).textTheme.bodyMedium?.copyWith(
    +                        color: RealUnitColors.neutral500,
                           ),
                         ),
    -                  },
    +                  ),
    +                    },
    +                  ),
                     ),
    -              ),
    -            ],
    +              ],
    +            ),
               ),
             ),
           ),
    -    ),
    -  );
    +    );
    +  }
     }
    diff --git a/lib/screens/hardware_connect_bitbox/widgets/connect_content.dart b/lib/screens/hardware_connect_bitbox/widgets/connect_content.dart
    index 60a471efa..ae12bdefb 100644
    --- a/lib/screens/hardware_connect_bitbox/widgets/connect_content.dart
    +++ b/lib/screens/hardware_connect_bitbox/widgets/connect_content.dart
    @@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
     import 'package:flutter_svg/svg.dart';
     import 'package:realunit_wallet/generated/i18n.dart';
     import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart';
    +import 'package:realunit_wallet/widgets/scrollable_actions_layout.dart';
     
     class ConnectContent extends StatelessWidget {
       final String imagePath;
    @@ -27,41 +28,46 @@ class ConnectContent extends StatelessWidget {
       });
     
       @override
    -  Widget build(BuildContext context) => Container(
    -    width: .infinity,
    -    padding: const .symmetric(vertical: 20),
    -    child: Column(
    -      spacing: 10,
    -      children: [
    -        Padding(
    -          padding: const .only(top: 40, bottom: 20),
    -          child: SvgPicture.asset(imagePath),
    +  Widget build(BuildContext context) {
    +    final actions = [
    +      if (onConfirm != null)
    +        AppFilledButton(
    +          onPressed: onConfirm,
    +          label: confirmLabel ?? S.of(context).confirm,
             ),
    -        Text(
    -          title,
    -          textAlign: .center,
    -          style: Theme.of(context).textTheme.headlineMedium,
    +      if (onCancel != null)
    +        AppFilledButton(
    +          variant: .secondary,
    +          fullWidth: false,
    +          onPressed: onCancel,
    +          label: cancelLabel ?? S.of(context).cancel,
             ),
    -        child,
    -        const Spacer(),
    -        Column(
    -          spacing: 12,
    -          children: [
    -            if (onConfirm != null)
    -              AppFilledButton(
    -                onPressed: onConfirm,
    -                label: confirmLabel ?? S.of(context).confirm,
    -              ),
    -            if (onCancel != null)
    -              AppFilledButton(
    -                variant: .secondary,
    -                fullWidth: false,
    -                onPressed: onCancel,
    -                label: cancelLabel ?? S.of(context).cancel,
    -              ),
    -          ],
    -        ),
    -      ],
    -    ),
    -  );
    +    ];
    +
    +    return ScrollableActionsLayout(
    +      padding: const .symmetric(vertical: 20),
    +      actionsSpacing: 12,
    +      body: Column(
    +        spacing: 10,
    +        children: [
    +          // Cap illustration height so accessibility text scale still leaves
    +          // room for copy + actions in the scroll viewport.
    +          Padding(
    +            padding: const .only(top: 24, bottom: 12),
    +            child: ConstrainedBox(
    +              constraints: const BoxConstraints(maxHeight: 160),
    +              child: SvgPicture.asset(imagePath, fit: BoxFit.contain),
    +            ),
    +          ),
    +          Text(
    +            title,
    +            textAlign: .center,
    +            style: Theme.of(context).textTheme.headlineMedium,
    +          ),
    +          child,
    +        ],
    +      ),
    +      actions: actions,
    +    );
    +  }
     }
    diff --git a/lib/screens/kyc/cubits/kyc/kyc_cubit.dart b/lib/screens/kyc/cubits/kyc/kyc_cubit.dart
    index 2d393df9e..89e3c50e0 100644
    --- a/lib/screens/kyc/cubits/kyc/kyc_cubit.dart
    +++ b/lib/screens/kyc/cubits/kyc/kyc_cubit.dart
    @@ -8,9 +8,11 @@ import 'package:realunit_wallet/packages/service/dfx/dfx_kyc_service.dart';
     import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart';
     import 'package:realunit_wallet/packages/service/dfx/models/kyc/dto/kyc_level_dto.dart';
     import 'package:realunit_wallet/packages/service/dfx/models/kyc/kyc_level.dart';
    +import 'package:realunit_wallet/packages/service/dfx/models/legal/real_unit_legal_agreement.dart';
     import 'package:realunit_wallet/packages/service/dfx/models/user/dto/real_unit_user_data_dto.dart';
     import 'package:realunit_wallet/packages/service/dfx/models/user/dto/user_dto.dart';
     import 'package:realunit_wallet/packages/service/dfx/models/wallet/real_unit_registration_state.dart';
    +import 'package:realunit_wallet/packages/service/dfx/real_unit_legal_service.dart';
     import 'package:realunit_wallet/packages/service/dfx/real_unit_registration_service.dart';
     import 'package:realunit_wallet/packages/wallet/wallet.dart';
     
    @@ -21,12 +23,25 @@ class KycCubit extends Cubit {
     
       final DfxKycService _kycService;
       final RealUnitRegistrationService _registrationService;
    +  final RealUnitLegalService _legalService;
       final AppStore _appStore;
     
    +  /// Offline fallback ONLY. The legal disclaimer gate is server-driven via
    +  /// `_legalService.getLegalInfo()`; this per-session flag is used solely when
    +  /// that endpoint is unreachable (pre-rollout backend or outage) so the
    +  /// disclaimer is not re-shown for the rest of the session — see the gate in
    +  /// `_runCheckKyc` and CONTRIBUTING.md "API as Decision Authority" (legacy
    +  /// tolerance).
       bool _legalDisclaimerAccepted = false;
       bool _emailRegistrationAttempted = false;
       String? _kycContext;
     
    +  /// Agreements the server last reported as outstanding, remembered so
    +  /// `acceptLegalDisclaimer` records exactly those on `PUT /v1/realunit/legal`.
    +  /// `null` until the first successful `getLegalInfo()` — the accept path then
    +  /// falls back to all agreements.
    +  List? _outstandingLegalAgreements;
    +
       // `Future.timeout` does not cancel the underlying work, so a late HTTP
       // response from an earlier call can still resume and emit state after a
       // retry. Each `checkKyc()` captures its own generation; the run body and
    @@ -37,9 +52,11 @@ class KycCubit extends Cubit {
       KycCubit(
         DfxKycService kycService,
         RealUnitRegistrationService registrationService,
    +    RealUnitLegalService legalService,
         AppStore appStore,
       ) : _kycService = kycService,
           _registrationService = registrationService,
    +      _legalService = legalService,
           _appStore = appStore,
           super(const KycInitial());
     
    @@ -94,11 +111,26 @@ class KycCubit extends Cubit {
             return;
           }
     
    -      // Disclaimer is a local session gate that must precede every sensitive
    -      // call. It does not encode business routing — the API does — it just
    -      // enforces a per-session ceremony on this device before the cubit
    -      // forwards anything that requires a signed user identity.
    -      if (!_legalDisclaimerAccepted) {
    +      // The legal disclaimer gate is server-driven: the API is the single
    +      // source of truth for whether this user still has outstanding agreements
    +      // to accept — see CONTRIBUTING.md "API as Decision Authority". A 404 means
    +      // the `/legal` endpoint is not deployed yet (pre-rollout backend): fall
    +      // back to the local per-session `_legalDisclaimerAccepted` ceremony
    +      // (legacy tolerance, mirrors the `emailConfirmed` gate below). Any OTHER
    +      // error must fail closed on this compliance gate — rethrow so it surfaces
    +      // as a KycFailure instead of silently letting the user past the disclaimer.
    +      bool showDisclaimer;
    +      try {
    +        final legalInfo = await _legalService.getLegalInfo();
    +        if (isClosed || generation != _runGeneration) return;
    +        showDisclaimer = !legalInfo.allAccepted;
    +        _outstandingLegalAgreements = legalInfo.outstandingAgreements;
    +      } on ApiException catch (e) {
    +        if (isClosed || generation != _runGeneration) return;
    +        if (e.statusCode != 404) rethrow;
    +        showDisclaimer = !_legalDisclaimerAccepted;
    +      }
    +      if (showDisclaimer) {
             emit(const KycSuccess(currentStep: KycStep.legalDisclaimer));
             return;
           }
    @@ -127,11 +159,23 @@ class KycCubit extends Cubit {
     
           switch (registrationInfo.state) {
             case RealUnitRegistrationState.alreadyRegistered:
    +          // The API owns the manual-review gate. When the Aktionariat forward
    +          // failed and staff must re-forward the registration, the backend
    +          // reports `manualReview == true` and we park the user on a dedicated
    +          // waiting screen. Takes precedence over the e-mail gate below. `false`
    +          // and `null` (pre-rollout backend) both mean "no gate" — proceed
    +          // exactly as before. Purely additive, API-driven; see CONTRIBUTING.md
    +          // "API as Decision Authority" (legacy tolerance).
    +          if (registrationInfo.manualReview == true) {
    +            emit(const KycManualReview());
    +            return;
    +          }
               // The API owns the confirmation gate. When it reports the account
    -          // e-mail is not yet confirmed, route to the confirm step. `null`
    -          // (pre-rollout backend / grandfathered account) and `true` both mean
    -          // "no gate" — proceed exactly as before. Purely additive, API-driven;
    -          // see CONTRIBUTING.md "API as Decision Authority" (legacy tolerance).
    +          // e-mail is not yet confirmed, route to the confirm step. `true`
    +          // (confirmed, or a grandfathered account) and `null` (pre-rollout
    +          // backend) both mean "no gate" — proceed exactly as before. Purely
    +          // additive, API-driven; see CONTRIBUTING.md "API as Decision
    +          // Authority" (legacy tolerance).
               if (registrationInfo.emailConfirmed == false) {
                 emit(const KycSuccess(currentStep: KycStep.confirmEmail));
                 return;
    @@ -143,6 +187,10 @@ class KycCubit extends Cubit {
               // Forward the server-supplied userData so `KycLinkWalletPage` does
               // not have to re-fetch — see CONTRIBUTING.md "Single round-trip per
               // decision". The backend always populates this for `AddWallet`.
    +          // `emailConfirmed` is deliberately not gated here: in `AddWallet` it
    +          // describes the *other* wallet's registration, not this one. After the
    +          // link-wallet round-trip `checkKyc()` re-fetches and the gate then
    +          // applies to the resulting `AlreadyRegistered` registration.
               emit(
                 KycSuccess(
                   currentStep: KycStep.linkWallet,
    @@ -233,8 +281,33 @@ class KycCubit extends Cubit {
         }
       }
     
    -  void markLegalDisclaimerAccepted() {
    +  /// Records acceptance of the legal disclaimer. Sets the local per-session
    +  /// flag first (the offline fallback for a pre-rollout backend), then durably
    +  /// records acceptance server-side via `PUT /v1/realunit/legal` for whatever
    +  /// the server last reported as outstanding (all agreements when we never got
    +  /// a successful `getLegalInfo()`). A 404 (endpoint not deployed yet) is
    +  /// tolerated — the local flag carries the session; any other PUT failure
    +  /// surfaces as `KycFailure` rather than being swallowed into a silent
    +  /// re-prompt loop. On success the following `checkKyc()` re-reads the
    +  /// authoritative server state, which drives the next routing decision.
    +  Future acceptLegalDisclaimer() async {
         _legalDisclaimerAccepted = true;
    +    try {
    +      await _legalService.acceptLegal(
    +        _outstandingLegalAgreements ?? RealUnitLegalAgreement.values,
    +      );
    +    } on ApiException catch (e) {
    +      if (isClosed) return;
    +      if (e.statusCode != 404) {
    +        emit(KycFailure(e.toString()));
    +        return;
    +      }
    +    } catch (e) {
    +      if (isClosed) return;
    +      emit(KycFailure(e.toString()));
    +      return;
    +    }
    +    await checkKyc();
       }
     
       /// should only be called after realunit registration was completed
    diff --git a/lib/screens/kyc/cubits/kyc/kyc_state.dart b/lib/screens/kyc/cubits/kyc/kyc_state.dart
    index 3504bcc0e..b5d0d47b1 100644
    --- a/lib/screens/kyc/cubits/kyc/kyc_state.dart
    +++ b/lib/screens/kyc/cubits/kyc/kyc_state.dart
    @@ -74,6 +74,15 @@ class KycMergeProcessing extends KycState {
       const KycMergeProcessing();
     }
     
    +/// Emitted when the API reports this wallet's RealUnit registration is parked in
    +/// manual review (`RealUnitRegistrationInfoDto.manualReview == true`) — the
    +/// Aktionariat forward failed and staff must re-forward it before onboarding can
    +/// complete. A terminal waiting state: the user cannot act, so the app renders a
    +/// "registration under review" screen with a refresh instead of routing further.
    +class KycManualReview extends KycState {
    +  const KycManualReview();
    +}
    +
     class KycUnsupportedStepFailure extends KycState {
       // Null when the backend says `PendingReview` but the step list contains no
       // `isRequired` step we can name — we still surface the failure (never a
    diff --git a/lib/screens/kyc/kyc_page_manager.dart b/lib/screens/kyc/kyc_page_manager.dart
    index fd7865423..c5ae9013e 100644
    --- a/lib/screens/kyc/kyc_page_manager.dart
    +++ b/lib/screens/kyc/kyc_page_manager.dart
    @@ -4,6 +4,7 @@ import 'package:realunit_wallet/generated/i18n.dart';
     import 'package:realunit_wallet/packages/service/app_store.dart';
     import 'package:realunit_wallet/packages/service/dfx/dfx_kyc_service.dart';
     import 'package:realunit_wallet/packages/service/dfx/models/kyc/kyc_level.dart';
    +import 'package:realunit_wallet/packages/service/dfx/real_unit_legal_service.dart';
     import 'package:realunit_wallet/packages/service/dfx/real_unit_registration_service.dart';
     import 'package:realunit_wallet/screens/kyc/cubits/kyc/kyc_cubit.dart';
     import 'package:realunit_wallet/screens/kyc/steps/2fa/kyc_2fa_page.dart';
    @@ -19,6 +20,7 @@ import 'package:realunit_wallet/screens/kyc/subpages/kyc_account_merge_page.dart
     import 'package:realunit_wallet/screens/kyc/subpages/kyc_completed_page.dart';
     import 'package:realunit_wallet/screens/kyc/subpages/kyc_failure_page.dart';
     import 'package:realunit_wallet/screens/kyc/subpages/kyc_loading_page.dart';
    +import 'package:realunit_wallet/screens/kyc/subpages/kyc_manual_review_page.dart';
     import 'package:realunit_wallet/screens/kyc/subpages/kyc_merge_processing_page.dart';
     import 'package:realunit_wallet/screens/kyc/subpages/kyc_pending_page.dart';
     import 'package:realunit_wallet/screens/legal/legal_disclaimer_page.dart';
    @@ -35,6 +37,7 @@ class KycPageManager extends StatelessWidget {
           create: (_) => KycCubit(
             getIt(),
             getIt(),
    +        getIt(),
             getIt(),
           )..checkKyc(context: kycContext),
           child: const KycViewManager(),
    @@ -57,6 +60,7 @@ class KycViewManager extends StatelessWidget {
             ),
             KycAccountMergeRequested() => const KycAccountMergePage(),
             KycMergeProcessing() => const KycMergeProcessingPage(),
    +        KycManualReview() => const KycManualReviewPage(),
             KycPending(:final pendingStep) => KycPendingPage(pendingStep: pendingStep),
             KycCompleted() => const KycCompletedPage(),
             KycSuccess(:final currentStep, :final urlOrToken, :final realUnitUserData) =>
    @@ -65,8 +69,9 @@ class KycViewManager extends StatelessWidget {
                 KycStep.confirmEmail => const KycConfirmEmailPage(),
                 KycStep.legalDisclaimer => LegalDisclaimerPage(
                   onCompleted: () {
    -                context.read().markLegalDisclaimerAccepted();
    -                context.read().checkKyc();
    +                // Records acceptance server-side and re-runs `checkKyc()`
    +                // internally, so the API drives the next routing decision.
    +                context.read().acceptLegalDisclaimer();
                   },
                 ),
                 KycStep.registration => KycRegistrationPage(initialUserData: realUnitUserData),
    diff --git a/lib/screens/kyc/steps/confirm_email/cubits/kyc_confirm_email_cubit.dart b/lib/screens/kyc/steps/confirm_email/cubits/kyc_confirm_email_cubit.dart
    index 4a02cab61..3a06c688a 100644
    --- a/lib/screens/kyc/steps/confirm_email/cubits/kyc_confirm_email_cubit.dart
    +++ b/lib/screens/kyc/steps/confirm_email/cubits/kyc_confirm_email_cubit.dart
    @@ -8,8 +8,10 @@ part 'kyc_confirm_email_state.dart';
     /// wallet. The gate is API-driven: `KycCubit` routes here only when
     /// `getRegistrationInfo().emailConfirmed == false`, and this cubit re-fetches
     /// the same fact when the user reports they clicked the confirmation link. When
    -/// the backend flips the flag (or no longer reports one — legacy/grandfathered,
    -/// `null`), the flow proceeds. See CONTRIBUTING.md "API as Decision Authority".
    +/// the backend reports the address is confirmed (an explicit `true`, including
    +/// grandfathered accounts) or no longer reports the flag at all (a pre-rollout
    +/// backend, `null`), the flow proceeds. See CONTRIBUTING.md "API as Decision
    +/// Authority".
     class KycConfirmEmailCubit extends Cubit {
       // Mirrors `KycCubit._checkKycTimeout`: the same `getRegistrationInfo()`
       // call is watch-dogged so a stalled request (socket up, backend never
    diff --git a/lib/screens/kyc/steps/financial_data/subpages/kyc_financial_data_questions_page.dart b/lib/screens/kyc/steps/financial_data/subpages/kyc_financial_data_questions_page.dart
    index 914a338ea..2bf16fcf1 100644
    --- a/lib/screens/kyc/steps/financial_data/subpages/kyc_financial_data_questions_page.dart
    +++ b/lib/screens/kyc/steps/financial_data/subpages/kyc_financial_data_questions_page.dart
    @@ -14,6 +14,7 @@ import 'package:realunit_wallet/setup/routing/routes/app_routes.dart';
     import 'package:realunit_wallet/setup/routing/routes/support_routes.dart';
     import 'package:realunit_wallet/styles/colors.dart';
     import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart';
    +import 'package:realunit_wallet/widgets/scrollable_actions_layout.dart';
     
     class KycFinancialDataQuestionsPage extends StatelessWidget {
       final KycFinancialDataLoadedSuccess state;
    @@ -34,73 +35,64 @@ class KycFinancialDataQuestionsPage extends StatelessWidget {
           ),
           body: Padding(
             padding: const .symmetric(horizontal: 20),
    -        child: LayoutBuilder(
    -          builder: (context, constraint) {
    -            return SingleChildScrollView(
    -              child: ConstrainedBox(
    -                constraints: BoxConstraints(
    -                  minHeight: constraint.maxHeight,
    -                ),
    -                child: IntrinsicHeight(
    -                  child: SafeArea(
    -                    child: Column(
    -                      spacing: 16.0,
    +        child: SafeArea(
    +          child: ScrollableActionsLayout(
    +            body: Column(
    +              spacing: 16.0,
    +              crossAxisAlignment: .start,
    +              mainAxisAlignment: .start,
    +              children: [
    +                Column(
    +                  spacing: 24.0,
    +                  crossAxisAlignment: .start,
    +                  children: [
    +                    Text(
    +                      S
    +                          .of(context)
    +                          .financialDataQuestion(
    +                            '${state.currentIndex + 1}',
    +                            '${state.visibleQuestions.length}',
    +                          ),
    +                      style:
    +                          Theme.of(
    +                            context,
    +                          ).textTheme.bodyMedium?.copyWith(
    +                            color: RealUnitColors.neutral500,
    +                          ),
    +                    ),
    +                    Column(
                           crossAxisAlignment: .start,
    +                      spacing: 16.0,
                           children: [
    -                        Column(
    -                          spacing: 24.0,
    -                          crossAxisAlignment: .start,
    -                          children: [
    -                            Text(
    -                              S
    -                                  .of(context)
    -                                  .financialDataQuestion(
    -                                    '${state.currentIndex + 1}',
    -                                    '${state.visibleQuestions.length}',
    -                                  ),
    -                              style:
    -                                  Theme.of(
    -                                    context,
    -                                  ).textTheme.bodyMedium?.copyWith(
    -                                    color: RealUnitColors.neutral500,
    -                                  ),
    -                            ),
    -                            Column(
    -                              crossAxisAlignment: .start,
    -                              spacing: 16.0,
    -                              children: [
    -                                Text(
    -                                  state.currentQuestion.title,
    -                                  style: Theme.of(
    -                                    context,
    -                                  ).textTheme.headlineSmall,
    -                                ),
    -                                if (state.currentQuestion.description != null)
    -                                  _buildDescription(context, state.currentQuestion),
    -                              ],
    -                            ),
    -                          ],
    -                        ),
    -                        _buildQuestionWidget(context, state),
    -                        const Spacer(),
    -                        Padding(
    -                          padding: const .symmetric(vertical: 16.0),
    -                          child: AppFilledButton(
    -                            onPressed: state.hasAnswer
    -                                ? () => context.read().submitAndNext()
    -                                : null,
    -                            label: state.isLastQuestion
    -                                ? S.of(context).complete
    -                                : S.of(context).next,
    -                          ),
    +                        Text(
    +                          state.currentQuestion.title,
    +                          style: Theme.of(
    +                            context,
    +                          ).textTheme.headlineSmall,
                             ),
    +                        if (state.currentQuestion.description != null)
    +                          _buildDescription(context, state.currentQuestion),
                           ],
                         ),
    -                  ),
    +                  ],
    +                ),
    +                _buildQuestionWidget(context, state),
    +              ],
    +            ),
    +            actions: [
    +              Padding(
    +                padding: const .symmetric(vertical: 16.0),
    +                child: AppFilledButton(
    +                  onPressed: state.hasAnswer
    +                      ? () => context.read().submitAndNext()
    +                      : null,
    +                  label: state.isLastQuestion
    +                      ? S.of(context).complete
    +                      : S.of(context).next,
                     ),
                   ),
    -            );
    -          },
    +            ],
    +          ),
             ),
           ),
         );
    diff --git a/lib/screens/kyc/steps/link_wallet/kyc_link_wallet_page.dart b/lib/screens/kyc/steps/link_wallet/kyc_link_wallet_page.dart
    index 1a854efa4..eda37a264 100644
    --- a/lib/screens/kyc/steps/link_wallet/kyc_link_wallet_page.dart
    +++ b/lib/screens/kyc/steps/link_wallet/kyc_link_wallet_page.dart
    @@ -13,6 +13,7 @@ import 'package:realunit_wallet/screens/kyc/steps/link_wallet/cubits/kyc_link_wa
     import 'package:realunit_wallet/setup/di.dart';
     import 'package:realunit_wallet/styles/colors.dart';
     import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart';
    +import 'package:realunit_wallet/widgets/scrollable_actions_layout.dart';
     
     class KycLinkWalletPage extends StatelessWidget {
       /// Server-supplied user data the parent `KycCubit` already fetched as part
    @@ -124,18 +125,22 @@ class _LinkWalletBody extends StatelessWidget {
         return Padding(
           padding: const EdgeInsets.symmetric(horizontal: 20.0),
           child: SafeArea(
    -        child: Column(
    -          spacing: 16.0,
    -          children: [
    -            const SizedBox(height: 16.0),
    -            Text(
    -              S.of(context).kycLinkWalletDescription,
    -              style: Theme.of(context).textTheme.bodyMedium,
    -              textAlign: TextAlign.center,
    -            ),
    -            _LinkWalletInfoRow(label: S.of(context).name, value: userData.name),
    -            _LinkWalletInfoRow(label: S.of(context).walletAddress, value: walletAddress),
    -            const Spacer(),
    +        child: ScrollableActionsLayout(
    +          centerBody: false,
    +          body: Column(
    +            spacing: 16.0,
    +            children: [
    +              const SizedBox(height: 16.0),
    +              Text(
    +                S.of(context).kycLinkWalletDescription,
    +                style: Theme.of(context).textTheme.bodyMedium,
    +                textAlign: TextAlign.center,
    +              ),
    +              _LinkWalletInfoRow(label: S.of(context).name, value: userData.name),
    +              _LinkWalletInfoRow(label: S.of(context).walletAddress, value: walletAddress),
    +            ],
    +          ),
    +          actions: [
                 AppFilledButton(
                   state: isSubmitting ? FilledButtonState.loading : FilledButtonState.idle,
                   onPressed: isSubmitting
    @@ -187,20 +192,18 @@ class _LinkWalletMissingUserDataPage extends StatelessWidget {
           body: Padding(
             padding: const EdgeInsets.symmetric(horizontal: 20.0),
             child: SafeArea(
    -          child: Column(
    -            spacing: 16.0,
    -            children: [
    -              const Spacer(),
    -              Text(
    -                S.of(context).kycFailure,
    -                style: Theme.of(context).textTheme.bodyMedium,
    -                textAlign: TextAlign.center,
    -              ),
    +          child: ScrollableActionsLayout(
    +            centerBody: true,
    +            body: Text(
    +              S.of(context).kycFailure,
    +              style: Theme.of(context).textTheme.bodyMedium,
    +              textAlign: TextAlign.center,
    +            ),
    +            actions: [
                   AppFilledButton(
                     onPressed: () => context.read().checkKyc(),
                     label: S.of(context).refresh,
                   ),
    -              const Spacer(),
                 ],
               ),
             ),
    diff --git a/lib/screens/kyc/steps/registration/kyc_registration_page.dart b/lib/screens/kyc/steps/registration/kyc_registration_page.dart
    index baa636577..72d0d1e9d 100644
    --- a/lib/screens/kyc/steps/registration/kyc_registration_page.dart
    +++ b/lib/screens/kyc/steps/registration/kyc_registration_page.dart
    @@ -1,4 +1,5 @@
     import 'dart:async';
    +import 'dart:developer' as developer;
     
     import 'package:flutter/cupertino.dart';
     import 'package:flutter/material.dart';
    @@ -8,6 +9,7 @@ import 'package:realunit_wallet/generated/i18n.dart';
     import 'package:realunit_wallet/packages/service/app_store.dart';
     import 'package:realunit_wallet/packages/service/dfx/dfx_country_service.dart';
     import 'package:realunit_wallet/packages/service/dfx/dfx_kyc_service.dart';
    +import 'package:realunit_wallet/packages/service/dfx/exceptions/registration_rejected_exception.dart';
     import 'package:realunit_wallet/packages/service/dfx/models/country/country.dart';
     import 'package:realunit_wallet/packages/service/dfx/models/registration/dto/real_unit_registration_request_dto.dart';
     import 'package:realunit_wallet/packages/service/dfx/models/registration/registration_status.dart';
    @@ -80,11 +82,10 @@ class _KycRegistrationViewState extends State {
       final postalCodeCtrl = TextEditingController();
       final cityCtrl = TextEditingController();
       final countryCtrl = ValueNotifier(null);
    -  final taxCountryCtrl = ValueNotifier(null);
    -  final tinCtrl = TextEditingController();
     
       Country? _initialNationality;
       Country? _initialAddressCountry;
    +  List _initialTaxResidences = const [];
     
       @override
       void initState() {
    @@ -114,16 +115,28 @@ class _KycRegistrationViewState extends State {
           addressStreetNumberCtrl.text = dto.kycData.address.houseNumber ?? '';
           postalCodeCtrl.text = dto.kycData.address.zip;
           cityCtrl.text = dto.kycData.address.city;
    -      unawaited(_resolveInitialCountries(dto.nationality, dto.addressCountry));
    +      unawaited(
    +        _resolveInitialCountries(
    +          dto.nationality,
    +          dto.addressCountry,
    +          swissTaxResidence: dto.swissTaxResidence,
    +          countryAndTINs: dto.countryAndTINs,
    +        ),
    +      );
         }
       }
     
       Future _resolveInitialCountries(
         String nationalitySymbol,
    -    String addressCountrySymbol,
    -  ) async {
    +    String addressCountrySymbol, {
    +    required bool swissTaxResidence,
    +    List? countryAndTINs,
    +  }) async {
    +    final countryService = getIt();
    +
    +    // Nationality/address prefill: independent of the tax-residence lookup. A failure here degrades
    +    // to empty pickers (pre-existing behaviour) and must not take the tax seeding down with it.
         try {
    -      final countryService = getIt();
           final countries = await Future.wait([
             countryService.getCountryBySymbol(nationalitySymbol),
             countryService.getCountryBySymbol(addressCountrySymbol),
    @@ -136,9 +149,32 @@ class _KycRegistrationViewState extends State {
             _initialAddressCountry = countries[1];
           });
         } catch (_) {
    -      // Country lookup failed (unknown symbol or network error): degrade
    -      // gracefully to the empty country fields — the user can pick manually
    -      // and the form still submits.
    +      // Country lookup failed (unknown symbol or network error): degrade to empty pickers; the
    +      // user can pick manually and the form still submits.
    +    }
    +
    +    // Tax-residence seeds resolved separately: a single unknown tax country must not wipe the
    +    // nationality/address prefill, and a failure is logged LOUDLY rather than swallowed — dropping a
    +    // seed silently would re-introduce the tax-residence loss this seeding exists to prevent (the API
    +    // overwrites the stored set with the form submit).
    +    try {
    +      final taxSeeds = [];
    +      if (swissTaxResidence) {
    +        final ch = await countryService.getCountryBySymbol('CH');
    +        taxSeeds.add(KycTaxResidenceSeed(country: ch, tin: ''));
    +      }
    +      if (countryAndTINs != null) {
    +        for (final entry in countryAndTINs) {
    +          final country = await countryService.getCountryBySymbol(entry.country);
    +          taxSeeds.add(KycTaxResidenceSeed(country: country, tin: entry.tin));
    +        }
    +      }
    +      if (!mounted) return;
    +      setState(() {
    +        _initialTaxResidences = taxSeeds;
    +      });
    +    } catch (e) {
    +      developer.log('Failed to resolve prefilled tax residences: $e');
         }
       }
     
    @@ -189,9 +225,23 @@ class _KycRegistrationViewState extends State {
                 }
               }
               if (state is KycRegistrationSubmitFailure) {
    -            final message = state.cause is SigningCancelledException
    -                ? S.of(context).signingCancelled
    -                : S.of(context).registrationFailed(state.message);
    +            final cause = state.cause;
    +            final String message;
    +            if (cause is SigningCancelledException) {
    +              message = S.of(context).signingCancelled;
    +            } else if (cause is RegistrationRejectedException) {
    +              // Thrown only for a content-level (non-auth) 4xx of
    +              // register/complete itself — surface the server reason with the
    +              // "nothing was saved" context so it is not mistaken for a hang
    +              // (the wizard state is purely local and a rejected submit
    +              // persists nothing). Auth/rate-limit/5xx/transport errors and
    +              // pre-submit failures (getUser, register/date) stay on the
    +              // generic message: "check your entries" would be the wrong
    +              // instruction there.
    +              message = S.of(context).registrationRejected(cause.message);
    +            } else {
    +              message = S.of(context).registrationFailed(state.message);
    +            }
                 ScaffoldMessenger.of(context).showSnackBar(
                   SnackBar(
                     content: Text(message),
    @@ -273,37 +323,30 @@ class _KycRegistrationViewState extends State {
             );
     
           case KycRegistrationStep.taxResidence:
    -        // Default the tax-residence country to the residence country entered on
    -        // the address step, rebuilding when it changes — most people are
    -        // tax-resident where they live. The field stays editable; CountryField
    -        // propagates the default into `taxCountryCtrl` and reveals the TIN for a
    -        // non-Swiss default.
    +        // The address-step residence country is hard-wired into the tax list as
    +        // a locked primary entry (API: tax residences must include addressCountry).
    +        // Rebuild when the residence changes so the locked row stays in sync.
    +        // ValueKey forces a new State when async seed resolution lands: the tax
    +        // step only reads initialTaxResidences in initState, so a late prefill
    +        // would otherwise never appear in the form.
             return ValueListenableBuilder(
               valueListenable: countryCtrl,
               builder: (context, residenceCountry, _) => KycRegistrationTaxStep(
    -            taxCountryCtrl: taxCountryCtrl,
    -            tinCtrl: tinCtrl,
    -            initialCountry: residenceCountry,
    -            onSubmit: _onSubmit,
    +            key: ValueKey(
    +              'tax-${_initialTaxResidences.map((s) => s.country.symbol).join(",")}',
    +            ),
    +            residenceCountry: residenceCountry,
    +            initialTaxResidences: _initialTaxResidences,
    +            onSubmit: _onSubmitTax,
               ),
             );
         }
       }
     
    -  Future _onSubmit() async {
    -    // `swissTaxResidence` is derived from the tax-residence country picked on
    -    // the final step: a Swiss (CH) tax residence is Swiss-only, and the TIN is
    -    // forwarded only for a non-Swiss tax residence (matching the backend
    -    // contract).
    -    final swissTaxResidence = taxCountryCtrl.value!.symbol == 'CH';
    -    final countryAndTINs = swissTaxResidence
    -        ? null
    -        : [
    -            CountryAndTin(
    -              country: taxCountryCtrl.value!.symbol,
    -              tin: tinCtrl.text.trim(),
    -            ),
    -          ];
    +  Future _onSubmitTax(KycTaxResidenceSubmit tax) async {
    +    // `swissTaxResidence` + `countryAndTINs` are derived inside the tax step so
    +    // multi-residence and the locked address-country entry stay consistent with
    +    // the backend contract (tax residences must include addressCountry).
         await context.read().submit(
           type: typeCtrl.value,
           firstName: firstnameCtrl.text.trim(),
    @@ -316,8 +359,8 @@ class _KycRegistrationViewState extends State {
           addressPostalCode: postalCodeCtrl.text.trim(),
           addressCity: cityCtrl.text.trim(),
           addressCountry: countryCtrl.value!,
    -      swissTaxResidence: swissTaxResidence,
    -      countryAndTINs: countryAndTINs,
    +      swissTaxResidence: tax.swissTaxResidence,
    +      countryAndTINs: tax.countryAndTINs,
         );
       }
     
    @@ -335,8 +378,6 @@ class _KycRegistrationViewState extends State {
         postalCodeCtrl.dispose();
         cityCtrl.dispose();
         countryCtrl.dispose();
    -    taxCountryCtrl.dispose();
    -    tinCtrl.dispose();
         super.dispose();
       }
     }
    diff --git a/lib/screens/kyc/steps/registration/steps/kyc_registration_tax_step.dart b/lib/screens/kyc/steps/registration/steps/kyc_registration_tax_step.dart
    index c0362993a..c7903ec45 100644
    --- a/lib/screens/kyc/steps/registration/steps/kyc_registration_tax_step.dart
    +++ b/lib/screens/kyc/steps/registration/steps/kyc_registration_tax_step.dart
    @@ -1,31 +1,217 @@
    +import 'package:collection/collection.dart';
     import 'package:flutter/material.dart';
    +import 'package:flutter/services.dart';
     import 'package:realunit_wallet/generated/i18n.dart';
     import 'package:realunit_wallet/packages/service/dfx/models/country/country.dart';
    +import 'package:realunit_wallet/packages/service/dfx/models/registration/dto/real_unit_registration_request_dto.dart';
    +import 'package:realunit_wallet/styles/colors.dart';
     import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart';
    +import 'package:realunit_wallet/widgets/buttons/app_text_button.dart';
     import 'package:realunit_wallet/widgets/form/country_field.dart';
     import 'package:realunit_wallet/widgets/form/labeled_text_field.dart';
     
    -class KycRegistrationTaxStep extends StatelessWidget {
    -  final ValueNotifier taxCountryCtrl;
    +/// Result of a completed tax-residence form. Mirrors the API contract:
    +/// - [swissTaxResidence] is true when any declared tax country is CH
    +/// - [countryAndTINs] carries every non-CH tax country with its TIN
    +class KycTaxResidenceSubmit {
    +  final bool swissTaxResidence;
    +  final List? countryAndTINs;
    +
    +  const KycTaxResidenceSubmit({
    +    required this.swissTaxResidence,
    +    this.countryAndTINs,
    +  });
    +}
    +
    +/// One tax residence already on file, resolved to a [Country]. Seeds the form so a returning user
    +/// does not silently drop tax residences they declared earlier: the API overwrites the stored set
    +/// with exactly what this form submits, so anything not shown here would be lost.
    +class KycTaxResidenceSeed {
    +  final Country country;
    +
    +  /// Empty for CH — Swiss tax residence is declared via the `swissTaxResidence` flag and carries no TIN.
    +  final String tin;
    +
    +  const KycTaxResidenceSeed({required this.country, required this.tin});
    +}
    +
    +/// Mirrors the API bounds so the user hits a clean UI limit instead of a server-side 400:
    +/// `countryAndTINs` is capped at 10 entries and each TIN at 64 characters.
    +const int _maxTaxResidences = 10;
    +const int _maxTinLength = 64;
    +
    +/// One tax-residence row in the form.
    +class _TaxRow {
    +  /// When true, [country] is fixed to the address-step residence country and
    +  /// cannot be removed or changed — hard-wires addressCountry into the tax list.
    +  final bool lockedToResidence;
    +  Country? country;
       final TextEditingController tinCtrl;
    -  final Future Function() onSubmit;
     
    -  /// Pre-selects the tax-residence country — the residence country the user
    -  /// entered on the address step (most people are tax-resident where they
    -  /// live). Editable here; `null` falls back to an empty, mandatory picker.
    -  final Country? initialCountry;
    +  _TaxRow({
    +    required this.lockedToResidence,
    +    this.country,
    +    String? tin,
    +  }) : tinCtrl = TextEditingController(
    +          // Seeded values bypass the keyboard formatter; clamp them to the same API bound so a
    +          // legacy over-length TIN cannot slip through to a server-side 400.
    +          text: (tin ?? '').length > _maxTinLength
    +              ? (tin ?? '').substring(0, _maxTinLength)
    +              : (tin ?? ''),
    +        );
    +
    +  void dispose() => tinCtrl.dispose();
    +}
    +
    +class KycRegistrationTaxStep extends StatefulWidget {
    +  /// Residence country from the address step. When non-null, it is always the
    +  /// first (locked) tax-residence entry so the address country is guaranteed to
    +  /// be among the declared tax residences. When null, the first row is a free
    +  /// mandatory country picker (empty-form fallback).
    +  final Country? residenceCountry;
    +
    +  /// Tax residences already declared on the backend (prefill). Includes a CH entry with an empty
    +  /// TIN when the stored declaration has `swissTaxResidence: true`. Empty for a first-time user.
    +  final List initialTaxResidences;
     
    -  KycRegistrationTaxStep({
    +  final Future Function(KycTaxResidenceSubmit result) onSubmit;
    +
    +  const KycRegistrationTaxStep({
         super.key,
    -    required this.taxCountryCtrl,
    -    required this.tinCtrl,
    +    required this.residenceCountry,
    +    required this.initialTaxResidences,
         required this.onSubmit,
    -    this.initialCountry,
       });
    +
    +  @override
    +  State createState() => _KycRegistrationTaxStepState();
    +}
    +
    +class _KycRegistrationTaxStepState extends State {
       final _formKey = GlobalKey();
    +  late List<_TaxRow> _rows;
    +
    +  @override
    +  void initState() {
    +    super.initState();
    +    _rows = _buildInitialRows(widget.residenceCountry);
    +  }
    +
    +  @override
    +  void didUpdateWidget(covariant KycRegistrationTaxStep oldWidget) {
    +    super.didUpdateWidget(oldWidget);
    +    // Keep the locked primary row in sync when the address country changes
    +    // (user navigated back and edited residence). Additional free rows are kept.
    +    if (oldWidget.residenceCountry?.symbol != widget.residenceCountry?.symbol) {
    +      final oldPrimary = _rows.first;
    +      final keptAdditional = _rows.skip(1).toList();
    +      // Drop additional rows that collide with the new residence country.
    +      final residenceSymbol = widget.residenceCountry?.symbol;
    +      final filtered = keptAdditional.where((r) => r.country?.symbol != residenceSymbol).toList();
    +      for (final dropped in keptAdditional.where((r) => !filtered.contains(r))) {
    +        dropped.dispose();
    +      }
    +      oldPrimary.dispose();
    +      setState(() {
    +        _rows = [_buildPrimaryRow(widget.residenceCountry), ...filtered];
    +      });
    +    }
    +  }
    +
    +  @override
    +  void dispose() {
    +    for (final row in _rows) {
    +      row.dispose();
    +    }
    +    super.dispose();
    +  }
    +
    +  /// Only non-CH rows become countryAndTINs entries; CH is declared via the swissTaxResidence flag
    +  /// and never occupies a slot. The API caps countryAndTINs (not total tax residences), so the cap
    +  /// counts non-CH rows only — a CH residence plus [_maxTaxResidences] foreign entries is valid.
    +  int get _nonChRowCount => _rows.where((r) => r.country?.symbol != 'CH').length;
    +
    +  /// Primary row = the (locked) residence country, with its TIN prefilled when one is on file.
    +  /// Every other seeded tax residence becomes an additional row. Non-CH rows are capped at
    +  /// [_maxTaxResidences] (CH never occupies a countryAndTINs slot).
    +  List<_TaxRow> _buildInitialRows(Country? residence) {
    +    final seeds = widget.initialTaxResidences;
    +    final residenceSeed = residence == null
    +        ? null
    +        : seeds.where((s) => s.country.symbol == residence.symbol).firstOrNull;
    +
    +    final rows = <_TaxRow>[
    +      _TaxRow(
    +        lockedToResidence: residence != null,
    +        country: residence,
    +        tin: residenceSeed?.tin,
    +      ),
    +    ];
    +
    +    for (final seed in seeds) {
    +      if (seed.country.symbol == residence?.symbol) continue; // already the primary row
    +      // Count only non-CH rows against the cap — CH never occupies a countryAndTINs slot.
    +      final nonChCount = rows.where((r) => r.country?.symbol != 'CH').length;
    +      if (seed.country.symbol != 'CH' && nonChCount >= _maxTaxResidences) continue;
    +      rows.add(_TaxRow(lockedToResidence: false, country: seed.country, tin: seed.tin));
    +    }
    +
    +    return rows;
    +  }
    +
    +  _TaxRow _buildPrimaryRow(Country? residence) {
    +    return _TaxRow(
    +      lockedToResidence: residence != null,
    +      country: residence,
    +    );
    +  }
    +
    +  bool _isSwiss(Country? country) => country?.symbol == 'CH';
    +
    +  Set _usedSymbols({int? excludingIndex}) {
    +    final symbols = {};
    +    for (var i = 0; i < _rows.length; i++) {
    +      if (excludingIndex != null && i == excludingIndex) continue;
    +      final symbol = _rows[i].country?.symbol;
    +      if (symbol != null) symbols.add(symbol);
    +    }
    +    return symbols;
    +  }
    +
    +  void _addRow() {
    +    if (_nonChRowCount >= _maxTaxResidences) return;
    +    setState(() {
    +      _rows.add(_TaxRow(lockedToResidence: false));
    +    });
    +  }
    +
    +  void _removeRow(int index) {
    +    if (index <= 0 || index >= _rows.length) return;
    +    setState(() {
    +      _rows.removeAt(index).dispose();
    +    });
    +  }
    +
    +  KycTaxResidenceSubmit _buildResult() {
    +    final countries = _rows.map((r) => r.country).whereType().toList();
    +    final swissTaxResidence = countries.any((c) => c.symbol == 'CH');
    +    final tins = [
    +      for (final row in _rows)
    +        if (row.country != null && row.country!.symbol != 'CH')
    +          CountryAndTin(
    +            country: row.country!.symbol,
    +            tin: row.tinCtrl.text.trim(),
    +          ),
    +    ];
    +    return KycTaxResidenceSubmit(
    +      swissTaxResidence: swissTaxResidence,
    +      countryAndTINs: tins.isEmpty ? null : tins,
    +    );
    +  }
     
       @override
       Widget build(BuildContext context) {
    +    final s = S.of(context);
         return SingleChildScrollView(
           padding: const EdgeInsets.symmetric(horizontal: 20),
           child: SafeArea(
    @@ -37,50 +223,28 @@ class KycRegistrationTaxStep extends StatelessWidget {
                 child: Column(
                   spacing: 16,
                   children: [
    -                // The tax-residence country defaults to the residence country
    -                // entered on the address step (via [initialCountry]) but stays
    -                // editable and mandatory: with no residence to fall back on the
    -                // picker is empty and CountryField's own validator blocks submit
    -                // until a choice is made. `swissTaxResidence` is derived from
    -                // this selection — a Swiss (CH) tax residence maps to Swiss-only,
    -                // and the TIN is collected only for a non-Swiss tax residence,
    -                // matching the backend contract (`swissTaxResidence` + optional
    -                // `countryAndTINs`).
    -                CountryField(
    -                  label: S.of(context).taxResidenceCountry,
    -                  purpose: CountryFieldPurpose.nationality,
    -                  initialValue: initialCountry,
    -                  onChanged: (country) => taxCountryCtrl.value = country,
    -                ),
    -                ValueListenableBuilder(
    -                  valueListenable: taxCountryCtrl,
    -                  builder: (context, country, _) {
    -                    final showTin = country != null && country.symbol != 'CH';
    -                    if (!showTin) return const SizedBox.shrink();
    -                    return LabeledTextField(
    -                      hintText: S.of(context).tinHint,
    -                      controller: tinCtrl,
    -                      label: S.of(context).taxIdentificationNumber,
    -                      keyboardType: TextInputType.text,
    -                      validator: (value) {
    -                        if (value == null || value.trim().isEmpty) {
    -                          return S.of(context).tinRequired;
    -                        }
    -                        return null;
    -                      },
    -                    );
    -                  },
    -                ),
    +                // The residence (address) country is hard-wired as a tax residence:
    +                // it is always the first entry and, when known, cannot be removed or
    +                // changed. Additional tax countries may be added; each non-CH entry
    +                // requires a TIN. `swissTaxResidence` + `countryAndTINs` are derived
    +                // on submit to match the backend contract.
    +                for (var i = 0; i < _rows.length; i++) _buildRow(context, i),
    +                if (_nonChRowCount < _maxTaxResidences)
    +                  AppTextButton(
    +                    label: s.addTaxResidence,
    +                    icon: Icons.add,
    +                    onPressed: _addRow,
    +                  ),
                     Padding(
                       padding: const EdgeInsets.symmetric(vertical: 16.0),
                       child: AppFilledButton(
                         onPressed: () async {
                           FocusManager.instance.primaryFocus?.unfocus();
                           if (_formKey.currentState?.validate() ?? false) {
    -                        await onSubmit();
    +                        await widget.onSubmit(_buildResult());
                           }
                         },
    -                    label: S.of(context).complete,
    +                    label: s.complete,
                       ),
                     ),
                   ],
    @@ -90,4 +254,116 @@ class KycRegistrationTaxStep extends StatelessWidget {
           ),
         );
       }
    +
    +  Widget _buildRow(BuildContext context, int index) {
    +    final row = _rows[index];
    +    final s = S.of(context);
    +    final showTin = row.country != null && !_isSwiss(row.country);
    +
    +    return Column(
    +      key: ValueKey('tax-row-$index-${row.lockedToResidence}'),
    +      spacing: 16,
    +      crossAxisAlignment: CrossAxisAlignment.stretch,
    +      children: [
    +        if (row.lockedToResidence)
    +          _LockedCountryField(
    +            label: s.taxResidenceCountry,
    +            country: row.country!,
    +          )
    +        else
    +          CountryField(
    +            // Key includes used symbols so excluding already-selected countries
    +            // rebuilds the field when sibling rows change (no stale FormField value).
    +            key: ValueKey(
    +              'tax-free-${identityHashCode(row)}-'
    +              '${row.country?.symbol}-'
    +              '${_usedSymbols(excludingIndex: index).join(',')}',
    +            ),
    +            label: s.taxResidenceCountry,
    +            purpose: CountryFieldPurpose.nationality,
    +            initialValue: row.country,
    +            // Already-selected countries cannot be picked again — prevents model
    +            // vs FormField desync and silent payload loss on duplicate picks.
    +            excludeSymbols: _usedSymbols(excludingIndex: index),
    +            onChanged: (country) => setState(() => row.country = country),
    +          ),
    +        if (showTin)
    +          LabeledTextField(
    +            hintText: s.tinHint,
    +            controller: row.tinCtrl,
    +            label: s.taxIdentificationNumber,
    +            keyboardType: TextInputType.text,
    +            inputFormatters: [LengthLimitingTextInputFormatter(_maxTinLength)],
    +            validator: (value) {
    +              if (value == null || value.trim().isEmpty) {
    +                return s.tinRequired;
    +              }
    +              return null;
    +            },
    +          ),
    +        if (!row.lockedToResidence && index > 0)
    +          Align(
    +            alignment: Alignment.centerRight,
    +            child: AppTextButton(
    +              label: s.removeTaxResidence,
    +              fullWidth: false,
    +              onPressed: () => _removeRow(index),
    +            ),
    +          ),
    +      ],
    +    );
    +  }
    +}
    +
    +/// Read-only display of the residence country used as the locked tax entry.
    +class _LockedCountryField extends StatelessWidget {
    +  final String label;
    +  final Country country;
    +
    +  const _LockedCountryField({
    +    required this.label,
    +    required this.country,
    +  });
    +
    +  @override
    +  Widget build(BuildContext context) {
    +    // Match LabeledTextField / DropdownField label chrome (13 bold, height 18/13).
    +    return Column(
    +      crossAxisAlignment: CrossAxisAlignment.start,
    +      children: [
    +        Padding(
    +          padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0),
    +          child: Text(
    +            label,
    +            style: const TextStyle(
    +              fontSize: 13,
    +              fontWeight: FontWeight.bold,
    +              height: 18 / 13,
    +            ),
    +          ),
    +        ),
    +        InputDecorator(
    +          decoration: const InputDecoration(
    +            filled: true,
    +            fillColor: RealUnitColors.neutral100,
    +            enabledBorder: OutlineInputBorder(
    +              borderRadius: BorderRadius.all(Radius.circular(8.0)),
    +              borderSide: BorderSide(color: RealUnitColors.neutral300),
    +            ),
    +            border: OutlineInputBorder(
    +              borderRadius: BorderRadius.all(Radius.circular(8.0)),
    +              borderSide: BorderSide(color: RealUnitColors.neutral300),
    +            ),
    +            contentPadding: EdgeInsets.symmetric(horizontal: 10.0, vertical: 14),
    +          ),
    +          child: Text(
    +            country.name,
    +            style: Theme.of(context).textTheme.bodyLarge?.copyWith(
    +                  color: RealUnitColors.neutral900,
    +                ),
    +          ),
    +        ),
    +      ],
    +    );
    +  }
     }
    diff --git a/lib/screens/kyc/steps/signature_unsupported/kyc_signature_unsupported_page.dart b/lib/screens/kyc/steps/signature_unsupported/kyc_signature_unsupported_page.dart
    index 206bf260c..5bee8fe41 100644
    --- a/lib/screens/kyc/steps/signature_unsupported/kyc_signature_unsupported_page.dart
    +++ b/lib/screens/kyc/steps/signature_unsupported/kyc_signature_unsupported_page.dart
    @@ -1,6 +1,7 @@
     import 'package:flutter/material.dart';
     import 'package:realunit_wallet/generated/i18n.dart';
     import 'package:realunit_wallet/styles/colors.dart';
    +import 'package:realunit_wallet/widgets/scrollable_actions_layout.dart';
     
     class KycSignatureUnsupportedPage extends StatelessWidget {
       const KycSignatureUnsupportedPage({super.key});
    @@ -14,29 +15,31 @@ class KycSignatureUnsupportedPage extends StatelessWidget {
           body: Padding(
             padding: const EdgeInsets.symmetric(horizontal: 20.0),
             child: SafeArea(
    -          child: Column(
    -            spacing: 16.0,
    -            children: [
    -              const Spacer(),
    -              const Icon(
    -                Icons.info_outline,
    -                size: 48,
    -                color: RealUnitColors.neutral500,
    -              ),
    -              Text(
    -                S.of(context).kycSignatureUnsupportedTitle,
    -                textAlign: TextAlign.center,
    -                style: Theme.of(context).textTheme.headlineMedium,
    -              ),
    -              Text(
    -                S.of(context).kycSignatureUnsupportedDescription,
    -                textAlign: TextAlign.center,
    -                style: Theme.of(context).textTheme.bodyMedium?.copyWith(
    +          child: ScrollableActionsLayout(
    +            centerBody: true,
    +            actions: const [],
    +            body: Column(
    +              spacing: 16.0,
    +              children: [
    +                const Icon(
    +                  Icons.info_outline,
    +                  size: 48,
                       color: RealUnitColors.neutral500,
                     ),
    -              ),
    -              const Spacer(),
    -            ],
    +                Text(
    +                  S.of(context).kycSignatureUnsupportedTitle,
    +                  textAlign: TextAlign.center,
    +                  style: Theme.of(context).textTheme.headlineMedium,
    +                ),
    +                Text(
    +                  S.of(context).kycSignatureUnsupportedDescription,
    +                  textAlign: TextAlign.center,
    +                  style: Theme.of(context).textTheme.bodyMedium?.copyWith(
    +                    color: RealUnitColors.neutral500,
    +                  ),
    +                ),
    +              ],
    +            ),
               ),
             ),
           ),
    diff --git a/lib/screens/kyc/subpages/kyc_account_merge_page.dart b/lib/screens/kyc/subpages/kyc_account_merge_page.dart
    index b93818c98..742161942 100644
    --- a/lib/screens/kyc/subpages/kyc_account_merge_page.dart
    +++ b/lib/screens/kyc/subpages/kyc_account_merge_page.dart
    @@ -4,6 +4,7 @@ import 'package:realunit_wallet/generated/i18n.dart';
     import 'package:realunit_wallet/screens/kyc/cubits/kyc/kyc_cubit.dart';
     import 'package:realunit_wallet/styles/colors.dart';
     import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart';
    +import 'package:realunit_wallet/widgets/scrollable_actions_layout.dart';
     
     class KycAccountMergePage extends StatelessWidget {
       const KycAccountMergePage({super.key});
    @@ -17,22 +18,26 @@ class KycAccountMergePage extends StatelessWidget {
           body: Padding(
             padding: const .symmetric(horizontal: 20.0),
             child: SafeArea(
    -          child: Column(
    -            spacing: 8.0,
    -            children: [
    -              const Spacer(),
    -              Text(
    -                S.of(context).kycAccountMergeTitle,
    -                style: Theme.of(context).textTheme.headlineMedium,
    -                textAlign: .center,
    -              ),
    -              Text(
    -                S.of(context).kycAccountMergeDescription,
    -                textAlign: .center,
    -                style: Theme.of(context).textTheme.bodyMedium?.copyWith(
    -                  color: RealUnitColors.neutral500,
    +          child: ScrollableActionsLayout(
    +            centerBody: true,
    +            body: Column(
    +              spacing: 8.0,
    +              children: [
    +                Text(
    +                  S.of(context).kycAccountMergeTitle,
    +                  style: Theme.of(context).textTheme.headlineMedium,
    +                  textAlign: .center,
                     ),
    -              ),
    +                Text(
    +                  S.of(context).kycAccountMergeDescription,
    +                  textAlign: .center,
    +                  style: Theme.of(context).textTheme.bodyMedium?.copyWith(
    +                    color: RealUnitColors.neutral500,
    +                  ),
    +                ),
    +              ],
    +            ),
    +            actions: [
                   Padding(
                     padding: const .symmetric(vertical: 16.0),
                     child: AppFilledButton(
    @@ -40,7 +45,6 @@ class KycAccountMergePage extends StatelessWidget {
                       label: S.of(context).refresh,
                     ),
                   ),
    -              const Spacer(),
                 ],
               ),
             ),
    diff --git a/lib/screens/kyc/subpages/kyc_completed_page.dart b/lib/screens/kyc/subpages/kyc_completed_page.dart
    index bbd7494fb..6142a335b 100644
    --- a/lib/screens/kyc/subpages/kyc_completed_page.dart
    +++ b/lib/screens/kyc/subpages/kyc_completed_page.dart
    @@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
     import 'package:go_router/go_router.dart';
     import 'package:realunit_wallet/generated/i18n.dart';
     import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart';
    +import 'package:realunit_wallet/widgets/scrollable_actions_layout.dart';
     
     class KycCompletedPage extends StatelessWidget {
       const KycCompletedPage({super.key});
    @@ -13,29 +14,33 @@ class KycCompletedPage extends StatelessWidget {
           body: Padding(
             padding: const EdgeInsets.symmetric(horizontal: 20),
             child: SafeArea(
    -          child: Column(
    -            spacing: 24,
    -            children: [
    -              const Spacer(),
    -              Text(
    -                S.of(context).kycCompleted,
    -                textAlign: TextAlign.center,
    -                style: const TextStyle(
    -                  fontWeight: FontWeight.bold,
    -                  fontSize: 26,
    -                  letterSpacing: 26 * -0.02,
    -                  height: 30 / 26,
    +          child: ScrollableActionsLayout(
    +            centerBody: true,
    +            body: Column(
    +              spacing: 24,
    +              children: [
    +                Text(
    +                  S.of(context).kycCompleted,
    +                  textAlign: TextAlign.center,
    +                  style: const TextStyle(
    +                    fontWeight: FontWeight.bold,
    +                    fontSize: 26,
    +                    letterSpacing: 26 * -0.02,
    +                    height: 30 / 26,
    +                  ),
                     ),
    -              ),
    -              Text(
    -                S.of(context).kycCompletedDescription,
    -                textAlign: TextAlign.center,
    -                style: const TextStyle(
    -                  fontSize: 14,
    -                  height: 18 / 14,
    -                  letterSpacing: 0.0,
    +                Text(
    +                  S.of(context).kycCompletedDescription,
    +                  textAlign: TextAlign.center,
    +                  style: const TextStyle(
    +                    fontSize: 14,
    +                    height: 18 / 14,
    +                    letterSpacing: 0.0,
    +                  ),
                     ),
    -              ),
    +              ],
    +            ),
    +            actions: [
                   Padding(
                     padding: const .symmetric(vertical: 16.0),
                     child: AppFilledButton(
    @@ -43,7 +48,6 @@ class KycCompletedPage extends StatelessWidget {
                       label: S.of(context).close,
                     ),
                   ),
    -              const Spacer(),
                 ],
               ),
             ),
    diff --git a/lib/screens/kyc/subpages/kyc_failure_page.dart b/lib/screens/kyc/subpages/kyc_failure_page.dart
    index d831604ed..c840801a9 100644
    --- a/lib/screens/kyc/subpages/kyc_failure_page.dart
    +++ b/lib/screens/kyc/subpages/kyc_failure_page.dart
    @@ -1,6 +1,7 @@
     import 'package:flutter/material.dart';
     import 'package:realunit_wallet/generated/i18n.dart';
     import 'package:realunit_wallet/styles/colors.dart';
    +import 'package:realunit_wallet/widgets/scrollable_actions_layout.dart';
     
     class KycFailurePage extends StatelessWidget {
       final String message;
    @@ -14,31 +15,33 @@ class KycFailurePage extends StatelessWidget {
           body: Padding(
             padding: const EdgeInsets.symmetric(horizontal: 20.0),
             child: SafeArea(
    -          child: Column(
    -            spacing: 8.0,
    -            children: [
    -              const Spacer(),
    -              Text(
    -                S.of(context).kycFailure,
    -                style: const TextStyle(
    -                  fontSize: 26,
    -                  fontWeight: FontWeight.w700,
    -                  height: 30 / 26,
    -                  letterSpacing: -0.52,
    +          child: ScrollableActionsLayout(
    +            centerBody: true,
    +            actions: const [],
    +            body: Column(
    +              spacing: 8.0,
    +              children: [
    +                Text(
    +                  S.of(context).kycFailure,
    +                  style: const TextStyle(
    +                    fontSize: 26,
    +                    fontWeight: FontWeight.w700,
    +                    height: 30 / 26,
    +                    letterSpacing: -0.52,
    +                  ),
                     ),
    -              ),
    -              Text(
    -                S.of(context).kycFailureDescription(message),
    -                textAlign: TextAlign.center,
    -                style: const TextStyle(
    -                  color: RealUnitColors.neutral500,
    -                  fontSize: 14,
    -                  height: 18 / 14,
    -                  letterSpacing: 0.0,
    +                Text(
    +                  S.of(context).kycFailureDescription(message),
    +                  textAlign: TextAlign.center,
    +                  style: const TextStyle(
    +                    color: RealUnitColors.neutral500,
    +                    fontSize: 14,
    +                    height: 18 / 14,
    +                    letterSpacing: 0.0,
    +                  ),
                     ),
    -              ),
    -              const Spacer(),
    -            ],
    +              ],
    +            ),
               ),
             ),
           ),
    diff --git a/lib/screens/kyc/subpages/kyc_manual_review_page.dart b/lib/screens/kyc/subpages/kyc_manual_review_page.dart
    new file mode 100644
    index 000000000..a1bbea303
    --- /dev/null
    +++ b/lib/screens/kyc/subpages/kyc_manual_review_page.dart
    @@ -0,0 +1,54 @@
    +import 'package:flutter/material.dart';
    +import 'package:flutter_bloc/flutter_bloc.dart';
    +import 'package:realunit_wallet/generated/i18n.dart';
    +import 'package:realunit_wallet/screens/kyc/cubits/kyc/kyc_cubit.dart';
    +import 'package:realunit_wallet/styles/colors.dart';
    +import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart';
    +import 'package:realunit_wallet/widgets/scrollable_actions_layout.dart';
    +
    +class KycManualReviewPage extends StatelessWidget {
    +  const KycManualReviewPage({super.key});
    +
    +  @override
    +  Widget build(BuildContext context) {
    +    return Scaffold(
    +      appBar: AppBar(
    +        title: Text(S.of(context).kyc),
    +      ),
    +      body: Padding(
    +        padding: const EdgeInsets.symmetric(horizontal: 20.0),
    +        child: SafeArea(
    +          child: ScrollableActionsLayout(
    +            centerBody: true,
    +            body: Column(
    +              spacing: 8.0,
    +              children: [
    +                Text(
    +                  S.of(context).kycManualReviewTitle,
    +                  style: Theme.of(context).textTheme.headlineMedium,
    +                  textAlign: TextAlign.center,
    +                ),
    +                Text(
    +                  S.of(context).kycManualReviewDescription,
    +                  textAlign: TextAlign.center,
    +                  style: Theme.of(context).textTheme.bodyMedium?.copyWith(
    +                    color: RealUnitColors.neutral500,
    +                  ),
    +                ),
    +              ],
    +            ),
    +            actions: [
    +              Padding(
    +                padding: const EdgeInsets.symmetric(vertical: 16.0),
    +                child: AppFilledButton(
    +                  onPressed: () => context.read().checkKyc(),
    +                  label: S.of(context).refresh,
    +                ),
    +              ),
    +            ],
    +          ),
    +        ),
    +      ),
    +    );
    +  }
    +}
    diff --git a/lib/screens/kyc/subpages/kyc_merge_processing_page.dart b/lib/screens/kyc/subpages/kyc_merge_processing_page.dart
    index 80fe5a19d..3d110ec1e 100644
    --- a/lib/screens/kyc/subpages/kyc_merge_processing_page.dart
    +++ b/lib/screens/kyc/subpages/kyc_merge_processing_page.dart
    @@ -5,6 +5,7 @@ import 'package:realunit_wallet/generated/i18n.dart';
     import 'package:realunit_wallet/screens/kyc/cubits/kyc/kyc_cubit.dart';
     import 'package:realunit_wallet/styles/colors.dart';
     import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart';
    +import 'package:realunit_wallet/widgets/scrollable_actions_layout.dart';
     
     /// Waiting screen shown while the backend processes a confirmed account merge
     /// (`KycProcessStatus.mergeProcessing`). The user already confirmed; the API is
    @@ -22,23 +23,27 @@ class KycMergeProcessingPage extends StatelessWidget {
           body: Padding(
             padding: const EdgeInsets.symmetric(horizontal: 20.0),
             child: SafeArea(
    -          child: Column(
    -            spacing: 8.0,
    -            children: [
    -              const Spacer(),
    -              const CupertinoActivityIndicator(),
    -              Text(
    -                S.of(context).kycMergeProcessingTitle,
    -                style: Theme.of(context).textTheme.headlineMedium,
    -                textAlign: TextAlign.center,
    -              ),
    -              Text(
    -                S.of(context).kycMergeProcessingDescription,
    -                textAlign: TextAlign.center,
    -                style: Theme.of(context).textTheme.bodyMedium?.copyWith(
    -                  color: RealUnitColors.neutral500,
    +          child: ScrollableActionsLayout(
    +            centerBody: true,
    +            body: Column(
    +              spacing: 8.0,
    +              children: [
    +                const CupertinoActivityIndicator(),
    +                Text(
    +                  S.of(context).kycMergeProcessingTitle,
    +                  style: Theme.of(context).textTheme.headlineMedium,
    +                  textAlign: TextAlign.center,
                     ),
    -              ),
    +                Text(
    +                  S.of(context).kycMergeProcessingDescription,
    +                  textAlign: TextAlign.center,
    +                  style: Theme.of(context).textTheme.bodyMedium?.copyWith(
    +                    color: RealUnitColors.neutral500,
    +                  ),
    +                ),
    +              ],
    +            ),
    +            actions: [
                   Padding(
                     padding: const EdgeInsets.symmetric(vertical: 16.0),
                     child: AppFilledButton(
    @@ -46,7 +51,6 @@ class KycMergeProcessingPage extends StatelessWidget {
                       label: S.of(context).refresh,
                     ),
                   ),
    -              const Spacer(),
                 ],
               ),
             ),
    diff --git a/lib/screens/kyc/subpages/kyc_pending_page.dart b/lib/screens/kyc/subpages/kyc_pending_page.dart
    index 2ba1d1fbc..e34c1f8ff 100644
    --- a/lib/screens/kyc/subpages/kyc_pending_page.dart
    +++ b/lib/screens/kyc/subpages/kyc_pending_page.dart
    @@ -4,6 +4,7 @@ import 'package:realunit_wallet/generated/i18n.dart';
     import 'package:realunit_wallet/screens/kyc/cubits/kyc/kyc_cubit.dart';
     import 'package:realunit_wallet/styles/colors.dart';
     import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart';
    +import 'package:realunit_wallet/widgets/scrollable_actions_layout.dart';
     import 'package:realunit_wallet/widgets/text_substring_highlighting.dart';
     
     class KycPendingPage extends StatelessWidget {
    @@ -29,37 +30,41 @@ class KycPendingView extends StatelessWidget {
           body: Padding(
             padding: const EdgeInsets.symmetric(horizontal: 20.0),
             child: SafeArea(
    -          child: Column(
    -            spacing: 8.0,
    -            children: [
    -              const Spacer(),
    -              Text(
    -                S.of(context).kycPending,
    -                style: const TextStyle(
    -                  fontSize: 26,
    -                  fontWeight: FontWeight.w700,
    -                  height: 30 / 26,
    -                  letterSpacing: -0.52,
    +          child: ScrollableActionsLayout(
    +            centerBody: true,
    +            body: Column(
    +              spacing: 8.0,
    +              children: [
    +                Text(
    +                  S.of(context).kycPending,
    +                  style: const TextStyle(
    +                    fontSize: 26,
    +                    fontWeight: FontWeight.w700,
    +                    height: 30 / 26,
    +                    letterSpacing: -0.52,
    +                  ),
                     ),
    -              ),
    -              TextSubstringHighlighting(
    -                text: S.of(context).kycPendingDescription(pendingStep.name.toUpperCase()),
    -                textAlign: TextAlign.center,
    -                style: const TextStyle(
    -                  color: RealUnitColors.neutral500,
    -                  fontSize: 14,
    -                  height: 18 / 14,
    -                  letterSpacing: 0.0,
    -                ),
    -                highlightedText: pendingStep.name.toUpperCase(),
    -                highlightedStyle: const TextStyle(
    -                  color: RealUnitColors.neutral500,
    -                  fontWeight: FontWeight.w600,
    -                  fontSize: 14,
    -                  height: 18 / 14,
    -                  letterSpacing: 0.0,
    +                TextSubstringHighlighting(
    +                  text: S.of(context).kycPendingDescription(pendingStep.name.toUpperCase()),
    +                  textAlign: TextAlign.center,
    +                  style: const TextStyle(
    +                    color: RealUnitColors.neutral500,
    +                    fontSize: 14,
    +                    height: 18 / 14,
    +                    letterSpacing: 0.0,
    +                  ),
    +                  highlightedText: pendingStep.name.toUpperCase(),
    +                  highlightedStyle: const TextStyle(
    +                    color: RealUnitColors.neutral500,
    +                    fontWeight: FontWeight.w600,
    +                    fontSize: 14,
    +                    height: 18 / 14,
    +                    letterSpacing: 0.0,
    +                  ),
                     ),
    -              ),
    +              ],
    +            ),
    +            actions: [
                   Padding(
                     padding: const .symmetric(vertical: 16.0),
                     child: AppFilledButton(
    @@ -67,7 +72,6 @@ class KycPendingView extends StatelessWidget {
                       label: S.of(context).refresh,
                     ),
                   ),
    -              const Spacer(),
                 ],
               ),
             ),
    diff --git a/lib/screens/onboarding/onboarding_completed_page.dart b/lib/screens/onboarding/onboarding_completed_page.dart
    index 5c3359d83..d97503115 100644
    --- a/lib/screens/onboarding/onboarding_completed_page.dart
    +++ b/lib/screens/onboarding/onboarding_completed_page.dart
    @@ -5,6 +5,7 @@ import 'package:realunit_wallet/generated/i18n.dart';
     import 'package:realunit_wallet/screens/home/bloc/home_bloc.dart';
     import 'package:realunit_wallet/styles/colors.dart';
     import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart';
    +import 'package:realunit_wallet/widgets/scrollable_actions_layout.dart';
     
     class OnboardingCompletedPage extends StatelessWidget {
       const OnboardingCompletedPage({super.key});
    @@ -19,57 +20,49 @@ class OnboardingCompletedPage extends StatelessWidget {
           body: SafeArea(
             child: Padding(
               padding: const .symmetric(horizontal: 20.0),
    -          child: LayoutBuilder(
    -            builder: (context, constraint) {
    -              return SingleChildScrollView(
    -                child: ConstrainedBox(
    -                  constraints: BoxConstraints(minHeight: constraint.maxHeight),
    -                  child: IntrinsicHeight(
    -                    child: Column(
    -                      children: [
    -                        Padding(
    -                          padding: const .all(32.0),
    -                          child: SvgPicture.asset(
    -                            'assets/images/illustrations/realu_token.svg',
    -                            height: 216,
    -                          ),
    -                        ),
    -                        const SizedBox(height: 40.0),
    -                        Column(
    -                          spacing: 8.0,
    -                          children: [
    -                            Text(
    -                              s.onboardingCompletedTitle,
    -                              textAlign: .center,
    -                              style: Theme.of(context).textTheme.headlineMedium,
    -                            ),
    -                            Text(
    -                              s.onboardingCompletedSubtitle,
    -                              textAlign: .center,
    -                              style:
    -                                  Theme.of(
    -                                    context,
    -                                  ).textTheme.bodyMedium?.copyWith(
    -                                    color: RealUnitColors.neutral500,
    -                                  ),
    -                            ),
    -                          ],
    -                        ),
    -                        const Spacer(),
    -                        Padding(
    -                          padding: const .symmetric(vertical: 20),
    -                          child: AppFilledButton(
    -                            onPressed: () =>
    -                                context.read().add(const CompleteOnboardingEvent()),
    -                            label: s.next,
    -                          ),
    -                        ),
    -                      ],
    -                    ),
    +          child: ScrollableActionsLayout(
    +            body: Column(
    +              mainAxisAlignment: .start,
    +              children: [
    +                Padding(
    +                  padding: const .all(32.0),
    +                  child: SvgPicture.asset(
    +                    'assets/images/illustrations/realu_token.svg',
    +                    height: 216,
                       ),
                     ),
    -              );
    -            },
    +                const SizedBox(height: 40.0),
    +                Column(
    +                  spacing: 8.0,
    +                  children: [
    +                    Text(
    +                      s.onboardingCompletedTitle,
    +                      textAlign: .center,
    +                      style: Theme.of(context).textTheme.headlineMedium,
    +                    ),
    +                    Text(
    +                      s.onboardingCompletedSubtitle,
    +                      textAlign: .center,
    +                      style: Theme.of(
    +                        context,
    +                      ).textTheme.bodyMedium?.copyWith(
    +                        color: RealUnitColors.neutral500,
    +                      ),
    +                    ),
    +                  ],
    +                ),
    +              ],
    +            ),
    +            actions: [
    +              Padding(
    +                padding: const .symmetric(vertical: 20),
    +                child: AppFilledButton(
    +                  onPressed: () =>
    +                      context.read().add(const CompleteOnboardingEvent()),
    +                  label: s.next,
    +                ),
    +              ),
    +            ],
               ),
             ),
           ),
    diff --git a/lib/screens/pin/bloc/auth/pin_auth_cubit.dart b/lib/screens/pin/bloc/auth/pin_auth_cubit.dart
    index a522f124b..c4e321efc 100644
    --- a/lib/screens/pin/bloc/auth/pin_auth_cubit.dart
    +++ b/lib/screens/pin/bloc/auth/pin_auth_cubit.dart
    @@ -15,6 +15,7 @@ class PinAuthCubit extends Cubit {
       final SecureStorage _secureStorage;
     
       DateTime? _lastBackgroundTime;
    +  String? _resumeLocation;
     
       Future initialize() async {
         final isPinSetup = await _secureStorage.hasPinHash();
    @@ -32,7 +33,24 @@ class PinAuthCubit extends Cubit {
     
       void onPinVerified() => emit(state.copyWith(isPinVerified: true));
     
    -  void onAppHidden() => _lastBackgroundTime ??= clock.now();
    +  /// Captures the background timestamp (for the re-lock timeout) and the route
    +  /// to restore after any resulting PIN re-lock. The timestamp uses `??=` — only
    +  /// the first backgrounding of an episode arms the timeout. The resume route
    +  /// instead takes the freshest non-null value: the caller passes null when the
    +  /// app is backgrounded on a gate route (see `lifecycle_initializer`), so a
    +  /// nested re-lock — backgrounding again while `/verifyPin` is showing — keeps
    +  /// the good in-flight capture instead of overwriting it with the gate.
    +  void onAppHidden(String? currentLocation) {
    +    _lastBackgroundTime ??= clock.now();
    +    if (currentLocation != null) _resumeLocation = currentLocation;
    +  }
    +
    +  /// The captured pre-background route, or null. Read (not consumed) here; the
    +  /// consumer (`_navigate`) clears it via [clearResumeLocation] once it has
    +  /// either restored the route or reached a final landing.
    +  String? peekResumeLocation() => _resumeLocation;
    +
    +  void clearResumeLocation() => _resumeLocation = null;
     
       void onAppResumed() {
         if (!state.isPinSetup) return;
    @@ -43,6 +61,13 @@ class PinAuthCubit extends Cubit {
         final elapsed = clock.now().difference(lastBackground);
         if (elapsed >= lockoutDuration) {
           emit(state.copyWith(isPinVerified: false));
    +    } else if (state.isPinVerified) {
    +      // The episode ended without a re-lock — nothing will consume the capture,
    +      // so drop it eagerly, or a much later unrelated re-lock could restore a
    +      // route from a long-finished episode. Kept while the PIN gate is showing
    +      // (isPinVerified == false): a short away-switch on `/verifyPin` is the
    +      // nested re-lock whose in-flight capture must survive.
    +      _resumeLocation = null;
         }
         _lastBackgroundTime = null;
       }
    @@ -53,6 +78,7 @@ class PinAuthCubit extends Cubit {
           _secureStorage.deleteBiometricEnabled(),
           _secureStorage.resetPinLockout(),
         ]);
    +    _resumeLocation = null;
         emit(const PinAuthState());
       }
     }
    diff --git a/lib/screens/pin/setup_pin_page.dart b/lib/screens/pin/setup_pin_page.dart
    index 5f32e5f85..8bd99b477 100644
    --- a/lib/screens/pin/setup_pin_page.dart
    +++ b/lib/screens/pin/setup_pin_page.dart
    @@ -12,6 +12,7 @@ import 'package:realunit_wallet/screens/pin/widgets/verifying_indicator.dart';
     import 'package:realunit_wallet/setup/di.dart';
     import 'package:realunit_wallet/styles/colors.dart';
     import 'package:realunit_wallet/widgets/number_pad.dart';
    +import 'package:realunit_wallet/widgets/scrollable_actions_layout.dart';
     
     class SetupPinPage extends StatelessWidget {
       /// Invoked once the PIN is stored instead of the default onboarding
    @@ -84,92 +85,75 @@ class SetupPinView extends StatelessWidget {
               ),
               backgroundColor: RealUnitColors.brand700,
               body: SafeArea(
    -            child: LayoutBuilder(
    -              builder: (context, constraints) {
    -                return SingleChildScrollView(
    -                  physics: const BouncingScrollPhysics(),
    -                  child: ConstrainedBox(
    -                    constraints: BoxConstraints(
    -                      minHeight: constraints.maxHeight,
    +            child: ScrollableActionsLayout(
    +              centerBody: true,
    +              body: Padding(
    +                padding: const EdgeInsets.symmetric(horizontal: 20.0),
    +                child: Column(
    +                  mainAxisSize: MainAxisSize.min,
    +                  children: [
    +                    Column(
    +                      spacing: 8.0,
    +                      children: [
    +                        Text(
    +                          switch (state.mode) {
    +                            .create => S.of(context).pinCreate,
    +                            .confirm => S.of(context).pinConfirm,
    +                          },
    +                          textAlign: TextAlign.center,
    +                          style: Theme.of(context).textTheme.headlineMedium,
    +                        ),
    +                        Text(
    +                          switch (state.mode) {
    +                            .create => S.of(context).pinCreateDescription('$pinLength'),
    +                            .confirm => S.of(context).pinConfirmDescription,
    +                          },
    +                          textAlign: TextAlign.center,
    +                          style: Theme.of(context).textTheme.bodyMedium?.copyWith(
    +                            color: RealUnitColors.neutral500,
    +                          ),
    +                        ),
    +                      ],
                         ),
    -                    child: IntrinsicHeight(
    -                      child: Column(
    -                        spacing: 4.0,
    -                        children: [
    -                          const Spacer(),
    -                          Padding(
    -                            padding: const .symmetric(horizontal: 20.0),
    -                            child: Column(
    -                              mainAxisSize: .min,
    -                              children: [
    -                                Column(
    -                                  spacing: 8.0,
    -                                  children: [
    -                                    Text(
    -                                      switch (state.mode) {
    -                                        .create => S.of(context).pinCreate,
    -                                        .confirm => S.of(context).pinConfirm,
    -                                      },
    -                                      textAlign: .center,
    -                                      style: Theme.of(context).textTheme.headlineMedium,
    -                                    ),
    -                                    Text(
    -                                      switch (state.mode) {
    -                                        .create => S.of(context).pinCreateDescription('$pinLength'),
    -                                        .confirm => S.of(context).pinConfirmDescription,
    -                                      },
    -                                      textAlign: .center,
    -                                      style: Theme.of(context).textTheme.bodyMedium?.copyWith(
    -                                        color: RealUnitColors.neutral500,
    -                                      ),
    -                                    ),
    -                                  ],
    -                                ),
    -                                const SizedBox(height: 32),
    -                                Column(
    -                                  spacing: 16.0,
    -                                  children: [
    -                                    PinIndicator(
    -                                      pinLength: state.currentPin.length,
    -                                      expectedPinLength: pinLength,
    -                                      wrongPin: state.mismatch,
    -                                    ),
    -                                    Visibility(
    -                                      visible: state.mismatch || state.storeFailed,
    -                                      maintainSize: true,
    -                                      maintainAnimation: true,
    -                                      maintainState: true,
    -                                      child: Text(
    -                                        state.storeFailed
    -                                            ? S.of(context).pinSaveFailed
    -                                            : S.of(context).pinConfirmFailed,
    -                                        style: Theme.of(context).textTheme.bodyMedium?.copyWith(
    -                                          color: RealUnitColors.status.red600,
    -                                        ),
    -                                        textAlign: .center,
    -                                      ),
    -                                    ),
    -                                  ],
    -                                ),
    -                              ],
    +                    const SizedBox(height: 32),
    +                    Column(
    +                      spacing: 16.0,
    +                      children: [
    +                        PinIndicator(
    +                          pinLength: state.currentPin.length,
    +                          expectedPinLength: pinLength,
    +                          wrongPin: state.mismatch,
    +                        ),
    +                        Visibility(
    +                          visible: state.mismatch || state.storeFailed,
    +                          maintainSize: true,
    +                          maintainAnimation: true,
    +                          maintainState: true,
    +                          child: Text(
    +                            state.storeFailed
    +                                ? S.of(context).pinSaveFailed
    +                                : S.of(context).pinConfirmFailed,
    +                            style: Theme.of(context).textTheme.bodyMedium?.copyWith(
    +                              color: RealUnitColors.status.red600,
                                 ),
    +                            textAlign: TextAlign.center,
                               ),
    -                          const Spacer(),
    -                          if (state.isSubmitting)
    -                            VerifyingIndicator(label: S.of(context).pinSaving)
    -                          else
    -                            NumberPad(
    -                              onNumberPressed: (digit) =>
    -                                  context.read().addDigit(digit),
    -                              onDeletePressed: context.read().deleteDigit,
    -                            ),
    -                          const SizedBox(height: 60.0),
    -                        ],
    -                      ),
    +                        ),
    +                      ],
                         ),
    +                  ],
    +                ),
    +              ),
    +              actions: [
    +                if (state.isSubmitting)
    +                  VerifyingIndicator(label: S.of(context).pinSaving)
    +                else
    +                  NumberPad(
    +                    onNumberPressed: (digit) => context.read().addDigit(digit),
    +                    onDeletePressed: context.read().deleteDigit,
                       ),
    -                );
    -              },
    +                const SizedBox(height: 60.0),
    +              ],
                 ),
               ),
             );
    diff --git a/lib/screens/pin/verify_pin_page.dart b/lib/screens/pin/verify_pin_page.dart
    index 50d6be429..702e8a0d6 100644
    --- a/lib/screens/pin/verify_pin_page.dart
    +++ b/lib/screens/pin/verify_pin_page.dart
    @@ -16,6 +16,7 @@ import 'package:realunit_wallet/setup/di.dart';
     import 'package:realunit_wallet/styles/colors.dart';
     import 'package:realunit_wallet/widgets/buttons/app_text_button.dart';
     import 'package:realunit_wallet/widgets/number_pad.dart';
    +import 'package:realunit_wallet/widgets/scrollable_actions_layout.dart';
     
     class VerifyPinParams {
       final String? description;
    @@ -116,121 +117,116 @@ class _VerifyPinViewState extends State {
             appBar: AppBar(),
             backgroundColor: RealUnitColors.brand700,
             body: SafeArea(
    -          child: LayoutBuilder(
    -            builder: (context, constraints) => SingleChildScrollView(
    -              child: ConstrainedBox(
    -                constraints: BoxConstraints(minHeight: constraints.maxHeight),
    -                child: IntrinsicHeight(
    +          child: ScrollableActionsLayout(
    +            centerBody: true,
    +            body: Column(
    +              spacing: 4.0,
    +              children: [
    +                Padding(
    +                  padding: const EdgeInsets.symmetric(horizontal: 20.0),
                       child: Column(
    -                    spacing: 4.0,
                         children: [
    -                      const Spacer(),
    -                      Padding(
    -                        padding: const EdgeInsets.symmetric(horizontal: 20.0),
    -                        child: Column(
    -                          mainAxisAlignment: MainAxisAlignment.center,
    -                          children: [
    -                            Column(
    -                              spacing: 8.0,
    -                              children: [
    -                                Text(
    -                                  S.of(context).pinVerify,
    -                                  textAlign: TextAlign.center,
    -                                  style: Theme.of(context).textTheme.headlineMedium,
    -                                ),
    -                                Text(
    -                                  widget.description ?? S.of(context).pinVerifyDescription,
    -                                  textAlign: TextAlign.center,
    -                                  style: Theme.of(context).textTheme.bodyMedium?.copyWith(
    -                                    color: RealUnitColors.neutral500,
    -                                  ),
    -                                ),
    -                              ],
    +                      Column(
    +                        spacing: 8.0,
    +                        children: [
    +                          Text(
    +                            S.of(context).pinVerify,
    +                            textAlign: TextAlign.center,
    +                            style: Theme.of(context).textTheme.headlineMedium,
    +                          ),
    +                          Text(
    +                            widget.description ?? S.of(context).pinVerifyDescription,
    +                            textAlign: TextAlign.center,
    +                            style: Theme.of(context).textTheme.bodyMedium?.copyWith(
    +                              color: RealUnitColors.neutral500,
                                 ),
    -                            const SizedBox(height: 32),
    -                            Column(
    -                              spacing: 16.0,
    -                              children: [
    -                                PinIndicator(
    -                                  pinLength: isVerifying ? pinLength : state.pin.length,
    -                                  expectedPinLength: pinLength,
    -                                  wrongPin: state is VerifyPinFailure || isLocked || isUnverifiable,
    -                                ),
    -                                Visibility(
    -                                  visible: showsError,
    -                                  maintainSize: true,
    -                                  maintainAnimation: true,
    -                                  maintainState: true,
    -                                  child: ConstrainedBox(
    -                                    constraints: const BoxConstraints(minHeight: 40.0),
    -                                    child: Text(
    -                                      switch (state) {
    -                                        VerifyPinTemporarilyLocked s =>
    -                                          S
    -                                              .of(context)
    -                                              .pinVerifyLockedTemporarily(
    -                                                _formatRemaining(s.lockedUntil),
    -                                              ),
    -                                        VerifyPinLocked _ => S.of(context).pinVerifyLocked,
    -                                        // The 'Forgot PIN?' button only exists in
    -                                        // the appLock variant (bottom != null);
    -                                        // gate flows get a text without that
    -                                        // dangling button reference.
    -                                        VerifyPinUnverifiable _ => widget.bottom != null
    -                                            ? S.of(context).pinVerifyUnverifiable
    -                                            : S.of(context).pinVerifyUnverifiableGate,
    -                                        _ => S.of(context).pinVerifyFailed,
    -                                      },
    -                                      style: Theme.of(context).textTheme.bodyMedium?.copyWith(
    -                                        color: RealUnitColors.status.red600,
    -                                      ),
    -                                      textAlign: TextAlign.center,
    -                                    ),
    -                                  ),
    +                          ),
    +                        ],
    +                      ),
    +                      const SizedBox(height: 32),
    +                      Column(
    +                        spacing: 16.0,
    +                        children: [
    +                          PinIndicator(
    +                            pinLength: isVerifying ? pinLength : state.pin.length,
    +                            expectedPinLength: pinLength,
    +                            wrongPin: state is VerifyPinFailure || isLocked || isUnverifiable,
    +                          ),
    +                          Visibility(
    +                            visible: showsError,
    +                            maintainSize: true,
    +                            maintainAnimation: true,
    +                            maintainState: true,
    +                            child: ConstrainedBox(
    +                              constraints: const BoxConstraints(minHeight: 40.0),
    +                              child: Text(
    +                                switch (state) {
    +                                  VerifyPinTemporarilyLocked s =>
    +                                    S
    +                                        .of(context)
    +                                        .pinVerifyLockedTemporarily(
    +                                          _formatRemaining(s.lockedUntil),
    +                                        ),
    +                                  VerifyPinLocked _ => S.of(context).pinVerifyLocked,
    +                                  // The 'Forgot PIN?' button only exists in
    +                                  // the appLock variant (bottom != null);
    +                                  // gate flows get a text without that
    +                                  // dangling button reference.
    +                                  VerifyPinUnverifiable _ => widget.bottom != null
    +                                      ? S.of(context).pinVerifyUnverifiable
    +                                      : S.of(context).pinVerifyUnverifiableGate,
    +                                  _ => S.of(context).pinVerifyFailed,
    +                                },
    +                                style: Theme.of(context).textTheme.bodyMedium?.copyWith(
    +                                  color: RealUnitColors.status.red600,
                                     ),
    -                                if (biometricHint != null)
    -                                  Text(
    -                                    biometricHint,
    -                                    textAlign: TextAlign.center,
    -                                    style: Theme.of(context).textTheme.bodySmall?.copyWith(
    -                                      color: RealUnitColors.neutral500,
    -                                    ),
    -                                  ),
    -                              ],
    +                                textAlign: TextAlign.center,
    +                              ),
    +                            ),
    +                          ),
    +                          if (biometricHint != null)
    +                            Text(
    +                              biometricHint,
    +                              textAlign: TextAlign.center,
    +                              style: Theme.of(context).textTheme.bodySmall?.copyWith(
    +                                color: RealUnitColors.neutral500,
    +                              ),
                                 ),
    -                          ],
    -                        ),
    +                        ],
                           ),
    -                      const Spacer(),
    -                      if (isVerifying)
    -                        const VerifyingIndicator()
    -                      else
    -                        NumberPad(
    -                          onNumberPressed: context.read().addDigit,
    -                          onDeletePressed: context.read().deleteDigit,
    -                          inputEnabled: !(isLocked || isUnverifiable),
    -                          onBiometricPressed: biometricAvailable
    -                              ? context.read().promptBiometric
    -                              : null,
    -                          biometricIcon: biometricAvailable
    -                              ? Icon(
    -                                  // Face-ID phones expect a face glyph; everything
    -                                  // else keeps the fingerprint affordance.
    -                                  Theme.of(context).platform == TargetPlatform.iOS
    -                                      ? Icons.face
    -                                      : Icons.fingerprint,
    -                                  size: 28,
    -                                  color: RealUnitColors.realUnitBlack,
    -                                  semanticLabel: S.of(context).pinVerifyBiometricButton,
    -                                )
    -                              : null,
    -                        ),
    -                      if (widget.bottom != null) widget.bottom! else const SizedBox(height: 60.0),
                         ],
                       ),
                     ),
    -              ),
    +              ],
                 ),
    +            actions: [
    +              // Sticky primary surface: NumberPad must stay fully visible at
    +              // high text scales (shape 2 — not inside the scrollable body).
    +              if (isVerifying)
    +                const VerifyingIndicator()
    +              else
    +                NumberPad(
    +                  onNumberPressed: context.read().addDigit,
    +                  onDeletePressed: context.read().deleteDigit,
    +                  inputEnabled: !(isLocked || isUnverifiable),
    +                  onBiometricPressed: biometricAvailable
    +                      ? context.read().promptBiometric
    +                      : null,
    +                  biometricIcon: biometricAvailable
    +                      ? Icon(
    +                          // Face-ID phones expect a face glyph; everything
    +                          // else keeps the fingerprint affordance.
    +                          Theme.of(context).platform == TargetPlatform.iOS
    +                              ? Icons.face
    +                              : Icons.fingerprint,
    +                          size: 28,
    +                          color: RealUnitColors.realUnitBlack,
    +                          semanticLabel: S.of(context).pinVerifyBiometricButton,
    +                        )
    +                      : null,
    +                ),
    +              widget.bottom ?? const SizedBox(height: 60.0),
    +            ],
               ),
             ),
           );
    diff --git a/lib/screens/pin/widgets/enable_biometric_bottom_sheet.dart b/lib/screens/pin/widgets/enable_biometric_bottom_sheet.dart
    index b6e106dfe..b37c582e9 100644
    --- a/lib/screens/pin/widgets/enable_biometric_bottom_sheet.dart
    +++ b/lib/screens/pin/widgets/enable_biometric_bottom_sheet.dart
    @@ -3,6 +3,7 @@ import 'package:realunit_wallet/generated/i18n.dart';
     import 'package:realunit_wallet/styles/colors.dart';
     import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart';
     import 'package:realunit_wallet/widgets/buttons/app_text_button.dart';
    +import 'package:realunit_wallet/widgets/scrollable_actions_layout.dart';
     
     class EnableBiometricBottomSheet extends StatelessWidget {
       const EnableBiometricBottomSheet({super.key});
    @@ -10,51 +11,58 @@ class EnableBiometricBottomSheet extends StatelessWidget {
       @override
       Widget build(BuildContext context) {
         return SafeArea(
    -      child: Padding(
    -        padding: const EdgeInsets.all(20.0),
    -        child: Column(
    -          spacing: 32.0,
    -          mainAxisSize: MainAxisSize.min,
    -          children: [
    -            const Icon(
    -              Icons.fingerprint,
    -              size: 64,
    -              color: RealUnitColors.realUnitBlue,
    -            ),
    -            Column(
    -              spacing: 8.0,
    -              children: [
    -                Text(
    -                  S.of(context).biometricAuthenticationActivate,
    -                  style: const TextStyle(
    -                    fontSize: 20,
    -                    fontWeight: FontWeight.w700,
    -                    color: RealUnitColors.realUnitBlack,
    +      child: ConstrainedBox(
    +        constraints: BoxConstraints(
    +          maxHeight: MediaQuery.sizeOf(context).height * 0.9,
    +        ),
    +        child: ScrollableActionsLayout(
    +          shrinkWrap: true,
    +          padding: const EdgeInsets.all(20.0),
    +          actionsSpacing: 8.0,
    +          body: Column(
    +            spacing: 32.0,
    +            mainAxisSize: MainAxisSize.min,
    +            children: [
    +              const Icon(
    +                Icons.fingerprint,
    +                size: 64,
    +                color: RealUnitColors.realUnitBlue,
    +              ),
    +              Column(
    +                spacing: 8.0,
    +                children: [
    +                  Text(
    +                    S.of(context).biometricAuthenticationActivate,
    +                    style: const TextStyle(
    +                      fontSize: 20,
    +                      fontWeight: FontWeight.w700,
    +                      color: RealUnitColors.realUnitBlack,
    +                    ),
    +                    textAlign: TextAlign.center,
                       ),
    -                  textAlign: TextAlign.center,
    -                ),
    -                Text(
    -                  S.of(context).biometricAuthenticationActivateDescription,
    -                  style: const TextStyle(
    -                    fontSize: 14,
    -                    color: RealUnitColors.neutral500,
    +                  Text(
    +                    S.of(context).biometricAuthenticationActivateDescription,
    +                    style: const TextStyle(
    +                      fontSize: 14,
    +                      color: RealUnitColors.neutral500,
    +                    ),
    +                    textAlign: TextAlign.center,
                       ),
    -                  textAlign: TextAlign.center,
    -                ),
    -              ],
    +                ],
    +              ),
    +            ],
    +          ),
    +          actions: [
    +            Padding(
    +              padding: const EdgeInsets.only(top: 32.0),
    +              child: AppFilledButton(
    +                onPressed: () => Navigator.of(context).pop(true),
    +                label: S.of(context).enable,
    +              ),
                 ),
    -            Column(
    -              spacing: 8.0,
    -              children: [
    -                AppFilledButton(
    -                  onPressed: () => Navigator.of(context).pop(true),
    -                  label: S.of(context).enable,
    -                ),
    -                AppTextButton(
    -                  onPressed: () => Navigator.of(context).pop(false),
    -                  label: S.of(context).skip,
    -                ),
    -              ],
    +            AppTextButton(
    +              onPressed: () => Navigator.of(context).pop(false),
    +              label: S.of(context).skip,
                 ),
               ],
             ),
    diff --git a/lib/screens/pin/widgets/forgot_pin_bottom_sheet.dart b/lib/screens/pin/widgets/forgot_pin_bottom_sheet.dart
    index 389ad979e..af85d7919 100644
    --- a/lib/screens/pin/widgets/forgot_pin_bottom_sheet.dart
    +++ b/lib/screens/pin/widgets/forgot_pin_bottom_sheet.dart
    @@ -4,6 +4,7 @@ import 'package:realunit_wallet/generated/i18n.dart';
     import 'package:realunit_wallet/styles/colors.dart';
     import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart';
     import 'package:realunit_wallet/widgets/handlebars.dart';
    +import 'package:realunit_wallet/widgets/scrollable_actions_layout.dart';
     
     class ForgotPinBottomSheet extends StatelessWidget {
       const ForgotPinBottomSheet({super.key});
    @@ -11,19 +12,22 @@ class ForgotPinBottomSheet extends StatelessWidget {
       @override
       Widget build(BuildContext context) {
         return SafeArea(
    -      child: SizedBox(
    -        width: double.infinity,
    -        child: Column(
    -          mainAxisSize: MainAxisSize.min,
    -          children: [
    -            Handlebars.horizontal(
    -              context,
    -              margin: const EdgeInsets.only(top: 5),
    -              width: 36,
    +      child: Column(
    +        mainAxisSize: MainAxisSize.min,
    +        children: [
    +          Handlebars.horizontal(
    +            context,
    +            margin: const EdgeInsets.only(top: 5),
    +            width: 36,
    +          ),
    +          ConstrainedBox(
    +            constraints: BoxConstraints(
    +              maxHeight: MediaQuery.sizeOf(context).height * 0.9,
                 ),
    -            Padding(
    +            child: ScrollableActionsLayout(
    +              shrinkWrap: true,
                   padding: const EdgeInsets.symmetric(vertical: 40.0, horizontal: 30.0),
    -              child: Column(
    +              body: Column(
                     mainAxisSize: MainAxisSize.min,
                     children: [
                       Text(
    @@ -47,13 +51,17 @@ class ForgotPinBottomSheet extends StatelessWidget {
                           letterSpacing: 0.0,
                         ),
                       ),
    -                  const SizedBox(height: 28),
    -                  Row(
    +                ],
    +              ),
    +              actions: [
    +                Padding(
    +                  padding: const EdgeInsets.only(top: 28.0),
    +                  child: Row(
                         spacing: 12.0,
                         children: [
                           Expanded(
                             child: AppFilledButton(
    -                          variant: .secondary,
    +                          variant: FilledButtonVariant.secondary,
                               onPressed: () => context.pop(false),
                               label: S.of(context).close,
                             ),
    @@ -66,11 +74,11 @@ class ForgotPinBottomSheet extends StatelessWidget {
                           ),
                         ],
                       ),
    -                ],
    -              ),
    +                ),
    +              ],
                 ),
    -          ],
    -        ),
    +          ),
    +        ],
           ),
         );
       }
    diff --git a/lib/screens/restore_wallet/restore_wallet_view.dart b/lib/screens/restore_wallet/restore_wallet_view.dart
    index e5e51483b..7cf6f2b79 100644
    --- a/lib/screens/restore_wallet/restore_wallet_view.dart
    +++ b/lib/screens/restore_wallet/restore_wallet_view.dart
    @@ -11,6 +11,7 @@ import 'package:realunit_wallet/screens/restore_wallet/widgets/restore_wallet_in
     import 'package:realunit_wallet/styles/colors.dart';
     import 'package:realunit_wallet/styles/icons.dart';
     import 'package:realunit_wallet/widgets/mnemonic_field.dart';
    +import 'package:realunit_wallet/widgets/scrollable_actions_layout.dart';
     import 'package:realunit_wallet/widgets/text_substring_highlighting.dart';
     
     class RestoreWalletView extends StatefulWidget {
    @@ -63,66 +64,60 @@ class _RestoreWalletViewState extends State {
               behavior: HitTestBehavior.opaque,
               child: Padding(
                 padding: const EdgeInsets.symmetric(horizontal: 20),
    -            child: LayoutBuilder(
    -              builder: (context, constraint) {
    -                return SingleChildScrollView(
    -                  child: ConstrainedBox(
    -                    constraints: BoxConstraints(minHeight: constraint.maxHeight),
    -                    child: IntrinsicHeight(
    -                      child: Column(
    -                        children: [
    -                          SvgPicture.asset(
    -                            'assets/images/illustrations/restore_wallet.svg',
    -                            height: 124,
    -                          ),
    -                          const SizedBox(
    -                            height: 20,
    -                          ),
    -                          Text(
    -                            S.of(context).restoreWallet,
    -                            style: const TextStyle(
    -                              fontSize: 26,
    -                              color: RealUnitColors.realUnitBlack,
    -                              fontWeight: FontWeight.bold,
    -                              height: 30 / 26,
    -                              letterSpacing: -0.52,
    -                            ),
    -                          ),
    -                          const SizedBox(
    -                            height: 40,
    -                          ),
    -                          Row(
    -                            spacing: 8.0,
    -                            crossAxisAlignment: CrossAxisAlignment.start,
    -                            children: [
    -                              const RecoveryKeyIcon(size: 20, color: RealUnitColors.realUnitBlue),
    -                              Expanded(
    -                                child: TextSubstringHighlighting(
    -                                  text: S.of(context).restoreWalletFromSeedDescription,
    -                                  style: const TextStyle(
    -                                    fontSize: 15,
    -                                    color: RealUnitColors.neutral500,
    -                                  ),
    -                                  highlightedText: '12 ${S.of(context).recoveryWords}',
    -                                ),
    -                              ),
    -                            ],
    -                          ),
    -                          const SizedBox(height: 16),
    -                          RestoreWalletInputField(
    -                            controllers: _controllers,
    -                            focusNodes: _focusNodes,
    -                          ),
    -                          const Spacer(),
    -                          RestoreWalletButton(
    -                            controllers: _controllers,
    +            child: ScrollableActionsLayout(
    +              centerBody: false,
    +              body: Column(
    +                mainAxisAlignment: .start,
    +                children: [
    +                  SvgPicture.asset(
    +                    'assets/images/illustrations/restore_wallet.svg',
    +                    height: 124,
    +                  ),
    +                  const SizedBox(
    +                    height: 20,
    +                  ),
    +                  Text(
    +                    S.of(context).restoreWallet,
    +                    style: const TextStyle(
    +                      fontSize: 26,
    +                      color: RealUnitColors.realUnitBlack,
    +                      fontWeight: FontWeight.bold,
    +                      height: 30 / 26,
    +                      letterSpacing: -0.52,
    +                    ),
    +                  ),
    +                  const SizedBox(
    +                    height: 40,
    +                  ),
    +                  Row(
    +                    spacing: 8.0,
    +                    crossAxisAlignment: CrossAxisAlignment.start,
    +                    children: [
    +                      const RecoveryKeyIcon(size: 20, color: RealUnitColors.realUnitBlue),
    +                      Expanded(
    +                        child: TextSubstringHighlighting(
    +                          text: S.of(context).restoreWalletFromSeedDescription,
    +                          style: const TextStyle(
    +                            fontSize: 15,
    +                            color: RealUnitColors.neutral500,
                               ),
    -                        ],
    +                          highlightedText: '12 ${S.of(context).recoveryWords}',
    +                        ),
                           ),
    -                    ),
    +                    ],
    +                  ),
    +                  const SizedBox(height: 16),
    +                  RestoreWalletInputField(
    +                    controllers: _controllers,
    +                    focusNodes: _focusNodes,
                       ),
    -                );
    -              },
    +                ],
    +              ),
    +              actions: [
    +                RestoreWalletButton(
    +                  controllers: _controllers,
    +                ),
    +              ],
                 ),
               ),
             ),
    diff --git a/lib/screens/sell/sell_page.dart b/lib/screens/sell/sell_page.dart
    index cf27a5dea..72ce5c414 100644
    --- a/lib/screens/sell/sell_page.dart
    +++ b/lib/screens/sell/sell_page.dart
    @@ -13,6 +13,7 @@ import 'package:realunit_wallet/screens/sell/widgets/sell_bank_account_field.dar
     import 'package:realunit_wallet/screens/sell/widgets/sell_button.dart';
     import 'package:realunit_wallet/screens/sell/widgets/sell_converter.dart';
     import 'package:realunit_wallet/setup/di.dart';
    +import 'package:realunit_wallet/widgets/scrollable_actions_layout.dart';
     
     class SellPage extends StatelessWidget {
       const SellPage({super.key});
    @@ -77,34 +78,28 @@ class _SellViewState extends State {
                 child: GestureDetector(
                   behavior: .opaque,
                   onTap: () => FocusManager.instance.primaryFocus?.unfocus(),
    -              child: LayoutBuilder(
    -                builder: (context, constraints) {
    -                  return SingleChildScrollView(
    -                    child: ConstrainedBox(
    -                      constraints: BoxConstraints(minHeight: constraints.maxHeight),
    -                      child: IntrinsicHeight(
    -                        child: Padding(
    -                          padding: const .symmetric(horizontal: 20.0),
    -                          child: Column(
    -                            spacing: 32,
    -                            children: [
    -                              SellConverter(
    -                                amountController: _amountController,
    -                                resultController: _resultController,
    -                              ),
    -                              const SellBankAccountField(),
    -                              const Spacer(),
    -                              SellButton(
    -                                amount: _amountController.text,
    -                                bankAccount: context.watch().state,
    -                              ),
    -                            ],
    -                          ),
    -                        ),
    -                      ),
    +              child: ScrollableActionsLayout(
    +                centerBody: false,
    +                // Horizontal 20 applied to body + sticky actions so the CTA
    +                // stays inset like the pre-migration Column-wide Padding.
    +                padding: const .symmetric(horizontal: 20.0),
    +                body: Column(
    +                  mainAxisAlignment: .start,
    +                  spacing: 32,
    +                  children: [
    +                    SellConverter(
    +                      amountController: _amountController,
    +                      resultController: _resultController,
                         ),
    -                  );
    -                },
    +                    const SellBankAccountField(),
    +                  ],
    +                ),
    +                actions: [
    +                  SellButton(
    +                    amount: _amountController.text,
    +                    bankAccount: context.watch().state,
    +                  ),
    +                ],
                   ),
                 ),
               );
    diff --git a/lib/screens/sell/widgets/sell_button.dart b/lib/screens/sell/widgets/sell_button.dart
    index aedefcfdc..f4227d759 100644
    --- a/lib/screens/sell/widgets/sell_button.dart
    +++ b/lib/screens/sell/widgets/sell_button.dart
    @@ -79,6 +79,7 @@ class SellButton extends StatelessWidget {
                 if (confirmedSuccess ?? false) {
                   if (context.mounted) {
                     await showModalBottomSheet(
    +                  isScrollControlled: true,
                       context: context,
                       builder: (_) => const SellExecutedSheet(),
                     );
    diff --git a/lib/screens/sell/widgets/sell_confirm_sheet.dart b/lib/screens/sell/widgets/sell_confirm_sheet.dart
    index dc59266d3..178d8854b 100644
    --- a/lib/screens/sell/widgets/sell_confirm_sheet.dart
    +++ b/lib/screens/sell/widgets/sell_confirm_sheet.dart
    @@ -10,6 +10,7 @@ import 'package:realunit_wallet/setup/di.dart';
     import 'package:realunit_wallet/styles/colors.dart';
     import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart';
     import 'package:realunit_wallet/widgets/handlebars.dart';
    +import 'package:realunit_wallet/widgets/scrollable_actions_layout.dart';
     
     class SellConfirmSheet extends StatelessWidget {
       final SellPaymentInfo paymentInfo;
    @@ -59,121 +60,72 @@ class SellConfirmSheetView extends StatelessWidget {
                   mainAxisSize: MainAxisSize.min,
                   children: [
                     Handlebars.horizontal(context, margin: const EdgeInsets.only(top: 5), width: 36),
    -                Padding(
    -                  padding: const EdgeInsets.symmetric(vertical: 24.0),
    -                  child: Text(
    -                    S.of(context).sellReviewAndConfirm,
    -                    style: const TextStyle(
    -                      fontSize: 26,
    -                      fontWeight: FontWeight.bold,
    -                    ),
    -                    textAlign: TextAlign.center,
    -                  ),
    -                ),
    -                Container(
    -                  decoration: BoxDecoration(
    -                    border: Border.all(color: RealUnitColors.neutral200),
    -                    borderRadius: BorderRadius.circular(16.0),
    +                // Handlebar stays OUTSIDE the scrollable body (sibling above it) —
    +                // it must never scroll away with the content.
    +                ConstrainedBox(
    +                  constraints: BoxConstraints(
    +                    maxHeight: MediaQuery.sizeOf(context).height * 0.9,
                       ),
    -                  child: Column(
    -                    crossAxisAlignment: CrossAxisAlignment.start,
    -                    children: _withDividers(
    +                  child: ScrollableActionsLayout(
    +                    shrinkWrap: true,
    +                    body: Column(
    +                      mainAxisSize: MainAxisSize.min,
                           children: [
                             Padding(
    -                          padding: const EdgeInsets.symmetric(
    -                            vertical: 12.0,
    -                            horizontal: 20.0,
    -                          ),
    -                          child: Row(
    -                            mainAxisAlignment: MainAxisAlignment.spaceBetween,
    -                            children: [
    -                              Text(
    -                                realUnitAsset.symbol,
    -                                style: const TextStyle(
    -                                  color: RealUnitColors.neutral500,
    -                                  height: 18 / 14,
    -                                ),
    -                              ),
    -                              Text(
    -                                '${paymentInfo.amount}',
    -                                style: const TextStyle(
    -                                  fontWeight: FontWeight.w600,
    -                                  height: 18 / 14,
    -                                ),
    -                              ),
    -                            ],
    +                          padding: const EdgeInsets.symmetric(vertical: 24.0),
    +                          child: Text(
    +                            S.of(context).sellReviewAndConfirm,
    +                            style: const TextStyle(
    +                              fontSize: 26,
    +                              fontWeight: FontWeight.bold,
    +                            ),
    +                            textAlign: TextAlign.center,
                               ),
                             ),
    -                        Padding(
    -                          padding: const EdgeInsets.symmetric(
    -                            vertical: 12.0,
    -                            horizontal: 20.0,
    +                        Container(
    +                          decoration: BoxDecoration(
    +                            border: Border.all(color: RealUnitColors.neutral200),
    +                            borderRadius: BorderRadius.circular(16.0),
                               ),
    -                          child: Row(
    -                            mainAxisAlignment: MainAxisAlignment.spaceBetween,
    -                            children: [
    -                              Text(
    -                                '${S.of(context).amountIn} ${paymentInfo.currency.code}',
    -                                style: const TextStyle(
    -                                  color: RealUnitColors.neutral500,
    -                                  height: 18 / 14,
    +                          child: Column(
    +                            crossAxisAlignment: CrossAxisAlignment.start,
    +                            children: _withDividers(
    +                              children: [
    +                                _infoRow(
    +                                  label: realUnitAsset.symbol,
    +                                  value: '${paymentInfo.amount}',
                                     ),
    -                              ),
    -                              Text(
    -                                '${paymentInfo.estimatedAmount}',
    -                                style: const TextStyle(
    -                                  fontWeight: FontWeight.w600,
    -                                  height: 18 / 14,
    +                                _infoRow(
    +                                  label:
    +                                      '${S.of(context).amountIn} ${paymentInfo.currency.code}',
    +                                  value: '${paymentInfo.estimatedAmount}',
                                     ),
    -                              ),
    -                            ],
    -                          ),
    -                        ),
    -                        Padding(
    -                          padding: const EdgeInsets.symmetric(
    -                            vertical: 12.0,
    -                            horizontal: 20.0,
    -                          ),
    -                          child: Column(
    -                            children: [
    -                              Row(
    -                                mainAxisAlignment: MainAxisAlignment.spaceBetween,
    -                                children: [
    -                                  Text(
    -                                    S.of(context).receiver,
    -                                    style: const TextStyle(
    -                                      color: RealUnitColors.neutral500,
    -                                      height: 18 / 14,
    -                                    ),
    -                                  ),
    -                                  Text(
    -                                    paymentInfo.beneficiary.iban,
    -                                    style: const TextStyle(
    -                                      fontWeight: FontWeight.w600,
    -                                      height: 18 / 14,
    -                                    ),
    -                                  ),
    -                                ],
    -                              ),
    -                            ],
    +                                _infoRow(
    +                                  label: S.of(context).receiver,
    +                                  value: paymentInfo.beneficiary.iban,
    +                                ),
    +                              ],
    +                            ),
                               ),
                             ),
                           ],
                         ),
    -                  ),
    -                ),
    -                Padding(
    -                  padding: const EdgeInsets.all(16.0),
    -                  child: BlocBuilder(
    -                    builder: (context, state) {
    -                      final isLoading = state is SellConfirmLoading;
    -                      return AppFilledButton(
    -                        label: S.of(context).confirm,
    -                        onPressed: () =>
    -                            context.read().confirmPayment(paymentInfo),
    -                        state: isLoading ? .loading : .idle,
    -                      );
    -                    },
    +                    actions: [
    +                      Padding(
    +                        padding: const EdgeInsets.all(16.0),
    +                        child: BlocBuilder(
    +                          builder: (context, state) {
    +                            final isLoading = state is SellConfirmLoading;
    +                            return AppFilledButton(
    +                              label: S.of(context).confirm,
    +                              onPressed: () =>
    +                                  context.read().confirmPayment(paymentInfo),
    +                              state: isLoading ? .loading : .idle,
    +                            );
    +                          },
    +                        ),
    +                      ),
    +                    ],
                       ),
                     ),
                   ],
    @@ -184,6 +136,47 @@ class SellConfirmSheetView extends StatelessWidget {
         );
       }
     
    +  /// Label/value row: both sides are Flexible and WRAP (not ellipsize) so a long
    +  /// value (e.g. a beneficiary IBAN) always stays fully readable — the sheet
    +  /// scrolls, so extra height here is absorbed, never data loss via truncation.
    +  /// Wrapping also prevents horizontal RenderFlex overflow for either side.
    +  Widget _infoRow({required String label, required String value}) {
    +    return Padding(
    +      padding: const EdgeInsets.symmetric(
    +        vertical: 12.0,
    +        horizontal: 20.0,
    +      ),
    +      child: Row(
    +        crossAxisAlignment: CrossAxisAlignment.start,
    +        mainAxisAlignment: MainAxisAlignment.spaceBetween,
    +        children: [
    +          Flexible(
    +            child: Text(
    +              label,
    +              softWrap: true,
    +              style: const TextStyle(
    +                color: RealUnitColors.neutral500,
    +                height: 18 / 14,
    +              ),
    +            ),
    +          ),
    +          const SizedBox(width: 12),
    +          Flexible(
    +            child: Text(
    +              value,
    +              textAlign: TextAlign.end,
    +              softWrap: true,
    +              style: const TextStyle(
    +                fontWeight: FontWeight.w600,
    +                height: 18 / 14,
    +              ),
    +            ),
    +          ),
    +        ],
    +      ),
    +    );
    +  }
    +
       List _withDividers({required List children}) {
         final result = [];
         for (var i = 0; i < children.length; i++) {
    diff --git a/lib/screens/sell/widgets/sell_converter.dart b/lib/screens/sell/widgets/sell_converter.dart
    index 8fed74c4e..8d4e064c4 100644
    --- a/lib/screens/sell/widgets/sell_converter.dart
    +++ b/lib/screens/sell/widgets/sell_converter.dart
    @@ -160,25 +160,31 @@ class _SellConverterState extends State {
                               height: 24,
                               width: 24,
                             ),
    -                        Column(
    -                          crossAxisAlignment: .start,
    -                          children: [
    -                            Text(
    -                              realUnitAsset.symbol,
    -                              style: const TextStyle(
    -                                fontWeight: .bold,
    -                                fontSize: 16,
    -                                height: 20 / 16,
    +                        Expanded(
    +                          child: Column(
    +                            crossAxisAlignment: .start,
    +                            children: [
    +                              Text(
    +                                realUnitAsset.symbol,
    +                                maxLines: 1,
    +                                overflow: .ellipsis,
    +                                style: const TextStyle(
    +                                  fontWeight: .bold,
    +                                  fontSize: 16,
    +                                  height: 20 / 16,
    +                                ),
                                   ),
    -                            ),
    -                            Text(
    -                              realUnitAsset.name,
    -                              style: const TextStyle(
    -                                fontSize: 12,
    -                                height: 16 / 12,
    +                              Text(
    +                                realUnitAsset.name,
    +                                maxLines: 1,
    +                                overflow: .ellipsis,
    +                                style: const TextStyle(
    +                                  fontSize: 12,
    +                                  height: 16 / 12,
    +                                ),
                                   ),
    -                            ),
    -                          ],
    +                            ],
    +                          ),
                             ),
                           ],
                         ),
    diff --git a/lib/screens/sell/widgets/sell_executed_sheet.dart b/lib/screens/sell/widgets/sell_executed_sheet.dart
    index 8f65fbad3..3a5d70a00 100644
    --- a/lib/screens/sell/widgets/sell_executed_sheet.dart
    +++ b/lib/screens/sell/widgets/sell_executed_sheet.dart
    @@ -4,6 +4,7 @@ import 'package:realunit_wallet/generated/i18n.dart';
     import 'package:realunit_wallet/styles/colors.dart';
     import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart';
     import 'package:realunit_wallet/widgets/handlebars.dart';
    +import 'package:realunit_wallet/widgets/scrollable_actions_layout.dart';
     
     class SellExecutedSheet extends StatelessWidget {
       const SellExecutedSheet({super.key});
    @@ -16,44 +17,56 @@ class SellExecutedSheet extends StatelessWidget {
             mainAxisSize: MainAxisSize.min,
             children: [
               Handlebars.horizontal(context, margin: const EdgeInsets.only(top: 5), width: 36),
    -          Padding(
    -            padding: const EdgeInsets.symmetric(vertical: 50.0, horizontal: 30.0),
    -            child: Column(
    -              mainAxisSize: MainAxisSize.min,
    -              children: [
    -                const Icon(
    -                  Icons.check_circle_rounded,
    -                  color: RealUnitColors.realUnitBlue,
    -                  size: 64,
    -                ),
    -                const SizedBox(height: 28),
    -                Text(
    -                  S.of(context).sellSuccess,
    -                  style: const TextStyle(
    -                    fontSize: 26,
    -                    fontWeight: FontWeight.w700,
    -                    height: 30 / 26,
    -                    letterSpacing: 26 * -0.02,
    -                  ),
    +          ConstrainedBox(
    +            constraints: BoxConstraints(
    +              maxHeight: MediaQuery.sizeOf(context).height * 0.9,
    +            ),
    +            child: ScrollableActionsLayout(
    +              shrinkWrap: true,
    +              body: Padding(
    +                padding: const EdgeInsets.symmetric(vertical: 50.0, horizontal: 30.0),
    +                child: Column(
    +                  mainAxisSize: MainAxisSize.min,
    +                  children: [
    +                    const Icon(
    +                      Icons.check_circle_rounded,
    +                      color: RealUnitColors.realUnitBlue,
    +                      size: 64,
    +                    ),
    +                    const SizedBox(height: 28),
    +                    Text(
    +                      S.of(context).sellSuccess,
    +                      style: const TextStyle(
    +                        fontSize: 26,
    +                        fontWeight: FontWeight.w700,
    +                        height: 30 / 26,
    +                        letterSpacing: 26 * -0.02,
    +                      ),
    +                    ),
    +                    const SizedBox(height: 8),
    +                    Text(
    +                      S.of(context).sellSuccessDescription,
    +                      textAlign: TextAlign.center,
    +                      style: const TextStyle(
    +                        color: RealUnitColors.neutral500,
    +                        fontSize: 14,
    +                        height: 18 / 14,
    +                        letterSpacing: 0.0,
    +                      ),
    +                    ),
    +                  ],
                     ),
    -                const SizedBox(height: 8),
    -                Text(
    -                  S.of(context).sellSuccessDescription,
    -                  textAlign: TextAlign.center,
    -                  style: const TextStyle(
    -                    color: RealUnitColors.neutral500,
    -                    fontSize: 14,
    -                    height: 18 / 14,
    -                    letterSpacing: 0.0,
    +              ),
    +              actions: [
    +                Padding(
    +                  padding: const EdgeInsets.only(bottom: 24),
    +                  child: AppFilledButton(
    +                    variant: .secondary,
    +                    fullWidth: false,
    +                    onPressed: () => context.pop(),
    +                    label: S.of(context).close,
                       ),
                     ),
    -                const SizedBox(height: 28),
    -                AppFilledButton(
    -                  variant: .secondary,
    -                  fullWidth: false,
    -                  onPressed: () => context.pop(),
    -                  label: S.of(context).close,
    -                ),
                   ],
                 ),
               ),
    diff --git a/lib/screens/settings_user_data/subpages/edit_address/settings_edit_address_page.dart b/lib/screens/settings_user_data/subpages/edit_address/settings_edit_address_page.dart
    index 9efc2f7f7..1b16880b6 100644
    --- a/lib/screens/settings_user_data/subpages/edit_address/settings_edit_address_page.dart
    +++ b/lib/screens/settings_user_data/subpages/edit_address/settings_edit_address_page.dart
    @@ -18,6 +18,7 @@ import 'package:realunit_wallet/widgets/form/country_field.dart';
     import 'package:realunit_wallet/widgets/form/file_picker_field.dart';
     import 'package:realunit_wallet/widgets/form/labeled_text_field.dart';
     import 'package:realunit_wallet/widgets/image_picker_sheet.dart';
    +import 'package:realunit_wallet/widgets/scrollable_actions_layout.dart';
     
     class SettingsEditAddressPage extends StatelessWidget {
       const SettingsEditAddressPage({super.key});
    @@ -79,137 +80,129 @@ class _SettingsEditAddressViewState extends State {
             ),
             SettingsEditAddressReady() || SettingsEditAddressSubmitting() => Scaffold(
               appBar: AppBar(title: Text(S.of(context).changeAddress)),
    -          body: LayoutBuilder(
    -            builder: (context, constraints) {
    -              final isSubmitting = state is SettingsEditAddressSubmitting;
    -              return SingleChildScrollView(
    -                child: ConstrainedBox(
    -                  constraints: BoxConstraints(minHeight: constraints.maxHeight),
    -                  child: IntrinsicHeight(
    -                    child: SafeArea(
    -                      child: Padding(
    -                        padding: const .symmetric(horizontal: 20),
    -                        child: GestureDetector(
    -                          onTap: () => FocusManager.instance.primaryFocus?.unfocus(),
    -                          behavior: .opaque,
    -                          child: Form(
    -                            key: _formKey,
    -                            child: Column(
    -                              crossAxisAlignment: .stretch,
    -                              spacing: 16,
    -                              children: [
    -                                Row(
    -                                  crossAxisAlignment: .start,
    -                                  spacing: 10,
    -                                  children: [
    -                                    Expanded(
    -                                      flex: 2,
    -                                      child: LabeledTextField(
    -                                        hintText: S.of(context).streetHint,
    -                                        controller: _streetCtrl,
    -                                        label: S.of(context).street,
    -                                        keyboardType: .streetAddress,
    -                                        textCapitalization: .words,
    -                                        validator: (value) {
    -                                          if (value == null || value.isEmpty) return '';
    -                                          if (!isSwissPaymentText(value)) return S.of(context).swissPaymentTextInvalid;
    -                                          return null;
    -                                        },
    -                                      ),
    -                                    ),
    -                                    Expanded(
    -                                      child: LabeledTextField(
    -                                        hintText: '13',
    -                                        controller: _numberCtrl,
    -                                        label: S.of(context).number,
    -                                        keyboardType: .streetAddress,
    -                                        validator: (value) {
    -                                          if (value == null || value.isEmpty) return null;
    -                                          if (!isSwissPaymentText(value)) return S.of(context).swissPaymentTextInvalid;
    -                                          return null;
    -                                        },
    -                                      ),
    -                                    ),
    -                                  ],
    -                                ),
    -                                Row(
    -                                  crossAxisAlignment: .start,
    -                                  spacing: 10,
    -                                  children: [
    -                                    Expanded(
    -                                      flex: 2,
    -                                      child: LabeledTextField(
    -                                        hintText: '8000',
    -                                        controller: _postalCodeCtrl,
    -                                        label: S.of(context).postcodeAbr,
    -                                        // Postal codes are alphanumeric in many residence
    -                                        // countries (e.g. NL "1011 AB", UK "EC1A 1BB", CA
    -                                        // "K1A 0B1"). A number-only keyboard makes those
    -                                        // impossible to type, while the validator + backend
    -                                        // already accept letters and spaces.
    -                                        keyboardType: .text,
    -                                        validator: (value) {
    -                                          if (value == null || value.isEmpty) return '';
    -                                          if (!isSwissPaymentText(value)) return S.of(context).swissPaymentTextInvalid;
    -                                          return null;
    -                                        },
    -                                      ),
    -                                    ),
    -                                    Expanded(
    -                                      flex: 3,
    -                                      child: LabeledTextField(
    -                                        hintText: S.of(context).cityHint,
    -                                        controller: _cityCtrl,
    -                                        label: S.of(context).city,
    -                                        keyboardType: .text,
    -                                        textCapitalization: .words,
    -                                        validator: (value) {
    -                                          if (value == null || value.isEmpty) return '';
    -                                          if (!isSwissPaymentText(value)) return S.of(context).swissPaymentTextInvalid;
    -                                          return null;
    -                                        },
    -                                      ),
    -                                    ),
    -                                  ],
    -                                ),
    -                                CountryField(
    -                                  label: S.of(context).country,
    -                                  purpose: CountryFieldPurpose.residence,
    -                                  onChanged: (country) => _countryCtrl.value = country,
    -                                ),
    -                                FilePickerField(
    -                                  label: S.of(context).proofDocument,
    -                                  selectedFile: _selectedFile,
    -                                  onTap: () async {
    -                                    final file = await ImagePickerSheet.show(context);
    -                                    if (file != null) setState(() => _selectedFile = file);
    -                                  },
    -                                  validator: () {
    -                                    if (_fileValidationTriggered && _selectedFile == null) {
    -                                      return '';
    -                                    }
    -                                    return null;
    -                                  },
    -                                ),
    -                                const Spacer(),
    -                                Padding(
    -                                  padding: const .symmetric(vertical: 16.0),
    -                                  child: AppFilledButton(
    -                                    onPressed: _onSubmit,
    -                                    state: isSubmitting ? .loading : .idle,
    -                                    label: S.of(context).save,
    -                                  ),
    -                                ),
    -                              ],
    +          body: SafeArea(
    +            child: Padding(
    +              padding: const .symmetric(horizontal: 20),
    +              child: GestureDetector(
    +                onTap: () => FocusManager.instance.primaryFocus?.unfocus(),
    +                behavior: .opaque,
    +                child: Form(
    +                  key: _formKey,
    +                  child: ScrollableActionsLayout(
    +                    centerBody: false,
    +                    body: Column(
    +                      crossAxisAlignment: .stretch,
    +                      spacing: 16,
    +                      children: [
    +                        Row(
    +                          crossAxisAlignment: .start,
    +                          spacing: 10,
    +                          children: [
    +                            Expanded(
    +                              flex: 2,
    +                              child: LabeledTextField(
    +                                hintText: S.of(context).streetHint,
    +                                controller: _streetCtrl,
    +                                label: S.of(context).street,
    +                                keyboardType: .streetAddress,
    +                                textCapitalization: .words,
    +                                validator: (value) {
    +                                  if (value == null || value.isEmpty) return '';
    +                                  if (!isSwissPaymentText(value)) return S.of(context).swissPaymentTextInvalid;
    +                                  return null;
    +                                },
    +                              ),
                                 ),
    -                          ),
    +                            Expanded(
    +                              child: LabeledTextField(
    +                                hintText: '13',
    +                                controller: _numberCtrl,
    +                                label: S.of(context).number,
    +                                keyboardType: .streetAddress,
    +                                validator: (value) {
    +                                  if (value == null || value.isEmpty) return null;
    +                                  if (!isSwissPaymentText(value)) return S.of(context).swissPaymentTextInvalid;
    +                                  return null;
    +                                },
    +                              ),
    +                            ),
    +                          ],
                             ),
    -                      ),
    +                        Row(
    +                          crossAxisAlignment: .start,
    +                          spacing: 10,
    +                          children: [
    +                            Expanded(
    +                              flex: 2,
    +                              child: LabeledTextField(
    +                                hintText: '8000',
    +                                controller: _postalCodeCtrl,
    +                                label: S.of(context).postcodeAbr,
    +                                // Postal codes are alphanumeric in many residence
    +                                // countries (e.g. NL "1011 AB", UK "EC1A 1BB", CA
    +                                // "K1A 0B1"). A number-only keyboard makes those
    +                                // impossible to type, while the validator + backend
    +                                // already accept letters and spaces.
    +                                keyboardType: .text,
    +                                validator: (value) {
    +                                  if (value == null || value.isEmpty) return '';
    +                                  if (!isSwissPaymentText(value)) return S.of(context).swissPaymentTextInvalid;
    +                                  return null;
    +                                },
    +                              ),
    +                            ),
    +                            Expanded(
    +                              flex: 3,
    +                              child: LabeledTextField(
    +                                hintText: S.of(context).cityHint,
    +                                controller: _cityCtrl,
    +                                label: S.of(context).city,
    +                                keyboardType: .text,
    +                                textCapitalization: .words,
    +                                validator: (value) {
    +                                  if (value == null || value.isEmpty) return '';
    +                                  if (!isSwissPaymentText(value)) return S.of(context).swissPaymentTextInvalid;
    +                                  return null;
    +                                },
    +                              ),
    +                            ),
    +                          ],
    +                        ),
    +                        CountryField(
    +                          label: S.of(context).country,
    +                          purpose: CountryFieldPurpose.residence,
    +                          onChanged: (country) => _countryCtrl.value = country,
    +                        ),
    +                        FilePickerField(
    +                          label: S.of(context).proofDocument,
    +                          selectedFile: _selectedFile,
    +                          onTap: () async {
    +                            final file = await ImagePickerSheet.show(context);
    +                            if (file != null) setState(() => _selectedFile = file);
    +                          },
    +                          validator: () {
    +                            if (_fileValidationTriggered && _selectedFile == null) {
    +                              return '';
    +                            }
    +                            return null;
    +                          },
    +                        ),
    +                      ],
                         ),
    +                    actions: [
    +                      Padding(
    +                        padding: const .symmetric(vertical: 16.0),
    +                        child: AppFilledButton(
    +                          onPressed: _onSubmit,
    +                          state: state is SettingsEditAddressSubmitting ? .loading : .idle,
    +                          label: S.of(context).save,
    +                        ),
    +                      ),
    +                    ],
                       ),
                     ),
    -              );
    -            },
    +              ),
    +            ),
               ),
             ),
             _ => const Scaffold(),
    diff --git a/lib/screens/settings_user_data/subpages/edit_name/settings_edit_name_page.dart b/lib/screens/settings_user_data/subpages/edit_name/settings_edit_name_page.dart
    index 1eb291181..fe97320aa 100644
    --- a/lib/screens/settings_user_data/subpages/edit_name/settings_edit_name_page.dart
    +++ b/lib/screens/settings_user_data/subpages/edit_name/settings_edit_name_page.dart
    @@ -16,6 +16,7 @@ import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart';
     import 'package:realunit_wallet/widgets/form/file_picker_field.dart';
     import 'package:realunit_wallet/widgets/form/labeled_text_field.dart';
     import 'package:realunit_wallet/widgets/image_picker_sheet.dart';
    +import 'package:realunit_wallet/widgets/scrollable_actions_layout.dart';
     
     class SettingsEditNamePage extends StatelessWidget {
       const SettingsEditNamePage({super.key});
    @@ -74,79 +75,71 @@ class _SettingsEditNameViewState extends State {
             ),
             SettingsEditNameReady() || SettingsEditNameSubmitting() => Scaffold(
               appBar: AppBar(title: Text(S.of(context).changeName)),
    -          body: LayoutBuilder(
    -            builder: (context, constraints) {
    -              final isSubmitting = state is SettingsEditNameSubmitting;
    -              return SingleChildScrollView(
    -                child: ConstrainedBox(
    -                  constraints: BoxConstraints(minHeight: constraints.maxHeight),
    -                  child: IntrinsicHeight(
    -                    child: SafeArea(
    -                      child: Padding(
    -                        padding: const .symmetric(horizontal: 20),
    -                        child: GestureDetector(
    -                          onTap: () => FocusManager.instance.primaryFocus?.unfocus(),
    -                          behavior: .opaque,
    -                          child: Form(
    -                            key: _formKey,
    -                            child: Column(
    -                              spacing: 16,
    -                              children: [
    -                                LabeledTextField(
    -                                  label: S.of(context).firstName,
    -                                  hintText: 'Max',
    -                                  controller: _firstNameCtrl,
    -                                  textCapitalization: .words,
    -                                  validator: (value) {
    -                                    if (value == null || value.isEmpty) return '';
    -                                    if (!isSwissPaymentText(value)) return S.of(context).swissPaymentTextInvalid;
    -                                    return null;
    -                                  },
    -                                ),
    -                                LabeledTextField(
    -                                  label: S.of(context).lastName,
    -                                  hintText: 'Mustermann',
    -                                  controller: _lastNameCtrl,
    -                                  textCapitalization: .words,
    -                                  validator: (value) {
    -                                    if (value == null || value.isEmpty) return '';
    -                                    if (!isSwissPaymentText(value)) return S.of(context).swissPaymentTextInvalid;
    -                                    return null;
    -                                  },
    -                                ),
    -                                FilePickerField(
    -                                  label: S.of(context).proofDocument,
    -                                  selectedFile: _selectedFile,
    -                                  onTap: () async {
    -                                    final file = await ImagePickerSheet.show(context);
    -                                    if (file != null) setState(() => _selectedFile = file);
    -                                  },
    -                                  validator: () {
    -                                    if (_fileValidationTriggered && _selectedFile == null) {
    -                                      return '';
    -                                    }
    -                                    return null;
    -                                  },
    -                                ),
    -                                const Spacer(),
    -                                Padding(
    -                                  padding: const .symmetric(vertical: 16.0),
    -                                  child: AppFilledButton(
    -                                    onPressed: _onSubmit,
    -                                    state: isSubmitting ? .loading : .idle,
    -                                    label: S.of(context).save,
    -                                  ),
    -                                ),
    -                              ],
    -                            ),
    -                          ),
    +          body: SafeArea(
    +            child: Padding(
    +              padding: const .symmetric(horizontal: 20),
    +              child: GestureDetector(
    +                onTap: () => FocusManager.instance.primaryFocus?.unfocus(),
    +                behavior: .opaque,
    +                child: Form(
    +                  key: _formKey,
    +                  child: ScrollableActionsLayout(
    +                    centerBody: false,
    +                    body: Column(
    +                      spacing: 16,
    +                      children: [
    +                        LabeledTextField(
    +                          label: S.of(context).firstName,
    +                          hintText: 'Max',
    +                          controller: _firstNameCtrl,
    +                          textCapitalization: .words,
    +                          validator: (value) {
    +                            if (value == null || value.isEmpty) return '';
    +                            if (!isSwissPaymentText(value)) return S.of(context).swissPaymentTextInvalid;
    +                            return null;
    +                          },
                             ),
    -                      ),
    +                        LabeledTextField(
    +                          label: S.of(context).lastName,
    +                          hintText: 'Mustermann',
    +                          controller: _lastNameCtrl,
    +                          textCapitalization: .words,
    +                          validator: (value) {
    +                            if (value == null || value.isEmpty) return '';
    +                            if (!isSwissPaymentText(value)) return S.of(context).swissPaymentTextInvalid;
    +                            return null;
    +                          },
    +                        ),
    +                        FilePickerField(
    +                          label: S.of(context).proofDocument,
    +                          selectedFile: _selectedFile,
    +                          onTap: () async {
    +                            final file = await ImagePickerSheet.show(context);
    +                            if (file != null) setState(() => _selectedFile = file);
    +                          },
    +                          validator: () {
    +                            if (_fileValidationTriggered && _selectedFile == null) {
    +                              return '';
    +                            }
    +                            return null;
    +                          },
    +                        ),
    +                      ],
                         ),
    +                    actions: [
    +                      Padding(
    +                        padding: const .symmetric(vertical: 16.0),
    +                        child: AppFilledButton(
    +                          onPressed: _onSubmit,
    +                          state: state is SettingsEditNameSubmitting ? .loading : .idle,
    +                          label: S.of(context).save,
    +                        ),
    +                      ),
    +                    ],
                       ),
                     ),
    -              );
    -            },
    +              ),
    +            ),
               ),
             ),
             _ => const Scaffold(),
    diff --git a/lib/screens/settings_user_data/subpages/edit_phone_number/settings_edit_phone_number_page.dart b/lib/screens/settings_user_data/subpages/edit_phone_number/settings_edit_phone_number_page.dart
    index aaa2c99ca..9d5b8abde 100644
    --- a/lib/screens/settings_user_data/subpages/edit_phone_number/settings_edit_phone_number_page.dart
    +++ b/lib/screens/settings_user_data/subpages/edit_phone_number/settings_edit_phone_number_page.dart
    @@ -8,6 +8,7 @@ import 'package:realunit_wallet/setup/di.dart';
     import 'package:realunit_wallet/styles/colors.dart';
     import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart';
     import 'package:realunit_wallet/widgets/form/phone_number_field.dart';
    +import 'package:realunit_wallet/widgets/scrollable_actions_layout.dart';
     
     class SettingsEditPhoneNumberPage extends StatelessWidget {
       const SettingsEditPhoneNumberPage({super.key});
    @@ -53,50 +54,43 @@ class _SettingsEditPhoneNumberViewState extends State FocusManager.instance.primaryFocus?.unfocus(),
    -                          behavior: .opaque,
    -                          child: Form(
    -                            key: _formKey,
    -                            child: Column(
    -                              spacing: 16,
    -                              children: [
    -                                PhoneNumberField(controller: _phoneCtrl),
    -                                if (state is SettingsEditPhoneNumberFailure)
    -                                  Text(
    -                                    state.message,
    -                                    style: TextStyle(
    -                                      color: Theme.of(context).colorScheme.error,
    -                                    ),
    -                                  ),
    -                                const Spacer(),
    -                                Padding(
    -                                  padding: const .symmetric(vertical: 16.0),
    -                                  child: AppFilledButton(
    -                                    onPressed: _onSubmit,
    -                                    state: isSubmitting ? .loading : .idle,
    -                                    label: S.of(context).save,
    -                                  ),
    -                                ),
    -                              ],
    +          return SafeArea(
    +            child: Padding(
    +              padding: const .symmetric(horizontal: 20),
    +              child: GestureDetector(
    +                onTap: () => FocusManager.instance.primaryFocus?.unfocus(),
    +                behavior: .opaque,
    +                child: Form(
    +                  key: _formKey,
    +                  child: ScrollableActionsLayout(
    +                    centerBody: false,
    +                    body: Column(
    +                      spacing: 16,
    +                      children: [
    +                        PhoneNumberField(controller: _phoneCtrl),
    +                        if (state is SettingsEditPhoneNumberFailure)
    +                          Text(
    +                            state.message,
    +                            style: TextStyle(
    +                              color: Theme.of(context).colorScheme.error,
                                 ),
                               ),
    +                      ],
    +                    ),
    +                    actions: [
    +                      Padding(
    +                        padding: const .symmetric(vertical: 16.0),
    +                        child: AppFilledButton(
    +                          onPressed: _onSubmit,
    +                          state: isSubmitting ? .loading : .idle,
    +                          label: S.of(context).save,
                             ),
                           ),
    -                    ),
    +                    ],
                       ),
                     ),
    -              );
    -            },
    +              ),
    +            ),
               );
             },
           ),
    diff --git a/lib/screens/settings_user_data/subpages/others/settings_edit_failure_page.dart b/lib/screens/settings_user_data/subpages/others/settings_edit_failure_page.dart
    index ca9b8a3f7..603eb0f53 100644
    --- a/lib/screens/settings_user_data/subpages/others/settings_edit_failure_page.dart
    +++ b/lib/screens/settings_user_data/subpages/others/settings_edit_failure_page.dart
    @@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
     import 'package:realunit_wallet/generated/i18n.dart';
     import 'package:realunit_wallet/styles/colors.dart';
     import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart';
    +import 'package:realunit_wallet/widgets/scrollable_actions_layout.dart';
     
     class SettingsEditFailurePage extends StatelessWidget {
       final String title;
    @@ -22,21 +23,26 @@ class SettingsEditFailurePage extends StatelessWidget {
           body: Padding(
             padding: const .symmetric(horizontal: 20.0),
             child: SafeArea(
    -          child: Column(
    -            spacing: 8.0,
    -            children: [
    -              const Spacer(),
    -              Text(
    -                S.of(context).kycFailure,
    -                style: Theme.of(context).textTheme.headlineMedium,
    -              ),
    -              Text(
    -                S.of(context).kycFailureDescription(message),
    -                textAlign: .center,
    -                style: Theme.of(
    -                  context,
    -                ).textTheme.bodyMedium?.copyWith(color: RealUnitColors.neutral500),
    -              ),
    +          child: ScrollableActionsLayout(
    +            centerBody: true,
    +            body: Column(
    +              mainAxisSize: .min,
    +              spacing: 8.0,
    +              children: [
    +                Text(
    +                  S.of(context).kycFailure,
    +                  style: Theme.of(context).textTheme.headlineMedium,
    +                ),
    +                Text(
    +                  S.of(context).kycFailureDescription(message),
    +                  textAlign: .center,
    +                  style: Theme.of(
    +                    context,
    +                  ).textTheme.bodyMedium?.copyWith(color: RealUnitColors.neutral500),
    +                ),
    +              ],
    +            ),
    +            actions: [
                   Padding(
                     padding: const .symmetric(vertical: 16.0),
                     child: AppFilledButton(
    @@ -44,7 +50,6 @@ class SettingsEditFailurePage extends StatelessWidget {
                       label: S.of(context).refresh,
                     ),
                   ),
    -              const Spacer(),
                 ],
               ),
             ),
    diff --git a/lib/screens/settings_user_data/subpages/others/settings_edit_pending_page.dart b/lib/screens/settings_user_data/subpages/others/settings_edit_pending_page.dart
    index ceceb43a1..5e2bd83ea 100644
    --- a/lib/screens/settings_user_data/subpages/others/settings_edit_pending_page.dart
    +++ b/lib/screens/settings_user_data/subpages/others/settings_edit_pending_page.dart
    @@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
     import 'package:realunit_wallet/generated/i18n.dart';
     import 'package:realunit_wallet/styles/colors.dart';
     import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart';
    +import 'package:realunit_wallet/widgets/scrollable_actions_layout.dart';
     import 'package:realunit_wallet/widgets/text_substring_highlighting.dart';
     
     class SettingsEditPendingPage extends StatelessWidget {
    @@ -23,29 +24,34 @@ class SettingsEditPendingPage extends StatelessWidget {
           body: Padding(
             padding: const .symmetric(horizontal: 20.0),
             child: SafeArea(
    -          child: Column(
    -            spacing: 8.0,
    -            children: [
    -              const Spacer(),
    -              Text(
    -                S.of(context).kycPending,
    -                style: Theme.of(context).textTheme.headlineMedium,
    -              ),
    -              TextSubstringHighlighting(
    -                text: S.of(context).kycPendingDescription(title),
    -                textAlign: .center,
    -                style: Theme.of(
    -                  context,
    -                ).textTheme.bodyMedium?.copyWith(color: RealUnitColors.neutral500),
    -                highlightedText: title,
    -                highlightedStyle:
    -                    Theme.of(
    -                      context,
    -                    ).textTheme.bodyMedium?.copyWith(
    -                      color: RealUnitColors.neutral500,
    -                      fontWeight: .w600,
    -                    ),
    -              ),
    +          child: ScrollableActionsLayout(
    +            centerBody: true,
    +            body: Column(
    +              mainAxisSize: .min,
    +              spacing: 8.0,
    +              children: [
    +                Text(
    +                  S.of(context).kycPending,
    +                  style: Theme.of(context).textTheme.headlineMedium,
    +                ),
    +                TextSubstringHighlighting(
    +                  text: S.of(context).kycPendingDescription(title),
    +                  textAlign: .center,
    +                  style: Theme.of(
    +                    context,
    +                  ).textTheme.bodyMedium?.copyWith(color: RealUnitColors.neutral500),
    +                  highlightedText: title,
    +                  highlightedStyle:
    +                      Theme.of(
    +                        context,
    +                      ).textTheme.bodyMedium?.copyWith(
    +                        color: RealUnitColors.neutral500,
    +                        fontWeight: .w600,
    +                      ),
    +                ),
    +              ],
    +            ),
    +            actions: [
                   Padding(
                     padding: const .symmetric(vertical: 16.0),
                     child: AppFilledButton(
    @@ -53,7 +59,6 @@ class SettingsEditPendingPage extends StatelessWidget {
                       label: S.of(context).refresh,
                     ),
                   ),
    -              const Spacer(),
                 ],
               ),
             ),
    diff --git a/lib/screens/support/subpages/support_create_ticket_page.dart b/lib/screens/support/subpages/support_create_ticket_page.dart
    index d5418b6ff..0a3192355 100644
    --- a/lib/screens/support/subpages/support_create_ticket_page.dart
    +++ b/lib/screens/support/subpages/support_create_ticket_page.dart
    @@ -9,6 +9,7 @@ import 'package:realunit_wallet/screens/support/cubits/support_create_ticket/sup
     import 'package:realunit_wallet/setup/di.dart';
     import 'package:realunit_wallet/styles/colors.dart';
     import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart';
    +import 'package:realunit_wallet/widgets/scrollable_actions_layout.dart';
     import 'package:realunit_wallet/widgets/tag_selection.dart';
     
     class SupportCreateTicketPage extends StatelessWidget {
    @@ -54,109 +55,102 @@ class SupportCreateTicketView extends StatelessWidget {
                 );
               }
             },
    +        // Keyboard: Scaffold.resizeToAvoidBottomInset (default true) already
    +        // shrinks the body; ScrollableActionsLayout then gets a reduced
    +        // bounded height — no extra viewInsets padding needed.
             builder: (context, state) {
    -          return LayoutBuilder(
    -            builder: (context, constraints) {
    -              return SingleChildScrollView(
    -                child: ConstrainedBox(
    -                  constraints: BoxConstraints(minHeight: constraints.maxHeight),
    -                  child: IntrinsicHeight(
    -                    child: SafeArea(
    -                      child: Padding(
    -                        padding: const .symmetric(
    -                          horizontal: 20,
    -                          vertical: 12,
    +          return SafeArea(
    +            child: Padding(
    +              padding: const .symmetric(horizontal: 20, vertical: 12),
    +              child: ScrollableActionsLayout(
    +                body: Column(
    +                  crossAxisAlignment: .start,
    +                  mainAxisAlignment: .start,
    +                  spacing: 20.0,
    +                  children: [
    +                    Column(
    +                      crossAxisAlignment: .start,
    +                      spacing: 12.0,
    +                      children: [
    +                        Text(
    +                          S.of(context).supportSelectType,
    +                          style: Theme.of(context).textTheme.bodyLarge?.copyWith(
    +                            fontWeight: .w600,
    +                          ),
                             ),
    -                        child: Column(
    -                          crossAxisAlignment: .start,
    -                          spacing: 20.0,
    -                          children: [
    -                            Column(
    -                              crossAxisAlignment: .start,
    -                              spacing: 12.0,
    -                              children: [
    -                                Text(
    -                                  S.of(context).supportSelectType,
    -                                  style: Theme.of(context).textTheme.bodyLarge?.copyWith(
    -                                    fontWeight: .w600,
    -                                  ),
    -                                ),
    -                                TagSelection(
    -                                  items: [
    -                                    (
    -                                      SupportIssueType.genericIssue,
    -                                      S.of(context).supportGenericIssue,
    -                                      Icons.help_outline,
    -                                    ),
    -                                    (
    -                                      SupportIssueType.bugReport,
    -                                      S.of(context).supportBugReport,
    -                                      Icons.bug_report_outlined,
    -                                    ),
    -                                    (
    -                                      SupportIssueType.kycIssue,
    -                                      S.of(context).supportKycIssue,
    -                                      Icons.verified_user_outlined,
    -                                    ),
    -                                  ],
    -                                  selected: state.selectedType,
    -                                  onSelected: context.read().selectType,
    -                                ),
    -                              ],
    +                        TagSelection(
    +                          items: [
    +                            (
    +                              SupportIssueType.genericIssue,
    +                              S.of(context).supportGenericIssue,
    +                              Icons.help_outline,
                                 ),
    -                            Column(
    -                              crossAxisAlignment: .start,
    -                              spacing: 12.0,
    -                              children: [
    -                                Text(
    -                                  S.of(context).supportTypeMessage,
    -                                  style: Theme.of(context).textTheme.bodyLarge?.copyWith(
    -                                    fontWeight: .w600,
    -                                  ),
    -                                ),
    -                                TextField(
    -                                  onChanged: context.read().updateMessage,
    -                                  maxLines: 5,
    -                                  decoration: InputDecoration(
    -                                    hintText: S.of(context).supportEnterMessage,
    -                                    border: OutlineInputBorder(
    -                                      borderRadius: .circular(12),
    -                                      borderSide: const BorderSide(
    -                                        color: RealUnitColors.neutral200,
    -                                      ),
    -                                    ),
    -                                    enabledBorder: OutlineInputBorder(
    -                                      borderRadius: .circular(12),
    -                                      borderSide: const BorderSide(
    -                                        color: RealUnitColors.neutral200,
    -                                      ),
    -                                    ),
    -                                    focusedBorder: OutlineInputBorder(
    -                                      borderRadius: .circular(12),
    -                                      borderSide: const BorderSide(
    -                                        color: RealUnitColors.realUnitBlue,
    -                                      ),
    -                                    ),
    -                                  ),
    -                                ),
    -                              ],
    +                            (
    +                              SupportIssueType.bugReport,
    +                              S.of(context).supportBugReport,
    +                              Icons.bug_report_outlined,
                                 ),
    -                            const Spacer(),
    -                            AppFilledButton(
    -                              state: state.isSubmitting ? .loading : .idle,
    -                              onPressed: state.canSubmit
    -                                  ? () => context.read().submit()
    -                                  : null,
    -                              label: S.of(context).supportSend,
    +                            (
    +                              SupportIssueType.kycIssue,
    +                              S.of(context).supportKycIssue,
    +                              Icons.verified_user_outlined,
                                 ),
                               ],
    +                          selected: state.selectedType,
    +                          onSelected: context.read().selectType,
                             ),
    -                      ),
    +                      ],
                         ),
    -                  ),
    +                    Column(
    +                      crossAxisAlignment: .start,
    +                      spacing: 12.0,
    +                      children: [
    +                        Text(
    +                          S.of(context).supportTypeMessage,
    +                          style: Theme.of(context).textTheme.bodyLarge?.copyWith(
    +                            fontWeight: .w600,
    +                          ),
    +                        ),
    +                        TextField(
    +                          onChanged: context.read().updateMessage,
    +                          maxLines: 5,
    +                          decoration: InputDecoration(
    +                            hintText: S.of(context).supportEnterMessage,
    +                            border: OutlineInputBorder(
    +                              borderRadius: .circular(12),
    +                              borderSide: const BorderSide(
    +                                color: RealUnitColors.neutral200,
    +                              ),
    +                            ),
    +                            enabledBorder: OutlineInputBorder(
    +                              borderRadius: .circular(12),
    +                              borderSide: const BorderSide(
    +                                color: RealUnitColors.neutral200,
    +                              ),
    +                            ),
    +                            focusedBorder: OutlineInputBorder(
    +                              borderRadius: .circular(12),
    +                              borderSide: const BorderSide(
    +                                color: RealUnitColors.realUnitBlue,
    +                              ),
    +                            ),
    +                          ),
    +                        ),
    +                      ],
    +                    ),
    +                  ],
                     ),
    -              );
    -            },
    +                actions: [
    +                  AppFilledButton(
    +                    state: state.isSubmitting ? .loading : .idle,
    +                    onPressed: state.canSubmit
    +                        ? () => context.read().submit()
    +                        : null,
    +                    label: S.of(context).supportSend,
    +                  ),
    +                ],
    +              ),
    +            ),
               );
             },
           ),
    diff --git a/lib/screens/support/widgets/support_chat_message_bubble.dart b/lib/screens/support/widgets/support_chat_message_bubble.dart
    index 097afccff..59120562c 100644
    --- a/lib/screens/support/widgets/support_chat_message_bubble.dart
    +++ b/lib/screens/support/widgets/support_chat_message_bubble.dart
    @@ -75,8 +75,14 @@ class SupportChatMessageBubble extends StatelessWidget {
       }
     
       String _formatTime(DateTime date) {
    -    final offset = DateTime.now().timeZoneOffset;
    -    final adjustedDateToTimezone = date.add(offset);
    -    return '${adjustedDateToTimezone.hour.toString().padLeft(2, '0')}:${adjustedDateToTimezone.minute.toString().padLeft(2, '0')}';
    +    // `date` is a UTC instant (the API sends `created` as a `Z`-suffixed
    +    // ISO-8601 string, parsed to a UTC `DateTime`). Convert it to the device's
    +    // local zone using the rules that applied on the message's own date, so a
    +    // message stays render-time-stable across DST boundaries — a March instant
    +    // always shows CET, a July instant always shows CEST. Adding
    +    // `DateTime.now().timeZoneOffset` instead would apply the *current* offset
    +    // to a historic instant and drift by an hour across the DST switch.
    +    final localDate = date.toLocal();
    +    return '${localDate.hour.toString().padLeft(2, '0')}:${localDate.minute.toString().padLeft(2, '0')}';
       }
     }
    diff --git a/lib/screens/verify_seed/verify_seed_page.dart b/lib/screens/verify_seed/verify_seed_page.dart
    index 99a2382ac..5415d2c2f 100644
    --- a/lib/screens/verify_seed/verify_seed_page.dart
    +++ b/lib/screens/verify_seed/verify_seed_page.dart
    @@ -11,6 +11,7 @@ import 'package:realunit_wallet/screens/verify_seed/widgets/verify_seed_button.d
     import 'package:realunit_wallet/screens/verify_seed/widgets/verify_seed_input_field.dart';
     import 'package:realunit_wallet/setup/di.dart';
     import 'package:realunit_wallet/styles/colors.dart';
    +import 'package:realunit_wallet/widgets/scrollable_actions_layout.dart';
     
     class VerifySeedPage extends StatelessWidget {
       const VerifySeedPage({super.key, required this.wallet});
    @@ -55,79 +56,72 @@ class _VerifySeedViewState extends State {
         return Scaffold(
           backgroundColor: RealUnitColors.brand700,
           appBar: AppBar(),
    -      body: Padding(
    -        padding: const .symmetric(horizontal: 20),
    -        child: BlocConsumer(
    -          listener: (context, state) async {
    -            if (state.isVerified) {
    -              await Future.delayed(const Duration(seconds: 2));
    -              if (context.mounted) {
    -                // Load the *committed* wallet (`state.committedWallet`), not
    -                // the page's draft (`id == 0`). `committedWallet` is only
    -                // ever set together with `isVerified`, so it is non-null
    -                // here. `LoadWalletEvent` makes `HomeBloc` set
    -                // `hasWallet: true`, which `main.dart`'s `_navigate()` needs
    -                // to route forward to onboarding-completed instead of
    -                // looping back to welcome.
    -                context.read().add(
    -                  LoadWalletEvent(state.committedWallet!),
    -                );
    +      body: SafeArea(
    +        child: Padding(
    +          padding: const .symmetric(horizontal: 20),
    +          child: BlocConsumer(
    +            listener: (context, state) async {
    +              if (state.isVerified) {
    +                await Future.delayed(const Duration(seconds: 2));
    +                if (context.mounted) {
    +                  // Load the *committed* wallet (`state.committedWallet`), not
    +                  // the page's draft (`id == 0`). `committedWallet` is only
    +                  // ever set together with `isVerified`, so it is non-null
    +                  // here. `LoadWalletEvent` makes `HomeBloc` set
    +                  // `hasWallet: true`, which `main.dart`'s `_navigate()` needs
    +                  // to route forward to onboarding-completed instead of
    +                  // looping back to welcome.
    +                  context.read().add(
    +                    LoadWalletEvent(state.committedWallet!),
    +                  );
    +                }
                   }
    -            }
    -          },
    -          builder: (context, state) {
    -            if (state.wordIndices.isNotEmpty) {
    -              return LayoutBuilder(
    -                builder: (context, constraint) {
    -                  return SingleChildScrollView(
    -                    child: ConstrainedBox(
    -                      constraints: BoxConstraints(minHeight: constraint.maxHeight),
    -                      child: IntrinsicHeight(
    -                        child: SafeArea(
    -                          child: Column(
    -                            mainAxisAlignment: .start,
    -                            children: [
    -                              SvgPicture.asset(
    -                                'assets/images/illustrations/backup_wallet.svg',
    -                                width: 124,
    -                              ),
    -                              const SizedBox(height: 28),
    -                              Column(
    -                                spacing: 8.0,
    -                                children: [
    -                                  Text(
    -                                    S.of(context).verifySeedTitle,
    -                                    style: Theme.of(context).textTheme.headlineSmall,
    -                                    textAlign: .center,
    -                                  ),
    -                                  Text(
    -                                    S.of(context).verifySeedSubtitle,
    -                                    textAlign: .center,
    -                                    style: Theme.of(context).textTheme.bodyMedium?.copyWith(
    -                                      color: RealUnitColors.neutral500,
    -                                    ),
    -                                  ),
    -                                ],
    -                              ),
    -                              const SizedBox(height: 40),
    -                              VerifySeedInputField(
    -                                wordIndices: state.wordIndices,
    -                                enteredWords: state.enteredWords,
    -                                hasError: state.hasError,
    -                              ),
    -                              const Spacer(),
    -                              const VerifySeedButton(),
    -                            ],
    +            },
    +            builder: (context, state) {
    +              if (state.wordIndices.isNotEmpty) {
    +                return ScrollableActionsLayout(
    +                  centerBody: false,
    +                  body: Column(
    +                    mainAxisAlignment: .start,
    +                    children: [
    +                      SvgPicture.asset(
    +                        'assets/images/illustrations/backup_wallet.svg',
    +                        width: 124,
    +                      ),
    +                      const SizedBox(height: 28),
    +                      Column(
    +                        spacing: 8.0,
    +                        children: [
    +                          Text(
    +                            S.of(context).verifySeedTitle,
    +                            style: Theme.of(context).textTheme.headlineSmall,
    +                            textAlign: .center,
    +                          ),
    +                          Text(
    +                            S.of(context).verifySeedSubtitle,
    +                            textAlign: .center,
    +                            style: Theme.of(context).textTheme.bodyMedium?.copyWith(
    +                              color: RealUnitColors.neutral500,
    +                            ),
                               ),
    -                        ),
    +                        ],
                           ),
    -                    ),
    -                  );
    -                },
    -              );
    -            }
    -            return const SizedBox.shrink();
    -          },
    +                      const SizedBox(height: 40),
    +                      VerifySeedInputField(
    +                        wordIndices: state.wordIndices,
    +                        enteredWords: state.enteredWords,
    +                        hasError: state.hasError,
    +                      ),
    +                    ],
    +                  ),
    +                  actions: const [
    +                    VerifySeedButton(),
    +                  ],
    +                );
    +              }
    +              return const SizedBox.shrink();
    +            },
    +          ),
             ),
           ),
         );
    diff --git a/lib/setup/di.dart b/lib/setup/di.dart
    index 04937f7b3..6a328e567 100644
    --- a/lib/setup/di.dart
    +++ b/lib/setup/di.dart
    @@ -30,6 +30,7 @@ import 'package:realunit_wallet/packages/service/dfx/dfx_support_service.dart';
     import 'package:realunit_wallet/packages/service/dfx/dfx_widget_service.dart';
     import 'package:realunit_wallet/packages/service/dfx/real_unit_account_service.dart';
     import 'package:realunit_wallet/packages/service/dfx/real_unit_buy_payment_info_service.dart';
    +import 'package:realunit_wallet/packages/service/dfx/real_unit_legal_service.dart';
     import 'package:realunit_wallet/packages/service/dfx/real_unit_pdf_service.dart';
     import 'package:realunit_wallet/packages/service/dfx/real_unit_registration_service.dart';
     import 'package:realunit_wallet/packages/service/dfx/real_unit_sell_payment_info_service.dart';
    @@ -57,7 +58,7 @@ Future setupEssentials() async {
       final secureStorage = const SecureStorage();
       getIt.registerSingleton(secureStorage);
     
    -  await _migrateSecurityFlags(sharedPreferences, secureStorage);
    +  await migrateSecurityFlags(sharedPreferences, secureStorage);
     
       final encryptionKey = await secureStorage.getEncryptionKey();
     
    @@ -181,6 +182,9 @@ void setupServices() {
       getIt.registerFactory(
         () => RealUnitBuyPaymentInfoService(getIt(), getIt()),
       );
    +  getIt.registerFactory(
    +    () => RealUnitLegalService(getIt(), getIt()),
    +  );
       getIt.registerFactory(
         () => RealUnitPdfService(getIt(), getIt()),
       );
    @@ -227,7 +231,17 @@ Future setupBlocs() async {
     
     Future _existsDatabaseFile() async => File(await AppDatabase.getDatabasePath()).exists();
     
    -Future _migrateSecurityFlags(
    +/// Moves the security flags that used to live in [SharedPreferences]
    +/// (biometric-enabled, the PIN lockout attempt counter, and the lock-until
    +/// timestamp) into [SecureStorage], and drops the legacy `isPinEnabled` flag.
    +/// Runs once on every boot from [setupEssentials] and is idempotent once the
    +/// prefs are cleared.
    +///
    +/// Exposed for unit testing (via [SecureStorage.withStorage] + mocked
    +/// [SharedPreferences]) because the boot path is otherwise unreachable without
    +/// standing up the real service locator.
    +@visibleForTesting
    +Future migrateSecurityFlags(
       SharedPreferences prefs,
       SecureStorage secureStorage,
     ) async {
    diff --git a/lib/setup/lifecycle_initializer.dart b/lib/setup/lifecycle_initializer.dart
    index b92e301c2..c86b6afcb 100644
    --- a/lib/setup/lifecycle_initializer.dart
    +++ b/lib/setup/lifecycle_initializer.dart
    @@ -7,6 +7,8 @@ import 'package:realunit_wallet/packages/service/balance_service.dart';
     import 'package:realunit_wallet/packages/service/wallet_service.dart';
     import 'package:realunit_wallet/screens/pin/bloc/auth/pin_auth_cubit.dart';
     import 'package:realunit_wallet/setup/di.dart';
    +import 'package:realunit_wallet/setup/routing/boot_navigation.dart';
    +import 'package:realunit_wallet/setup/routing/router_config.dart';
     
     class LifecycleInitializer extends StatefulWidget {
       final Widget child;
    @@ -84,7 +86,10 @@ class _LifecycleInitializerState extends State {
         if (_armedForBackground) return;
         _armedForBackground = true;
     
    -    getIt().onAppHidden();
    +    // effectiveLocation, not currentConfiguration.uri: the KYC flow is pushed
    +    // imperatively, and the raw uri would capture the base route underneath.
    +    final location = effectiveLocation(routerConfig.routerDelegate.currentConfiguration);
    +    getIt().onAppHidden(resumeCaptureFor(location));
         // Drop the mnemonic before the OS suspends the isolate. `lockCurrentWallet`
         // is defensive on its own — no try/catch / catchError by design, so a
         // Future.error surfaces in the Zone instead of being silently swallowed.
    diff --git a/lib/setup/routing/boot_navigation.dart b/lib/setup/routing/boot_navigation.dart
    new file mode 100644
    index 000000000..bece67e06
    --- /dev/null
    +++ b/lib/setup/routing/boot_navigation.dart
    @@ -0,0 +1,198 @@
    +import 'dart:async';
    +
    +import 'package:go_router/go_router.dart';
    +import 'package:realunit_wallet/setup/routing/routes/app_routes.dart';
    +import 'package:realunit_wallet/setup/routing/routes/onboarding_routes.dart';
    +import 'package:realunit_wallet/setup/routing/routes/pin_routes.dart';
    +
    +/// The outcome of the boot/lock navigation decision (see
    +/// [resolveBootNavigation]) driven by `main.dart`'s `_navigate`. The decision
    +/// itself is `getIt`-free so the whole gate ladder can be unit-tested
    +/// exhaustively; [applyBootNavAction] below performs the go_router side effect.
    +sealed class BootNavAction {
    +  const BootNavAction();
    +}
    +
    +/// The wallet is still loading — do not navigate yet, wait for the next
    +/// emission that flips `isLoadingWallet` off.
    +final class BootNavWaitForLoad extends BootNavAction {
    +  const BootNavWaitForLoad();
    +}
    +
    +/// All gates passed but the wallet is not in memory yet — trigger the load and
    +/// wait for the resulting HomeBloc emission.
    +final class BootNavLoadWallet extends BootNavAction {
    +  const BootNavLoadWallet();
    +}
    +
    +/// Navigate to a gate / landing route by its go_router name.
    +final class BootNavGoNamed extends BootNavAction {
    +  final String routeName;
    +  const BootNavGoNamed(this.routeName);
    +}
    +
    +/// Restore the in-flight (non-gate) route the user was on before the background
    +/// lock / re-lock, addressed by its location (path).
    +final class BootNavRestore extends BootNavAction {
    +  final String location;
    +  const BootNavRestore(this.location);
    +}
    +
    +/// All gates passed and the user is already on a valid non-gate route — leave
    +/// them exactly where they are, never yank them to the dashboard.
    +final class BootNavStay extends BootNavAction {
    +  const BootNavStay();
    +}
    +
    +/// The location the user actually sees, including imperatively pushed routes.
    +///
    +/// `RouteMatchList.uri` only reflects declarative (`go`) matches — after a
    +/// `push` (how the KYC flow is entered from Buy/Sell) it still reports the
    +/// base route underneath. Everything that judges or captures "where the user
    +/// is" (the boot machine's `currentLocation`, the background capture, the
    +/// scheme-open redirect) must read this helper instead, or a pushed flow is
    +/// invisible to it.
    +String effectiveLocation(RouteMatchList configuration) {
    +  final matches = configuration.matches;
    +  final last = matches.isEmpty ? null : matches.last;
    +  if (last is ImperativeRouteMatch) return last.matches.uri.toString();
    +  return configuration.uri.toString();
    +}
    +
    +/// Locations that are boot/lock gates: `_navigate` re-derives them from state
    +/// and must never treat one as an in-flight route to restore. What *is*
    +/// restorable is governed by the [restorableLocations] allowlist below — not
    +/// by "anything that is not a gate".
    +const Set gateLocations = {
    +  '/home',
    +  '/welcome',
    +  '/createWallet',
    +  '/restoreWallet',
    +  '/verifySeed',
    +  '/onboardingComplete',
    +  '/pinGate',
    +  '/setupPin',
    +  '/verifyPin',
    +  '/bitboxAddressRecovery',
    +  '/debugAuth',
    +};
    +
    +/// Whether [loc] resolves to one of the [gateLocations] gates. Compares the
    +/// path only, so a query string cannot defeat the check.
    +bool isGateLocation(String loc) => gateLocations.contains(Uri.parse(loc).path);
    +
    +/// Maps the location the app was backgrounded on to what the resume capture
    +/// should store: gate locations map to null so a nested re-lock — backgrounded
    +/// again while the PIN gate is showing — keeps the earlier in-flight capture
    +/// instead of clobbering it with the gate.
    +String? resumeCaptureFor(String location) => isGateLocation(location) ? null : location;
    +
    +/// Non-gate locations safe to restore after a resume / re-lock: they rebuild
    +/// from a bare path (no required `extra`) and sit behind no secondary PIN gate.
    +/// Fail-closed — anything not listed falls back to the dashboard (the pre-fix
    +/// behaviour), so a newly added route can never silently become restorable (and
    +/// crash on a missing `extra`) or surface a PIN-gated screen. Matched by exact
    +/// path: `/settings/seed` != `/settings`, `/buyPaymentDetails` != `/buy`.
    +const Set restorableLocations = {
    +  '/kyc',
    +  '/dashboard',
    +  '/buy',
    +  '/sell',
    +  '/receive',
    +  '/settings',
    +  '/support',
    +};
    +
    +/// Whether [loc]'s path is in the [restorableLocations] allowlist (query
    +/// string ignored).
    +bool isRestorableLocation(String loc) => restorableLocations.contains(Uri.parse(loc).path);
    +
    +/// Pure boot/lock routing decision, evaluated on every HomeBloc / PinAuthCubit
    +/// emission.
    +///
    +/// The gate ladder is evaluated top to bottom, so a restore can only ever be
    +/// returned by the final branch — after `!isPinVerified` has already diverted
    +/// to the PIN gate. A [BootNavRestore] is additionally limited to the
    +/// [restorableLocations] allowlist, so it never re-enters a gate route, never
    +/// bypasses the PIN gate, and never targets a route that needs `extra`.
    +BootNavAction resolveBootNavigation({
    +  required bool isLoadingWallet,
    +  required bool softwareTermsAccepted,
    +  required bool hasWallet,
    +  required bool onboardingCompleted,
    +  required bool isPinSetup,
    +  required bool isPinVerified,
    +  required bool bitboxAddressRecoveryNeeded,
    +  required bool walletLoaded,
    +  required String currentLocation,
    +  required String? resumeLocation,
    +}) {
    +  if (isLoadingWallet) return const BootNavWaitForLoad();
    +  if (!softwareTermsAccepted) return const BootNavGoNamed(AppRoutes.home);
    +  if (!hasWallet) return const BootNavGoNamed(OnboardingRoutes.welcome);
    +  if (!onboardingCompleted) {
    +    return const BootNavGoNamed(OnboardingRoutes.completed);
    +  }
    +  if (!isPinSetup) return const BootNavGoNamed(PinRoutes.setup);
    +  if (!isPinVerified) return const BootNavGoNamed(PinRoutes.verify);
    +  if (bitboxAddressRecoveryNeeded) {
    +    // A BitBox wallet was persisted with an empty/invalid address — divert to
    +    // the re-pairing recovery flow instead of loading it into the dashboard
    +    // (which would crash the build via `EthereumAddress.fromHex("")`).
    +    return const BootNavGoNamed(AppRoutes.bitboxAddressRecovery);
    +  }
    +  if (!walletLoaded) return const BootNavLoadWallet();
    +
    +  // All gates passed.
    +  if (!isGateLocation(currentLocation)) return const BootNavStay();
    +  if (resumeLocation != null && isRestorableLocation(resumeLocation)) {
    +    return BootNavRestore(resumeLocation);
    +  }
    +  return const BootNavGoNamed(AppRoutes.dashboard);
    +}
    +
    +/// Executes a [resolveBootNavigation] decision against [router]. Split out from
    +/// `main.dart`'s `_navigate` so the routing side effects can be driven against a
    +/// real [GoRouter] in a widget test (the decision itself is covered by the pure
    +/// table). [onLoadWallet] triggers the wallet load; [onClearResume] drops the
    +/// captured resume location once it is spent.
    +void applyBootNavAction(
    +  BootNavAction action,
    +  GoRouter router, {
    +  required void Function() onLoadWallet,
    +  required void Function() onClearResume,
    +}) {
    +  switch (action) {
    +    case BootNavWaitForLoad():
    +      return;
    +    case BootNavLoadWallet():
    +      // Wallet not loaded yet — trigger load and wait for the HomeBloc update.
    +      onLoadWallet();
    +      return;
    +    case BootNavGoNamed(:final routeName):
    +      // Reaching the dashboard is the final landing — drop any stale resume
    +      // capture so a later benign emission can't bounce the user around.
    +      if (routeName == AppRoutes.dashboard) onClearResume();
    +      router.goNamed(routeName);
    +      return;
    +    case BootNavRestore(:final location):
    +      // Return to the in-flight route the user was on before the re-lock,
    +      // reached only after the PIN gate above has already passed. Rebuild the
    +      // real entry shape — dashboard as base, flow pushed on top — so the
    +      // flow's pop-based exits (Close buttons, AppBar auto-back) keep working;
    +      // a bare `go` would strand the restored route as the only match with
    +      // nothing underneath to pop back to.
    +      onClearResume();
    +      if (Uri.parse(location).path == '/dashboard') {
    +        router.go(location);
    +      } else {
    +        router.goNamed(AppRoutes.dashboard);
    +        unawaited(router.push(location));
    +      }
    +      return;
    +    case BootNavStay():
    +      // Already on a valid non-gate route — discard any stale capture.
    +      onClearResume();
    +      return;
    +  }
    +}
    diff --git a/lib/setup/routing/router_config.dart b/lib/setup/routing/router_config.dart
    index 5f2019ff4..88073f096 100644
    --- a/lib/setup/routing/router_config.dart
    +++ b/lib/setup/routing/router_config.dart
    @@ -45,6 +45,7 @@ import 'package:realunit_wallet/screens/transaction_history/transaction_history_
     import 'package:realunit_wallet/screens/verify_seed/verify_seed_page.dart';
     import 'package:realunit_wallet/screens/web_view/web_view_page.dart';
     import 'package:realunit_wallet/screens/welcome/welcome_page.dart';
    +import 'package:realunit_wallet/setup/routing/boot_navigation.dart';
     import 'package:realunit_wallet/setup/routing/routes/app_link_entry.dart';
     import 'package:realunit_wallet/setup/routing/routes/app_routes.dart';
     import 'package:realunit_wallet/setup/routing/routes/legal_routes.dart';
    @@ -56,13 +57,16 @@ import 'package:realunit_wallet/setup/routing/routes/support_routes.dart';
     final GoRouter routerConfig = GoRouter(
       initialLocation: '/home',
       // Custom-scheme opens (realunit-wallet://…, canonical realunit-wallet://open)
    -  // only foreground the app; they must not force any navigation. The redirect
    -  // keeps the current in-app route (warm resume) or hands off to the normal
    -  // boot entry (cold start). See app_link_entry.dart.
    +  // only foreground the app; they must not force any navigation. Cold start:
    +  // the redirect hands off to the normal boot entry. Warm resume: the redirect
    +  // lets the scheme URL fall through unmatched and onException keeps the
    +  // current configuration — the only true no-op that survives imperatively
    +  // pushed routes (e.g. the KYC flow). See app_link_entry.dart.
       redirect: (context, state) => appLinkSchemeRedirect(
         state,
    -    routerConfig.routerDelegate.currentConfiguration.uri.toString(),
    +    effectiveLocation(routerConfig.routerDelegate.currentConfiguration),
       ),
    +  onException: appLinkOnException,
       routes: [
         GoRoute(
           name: AppRoutes.home,
    diff --git a/lib/setup/routing/routes/app_link_entry.dart b/lib/setup/routing/routes/app_link_entry.dart
    index 769f844bc..4a846fb80 100644
    --- a/lib/setup/routing/routes/app_link_entry.dart
    +++ b/lib/setup/routing/routes/app_link_entry.dart
    @@ -1,3 +1,4 @@
    +import 'package:flutter/widgets.dart';
     import 'package:go_router/go_router.dart';
     
     /// Custom URL scheme the app is opened with. Registered in
    @@ -16,22 +17,69 @@ const String appLinkColdStartLocation = '/home';
     /// Top-level go_router redirect for custom-scheme opens.
     ///
     /// A `realunit-wallet://…` open (canonical `realunit-wallet://open`) only brings
    -/// the app to the foreground — it must NOT force any in-app navigation. So on a
    -/// warm resume it keeps the user exactly where they were: [currentLocation] is
    -/// the router's current in-app route, returned unchanged (a no-op). On a cold
    -/// start there is no prior in-app route (currentLocation is empty or the scheme
    -/// URL itself), so it hands off to the normal boot entry and lets `main.dart`'s
    -/// boot flow take over — the PIN gate and boot ordering stay untouched. Either
    -/// way go_router never shows an error screen for a scheme URL that matches no
    -/// path route. Non-scheme (in-app) navigation is left untouched (`null`).
    +/// the app to the foreground — it must NOT force any in-app navigation.
    +///
    +/// Cold start: there is no prior in-app route ([currentLocation] is empty or
    +/// the scheme URL itself), so it hands off to the normal boot entry and lets
    +/// `main.dart`'s boot flow take over — the PIN gate and boot ordering stay
    +/// untouched.
    +///
    +/// Warm resume, canonical path-less open (`realunit-wallet://open`): it returns
    +/// `null` so the scheme URL stays unmatched and falls through to
    +/// [appLinkOnException], which keeps the current route configuration untouched
    +/// — a true no-op. Returning a location string here instead would be applied as
    +/// a `go` and REPLACE the whole match list: an imperatively pushed route (the
    +/// KYC flow from Buy/Sell) would be dropped together with its page-scoped
    +/// state, its `extra`, and the back stack underneath it.
    +///
    +/// Warm resume, scheme URL CARRYING a path: go_router matches on `uri.path`
    +/// alone, so `realunit-wallet://open/settings/seed` would match — and with a
    +/// `null` return it would navigate, straight past flow-level gates (the seed
    +/// page's PIN gate lives in the settings navigation path, not on the route).
    +/// Returning the current location instead would be no safer: applied as a
    +/// `go`, it rebuilds a pushed `extra`-required route (`/buyPaymentDetails`,
    +/// `/webView`, …) with a null `extra` and crashes its builder cast. So crafted
    +/// path-carrying URLs are rewritten to the canonical [appLinkUrl]: go_router
    +/// resolves the rewritten URL to an unmatched (error) match list and
    +/// short-circuits before any further redirect pass, so the chain terminates
    +/// and [appLinkOnException] keeps the configuration — the same true no-op as
    +/// the canonical open, with no rebuild at all.
    +///
    +/// Non-scheme (in-app) navigation is left untouched (`null`).
     //
     // @no-integration-test: OS-level custom-scheme delivery and foregrounding can
     // only be exercised on a real device, and no integration_test/ harness exists
    -// in this repo yet. The redirect itself is covered by widget tests in
    +// in this repo yet. The redirect is covered by widget tests in
     // app_link_entry_test.dart.
     String? appLinkSchemeRedirect(GoRouterState state, String currentLocation) {
       if (state.uri.scheme != appLinkScheme) return null;
       final current = Uri.parse(currentLocation);
       final isInAppRoute = current.scheme.isEmpty && current.path.startsWith('/');
    -  return isInAppRoute ? currentLocation : appLinkColdStartLocation;
    +  if (!isInAppRoute) return appLinkColdStartLocation;
    +  final hasPath = state.uri.path.isNotEmpty && state.uri.path != '/';
    +  return hasPath ? appLinkUrl : null;
    +}
    +
    +/// go_router exception handler paired with [appLinkSchemeRedirect].
    +///
    +/// With an `onException` handler installed, go_router keeps the current route
    +/// configuration whenever a URL matches no route (go_router `router.dart`:
    +/// "Avoid updating GoRouterDelegate if onException is provided"). A warm
    +/// custom-scheme open is exactly that case — [appLinkSchemeRedirect] lets it
    +/// fall through unmatched — so doing nothing here IS the foregrounding no-op,
    +/// and it is the only variant that survives imperatively pushed routes.
    +/// Cold-start scheme opens never reach this handler; the redirect already
    +/// mapped them to [appLinkColdStartLocation].
    +//
    +// @no-integration-test: OS-level custom-scheme delivery and foregrounding can
    +// only be exercised on a real device, and no integration_test/ harness exists
    +// in this repo yet. The handler is covered by widget tests in
    +// app_link_entry_test.dart.
    +void appLinkOnException(BuildContext context, GoRouterState state, GoRouter router) {
    +  if (state.uri.scheme == appLinkScheme) return;
    +  // A non-scheme URL that matches no route is a programming error. Installing
    +  // onException removes go_router's default error screen, so fail loud where
    +  // tests and debug builds can see it; in release the user stays on the
    +  // current screen instead of landing on an error page.
    +  assert(false, 'No route matches ${state.uri}');
     }
    diff --git a/lib/widgets/form/country_field.dart b/lib/widgets/form/country_field.dart
    index ada1b7b77..5720a54b5 100644
    --- a/lib/widgets/form/country_field.dart
    +++ b/lib/widgets/form/country_field.dart
    @@ -28,12 +28,17 @@ class CountryField extends StatefulWidget {
       final Country? initialValue;
       final void Function(Country?)? onChanged;
     
    +  /// 2-letter country symbols to omit from the picker (e.g. already selected
    +  /// tax-residence rows). Matching is case-sensitive on [Country.symbol].
    +  final Set excludeSymbols;
    +
       const CountryField({
         super.key,
         required this.label,
         required this.purpose,
         this.initialValue,
         this.onChanged,
    +    this.excludeSymbols = const {},
       });
     
       @override
    @@ -87,7 +92,10 @@ class _CountryFieldState extends State {
               );
             }
     
    -        final countries = snapshot.data!.where(widget.purpose.allows).toList();
    +        final countries = snapshot.data!
    +            .where(widget.purpose.allows)
    +            .where((c) => !widget.excludeSymbols.contains(c.symbol))
    +            .toList();
             final initial = widget.initialValue != null && countries.contains(widget.initialValue)
                 ? widget.initialValue
                 : null;
    @@ -106,6 +114,7 @@ class _CountryFieldState extends State {
               initialValue: initial,
               onChanged: widget.onChanged,
               validator: (value) => value == null ? '' : null,
    +          autovalidateMode: AutovalidateMode.onUserInteraction,
             );
           },
         );
    diff --git a/lib/widgets/form/dropdown_field.dart b/lib/widgets/form/dropdown_field.dart
    index bb424a95d..489f34990 100644
    --- a/lib/widgets/form/dropdown_field.dart
    +++ b/lib/widgets/form/dropdown_field.dart
    @@ -9,6 +9,7 @@ class DropdownField extends StatelessWidget {
       final String? Function(T?)? validator;
       final List> items;
       final bool hideErrorText;
    +  final AutovalidateMode? autovalidateMode;
     
       const DropdownField({
         super.key,
    @@ -19,6 +20,7 @@ class DropdownField extends StatelessWidget {
         this.validator,
         required this.items,
         this.hideErrorText = true,
    +    this.autovalidateMode,
       });
     
       @override
    @@ -46,6 +48,7 @@ class DropdownField extends StatelessWidget {
               child: DropdownButtonFormField(
                 initialValue: initialValue,
                 validator: validator,
    +            autovalidateMode: autovalidateMode,
                 onChanged: onChanged,
                 items: items,
                 isExpanded: true,
    diff --git a/lib/widgets/form/file_picker_field.dart b/lib/widgets/form/file_picker_field.dart
    index 30b1cbd9f..60d1337d7 100644
    --- a/lib/widgets/form/file_picker_field.dart
    +++ b/lib/widgets/form/file_picker_field.dart
    @@ -64,6 +64,7 @@ class FilePickerField extends StatelessWidget {
                           Expanded(
                             child: Text(
                               selectedFile!.name,
    +                          maxLines: 1,
                               overflow: .ellipsis,
                               style: Theme.of(context).textTheme.bodyMedium,
                             ),
    @@ -82,10 +83,14 @@ class FilePickerField extends StatelessWidget {
                             Icons.add_a_photo_rounded,
                             color: RealUnitColors.neutral400,
                           ),
    -                      Text(
    -                        label,
    -                        style: const TextStyle(
    -                          color: RealUnitColors.neutral400,
    +                      Flexible(
    +                        child: Text(
    +                          label,
    +                          maxLines: 1,
    +                          overflow: .ellipsis,
    +                          style: const TextStyle(
    +                            color: RealUnitColors.neutral400,
    +                          ),
                             ),
                           ),
                         ],
    diff --git a/lib/widgets/form/labeled_text_field.dart b/lib/widgets/form/labeled_text_field.dart
    index 98221c7ce..79a38bbe0 100644
    --- a/lib/widgets/form/labeled_text_field.dart
    +++ b/lib/widgets/form/labeled_text_field.dart
    @@ -1,4 +1,5 @@
     import 'package:flutter/material.dart';
    +import 'package:flutter/services.dart';
     import 'package:realunit_wallet/styles/colors.dart';
     
     class LabeledTextField extends StatelessWidget {
    @@ -12,6 +13,7 @@ class LabeledTextField extends StatelessWidget {
       final int? maxLines;
       final bool hideErrorText;
       final TextCapitalization textCapitalization;
    +  final List? inputFormatters;
     
       const LabeledTextField({
         super.key,
    @@ -25,6 +27,7 @@ class LabeledTextField extends StatelessWidget {
         this.maxLines = 1,
         this.hideErrorText = true,
         this.textCapitalization = TextCapitalization.none,
    +    this.inputFormatters,
       });
     
       @override
    @@ -81,6 +84,7 @@ class LabeledTextField extends StatelessWidget {
               ),
               maxLines: maxLines,
               keyboardType: keyboardType,
    +          inputFormatters: inputFormatters,
               validator: validator,
             ),
           ],
    diff --git a/lib/widgets/scrollable_actions_layout.dart b/lib/widgets/scrollable_actions_layout.dart
    new file mode 100644
    index 000000000..3c4063cf3
    --- /dev/null
    +++ b/lib/widgets/scrollable_actions_layout.dart
    @@ -0,0 +1,158 @@
    +import 'package:flutter/material.dart';
    +
    +/// Column layout that keeps [actions] reachable on every viewport and text scale.
    +///
    +/// **Contract**
    +/// - [body] scrolls inside a [SingleChildScrollView] whenever it grows with
    +///   content or accessibility text scale.
    +/// - [actions] (primary/secondary CTAs) stay **outside** the scroll view, in a
    +///   sticky block below it, so they never leave the hit-testable region (the
    +///   BitBox pairing regression).
    +/// - This widget requires a **bounded height** (bottom sheet, `Expanded`, or a
    +///   `SizedBox`). Giving it an unbounded height is a programming error: there
    +///   would be no room to scroll and the sticky actions would be pushed out of
    +///   the hit-test region — exactly the bug this widget exists to prevent. An
    +///   unbounded height throws a [FlutterError] in every build mode (debug and
    +///   release), so the failure is always loud and never a silently degraded
    +///   layout.
    +/// - The body is always given at least the leftover viewport height, so
    +///   [mainAxisAlignment] (and vertical centering via [centerBody]) works inside
    +///   it. A [Spacer] inside [body] is **not** allowed: inside a scroll view's
    +///   unbounded main axis it is a programming error (RenderFlex "unbounded"
    +///   exception), unlike the outer bounded-height contract above.
    +/// - When the actions alone need the entire viewport, [Expanded] for [body]
    +///   collapses toward zero and body content becomes unreachable while the
    +///   actions stay reachable — a deliberate trade (a visible, scrollable CTA
    +///   beats a dead one) and strictly better than the pre-fix overflow.
    +///
    +/// Use this for every bottom sheet and full-screen flow that combines long copy
    +/// with bottom CTAs. Do not put a [Spacer] above buttons and hope it fits.
    +class ScrollableActionsLayout extends StatelessWidget {
    +  const ScrollableActionsLayout({
    +    super.key,
    +    required this.body,
    +    this.actions = const [],
    +    this.padding = EdgeInsets.zero,
    +    this.actionsSpacing = 12,
    +    this.scrollPhysics,
    +    this.centerBody = false,
    +    this.shrinkWrap = false,
    +  });
    +
    +  /// Scrollable main content (illustration, titles, forms, hints).
    +  final Widget body;
    +
    +  /// Sticky action widgets, top-to-bottom (confirm above cancel).
    +  final List actions;
    +
    +  /// Outer padding around the whole layout.
    +  final EdgeInsetsGeometry padding;
    +
    +  /// Vertical gap between action widgets.
    +  final double actionsSpacing;
    +
    +  final ScrollPhysics? scrollPhysics;
    +
    +  /// Vertically centre [body] while it fits the viewport (it still scrolls once
    +  /// it outgrows it). Use for screens that centred their content with a
    +  /// `Spacer()` above and below — without this they would top-align.
    +  final bool centerBody;
    +
    +  /// Size to content (up to the incoming max height) instead of expanding to
    +  /// fill it. For bottom sheets / dialogs that should be only as tall as their
    +  /// content but still cap-and-scroll when content grows past the available
    +  /// height. In this mode the body is NOT floored to the viewport height (so it
    +  /// genuinely shrink-wraps), the actions stay pinned below it, and the whole
    +  /// layout scrolls its body once content exceeds the cap.
    +  final bool shrinkWrap;
    +
    +  @override
    +  Widget build(BuildContext context) {
    +    return Padding(
    +      padding: padding,
    +      child: LayoutBuilder(
    +        builder: (context, constraints) {
    +          if (!constraints.hasBoundedHeight) {
    +            throw FlutterError.fromParts([
    +              ErrorSummary('ScrollableActionsLayout requires a bounded height.'),
    +              ErrorDescription(
    +                'It was given an unbounded height, so the body could not scroll and the '
    +                'sticky actions would be pushed out of the hit-test region — the exact '
    +                'bug this widget exists to prevent.',
    +              ),
    +              ErrorHint(
    +                'Host it in a bottom sheet, an Expanded, or a SizedBox with a fixed '
    +                'height. A Column(mainAxisSize: .min) hands its children an unbounded '
    +                'height and is not a valid host.',
    +              ),
    +            ]);
    +          }
    +
    +          final Widget actionBlock = actions.isEmpty
    +              ? const SizedBox.shrink()
    +              : ConstrainedBox(
    +                  // A Column lays out non-flex children with an UNBOUNDED main axis, so
    +                  // without this the action block takes its full intrinsic height and
    +                  // overflows the Column when it exceeds the viewport — clipping the CTA
    +                  // out of the hit-test region, the exact bug this widget prevents.
    +                  // Bounding it at the viewport height is enough: a SingleChildScrollView
    +                  // under a loose maxHeight shrink-wraps to its child, so when the actions
    +                  // fit (the normal case) NOTHING changes; only when they would overflow
    +                  // are they capped and scrolled internally.
    +                  constraints: BoxConstraints(maxHeight: constraints.maxHeight),
    +                  child: SingleChildScrollView(
    +                    key: const Key('scrollable_actions_layout.actions_scroll_view'),
    +                    child: Column(
    +                      mainAxisSize: .min,
    +                      spacing: actionsSpacing,
    +                      children: actions,
    +                    ),
    +                  ),
    +                );
    +
    +          return Column(
    +            crossAxisAlignment: .stretch,
    +            mainAxisSize: shrinkWrap ? MainAxisSize.min : MainAxisSize.max,
    +            children: [
    +              if (shrinkWrap)
    +                Flexible(
    +                  child: SingleChildScrollView(
    +                    key: const Key('scrollable_actions_layout.body_scroll_view'),
    +                    physics: scrollPhysics,
    +                    child: SizedBox(
    +                      // Center() below loosens the width constraint; without this the
    +                      // crossAxisAlignment: .stretch chain breaks for width-dependent bodies.
    +                      width: double.infinity,
    +                      child: centerBody ? Center(child: body) : body,
    +                    ),
    +                  ),
    +                )
    +              else
    +                Expanded(
    +                  child: LayoutBuilder(
    +                    builder: (context, viewport) => SingleChildScrollView(
    +                      key: const Key('scrollable_actions_layout.body_scroll_view'),
    +                      physics: scrollPhysics,
    +                      child: ConstrainedBox(
    +                        // Always at least the leftover viewport height, so bodies may use
    +                        // mainAxisAlignment / centering. Once the body outgrows the viewport
    +                        // minHeight stops binding and the body simply scrolls.
    +                        constraints: BoxConstraints(minHeight: viewport.maxHeight),
    +                        child: SizedBox(
    +                          // Center() below loosens the width constraint; without this the
    +                          // crossAxisAlignment: .stretch chain breaks for width-dependent bodies.
    +                          width: double.infinity,
    +                          child: centerBody ? Center(child: body) : body,
    +                        ),
    +                      ),
    +                    ),
    +                  ),
    +                ),
    +              actionBlock,
    +            ],
    +          );
    +        },
    +      ),
    +    );
    +  }
    +}
    diff --git a/lib/widgets/tag_selection.dart b/lib/widgets/tag_selection.dart
    index 0957b158e..ca320949e 100644
    --- a/lib/widgets/tag_selection.dart
    +++ b/lib/widgets/tag_selection.dart
    @@ -30,7 +30,13 @@ class TagSelection extends StatelessWidget {
                           size: 18,
                           color: isSelected ? RealUnitColors.basic.white : RealUnitColors.neutral600,
                         ),
    -                    Text(item.$2),
    +                    Flexible(
    +                      child: Text(
    +                        item.$2,
    +                        maxLines: 1,
    +                        overflow: .ellipsis,
    +                      ),
    +                    ),
                       ],
                     )
                   : Text(item.$2),
    diff --git a/pubspec.yaml b/pubspec.yaml
    index 24740e69b..13998da87 100644
    --- a/pubspec.yaml
    +++ b/pubspec.yaml
    @@ -20,7 +20,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
     # derived from the release tag (see tool/generate_release_info.dart). Local
     # builds carry `+0` so the settings footer shows `dev` instead of a stale
     # pinned build number.
    -version: 1.1.0+0
    +version: 1.2.0+0
     
     environment:
       sdk: ">=3.10.0 <4.0.0"
    diff --git a/scripts/assemble-handbook-screenshots.sh b/scripts/assemble-handbook-screenshots.sh
    index 10069d8d2..9a55f00ea 100755
    --- a/scripts/assemble-handbook-screenshots.sh
    +++ b/scripts/assemble-handbook-screenshots.sh
    @@ -1,6 +1,6 @@
     #!/usr/bin/env bash
     #
    -# Assemble the 61 handbook screenshots from the visual-regression Golden
    +# Assemble the 278 handbook screenshots from the visual-regression Golden
     # baselines. The flat `NN-name.png` output layout matches what
     # docs/handbook/de/index.html links to (``
     # — the relative path resolves to `docs/handbook/screenshots/NN-name.png`).
    @@ -14,8 +14,10 @@
     #     before opening docs/handbook/de/index.html; the target dir is git-ignored)
     #
     # Source of truth for every handbook page is one Golden PNG under
    -# `test/goldens/screens//goldens/macos/.png`. The mapping
    -# table below was established in the gap-audit on PR #568. When a new
    +# `test/goldens//goldens/macos/.png`, where  is `screens/`
    +# (nearly all) or `widgets/` (shared widgets such as the phone-number field).
    +# The mapping table below was established in the gap-audit on PR #568 and later
    +# extended to cover every Golden baseline. When a new
     # handbook page is added, append a row here AND add the corresponding
     # Golden test — never source a screenshot from anywhere else.
     
    @@ -28,74 +30,292 @@ fi
     
     OUT="$1"
     REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
    -GOLDENS_ROOT="$REPO_ROOT/test/goldens/screens"
    +GOLDENS_ROOT="$REPO_ROOT/test/goldens"
     
     mkdir -p "$OUT"
     
    -# handbook-name → relative golden path (under test/goldens/screens/)
    -# Keep this list in sync with .maestro/handbook/*.yaml (one entry per flow).
    +# handbook-name → golden path relative to test/goldens/ (under screens/ or widgets/)
    +# Not every entry has a maestro flow: only slots 01–26 have a companion
    +# .maestro/handbook/*.yaml smoke flow; slots 27+ are golden-only.
     MAPPING=(
    -  "01-welcome=home/goldens/macos/home_page_default.png"
    -  "02-create-vs-restore=welcome/goldens/macos/welcome_page_ios.png"
    -  "03-software-wallet-terms=welcome/goldens/macos/welcome_page_second_step.png"
    -  "04-seed-hidden=create_wallet/goldens/macos/create_wallet_page_default.png"
    -  "05-seed-revealed=create_wallet/goldens/macos/create_wallet_page_revealed.png"
    -  "06-verify-seed=verify_seed/goldens/macos/verify_seed_page_default.png"
    -  "07-onboarding-completed=onboarding/goldens/macos/onboarding_completed_page_default.png"
    -  "08-pin-setup=pin/goldens/macos/setup_pin_page_default.png"
    -  "09-pin-confirm=pin/goldens/macos/setup_pin_page_confirming.png"
    -  "10-biometric-prompt=pin/goldens/macos/biometric_prompt_sheet_default.png"
    -  "11-dashboard=home/goldens/macos/home_page_loaded.png"
    -  "12-settings=settings/goldens/macos/settings_page_default.png"
    -  "13-settings-languages=settings_languages/goldens/macos/settings_languages_page_default.png"
    -  "14-settings-currency=settings_currencies/goldens/macos/settings_currencies_page_default.png"
    -  "15-settings-network=settings_network/goldens/macos/settings_network_page_default.png"
    -  "16-settings-wallet-address=settings_wallet_address/goldens/macos/settings_wallet_address_page_default.png"
    -  "17-settings-backup-pin=pin/goldens/macos/verify_pin_page_seed_backup.png"
    -  "18-settings-seed-hidden=settings_seed/goldens/macos/settings_seed_page_default.png"
    -  "19-settings-seed-revealed=settings_seed/goldens/macos/settings_seed_page_revealed.png"
    -  "20-settings-legal-documents=settings_legal_documents/goldens/macos/settings_legal_documents_page_default.png"
    -  "21-settings-aktionariat-documents=settings_legal_documents/goldens/macos/settings_aktionariat_documents_page_default.png"
    -  "22-settings-dfx-documents=settings_legal_documents/goldens/macos/settings_dfx_documents_page_default.png"
    -  "23-settings-contact=settings_contact/goldens/macos/settings_contact_page_default.png"
    -  "24-settings-delete-wallet=settings/goldens/macos/settings_confirm_logout_wallet_sheet_default.png"
    -  "25-restore-wallet=restore_wallet/goldens/macos/restore_wallet_page_default.png"
    -  "26-terms=legal/goldens/macos/legal_document_page_terms_loaded.png"
    -  "27-software-wallet-disclaimer-step-0=legal/goldens/macos/legal_disclaimer_step_0.png"
    -  "28-software-wallet-disclaimer-step-1=legal/goldens/macos/legal_disclaimer_step_1.png"
    -  "29-legal-documents-step=legal/goldens/macos/legal_documents_step_default.png"
    -  "30-legal-aktionariat-step=legal/goldens/macos/legal_aktionariat_step_default.png"
    -  "31-legal-dfx-step=legal/goldens/macos/legal_dfx_step_default.png"
    -  "32-restore-wallet-valid=restore_wallet/goldens/macos/restore_wallet_page_valid.png"
    -  "33-restore-wallet-invalid=restore_wallet/goldens/macos/restore_wallet_page_invalid.png"
    -  "34-verify-seed-completed=verify_seed/goldens/macos/verify_seed_page_all_entered.png"
    -  "35-dashboard-with-balance=dashboard/goldens/macos/dashboard_with_balance.png"
    -  "36-transaction-history=transaction_history/goldens/macos/transaction_history_page_with_transactions.png"
    -  "37-kyc-email-loading=kyc/goldens/macos/kyc_email_page_loading.png"
    -  "38-kyc-email-mismatch=kyc/goldens/macos/kyc_email_page_does_not_match.png"
    -  "39-kyc-email-error=kyc/goldens/macos/kyc_email_page_unknown_error.png"
    -  "40-kyc-registration-personal=kyc/goldens/macos/kyc_registration_personal_step_default.png"
    -  "41-kyc-registration-address=kyc/goldens/macos/kyc_registration_address_step_default.png"
    -  "42-settings-tax-report-loading=settings_tax_report/goldens/macos/settings_tax_report_page_loading.png"
    -  "43-settings-tax-report-failure=settings_tax_report/goldens/macos/settings_tax_report_page_failure.png"
    -  "44-buy-loading=buy/goldens/macos/buy_payment_info_loading.png"
    -  "45-buy-registration-required=buy/goldens/macos/buy_registration_required.png"
    -  "46-buy-kyc-required=buy/goldens/macos/buy_kyc_required.png"
    -  "47-buy-min-amount=buy/goldens/macos/buy_min_amount_not_met.png"
    -  "48-buy-unknown-error=buy/goldens/macos/buy_unknown_error.png"
    -  "49-sell-loading=sell/goldens/macos/sell_payment_info_loading.png"
    -  "50-sell-kyc-required=sell/goldens/macos/sell_kyc_required.png"
    -  "51-sell-min-amount=sell/goldens/macos/sell_min_amount_not_met.png"
    -  "52-sell-unknown-error=sell/goldens/macos/sell_unknown_error.png"
    -  "53-buy-payment-details=buy/goldens/macos/buy_payment_details_default.png"
    -  "54-kyc-registration-personal-dropdown=kyc/goldens/macos/kyc_registration_personal_step_dropdown_open.png"
    -  "55-kyc-registration-address-dropdown=kyc/goldens/macos/kyc_registration_address_step_dropdown_open.png"
    -  "56-kyc-registration-tax=kyc/goldens/macos/kyc_registration_tax_step_default.png"
    -  "57-kyc-registration-tax-dropdown=kyc/goldens/macos/kyc_registration_tax_step_dropdown_open.png"
    -  "58-kyc-registration-tax-swiss=kyc/goldens/macos/kyc_registration_tax_step_swiss.png"
    -  "59-kyc-registration-tax-non-swiss=kyc/goldens/macos/kyc_registration_tax_step_non_swiss.png"
    -  "60-kyc-registration-tax-country-error=kyc/goldens/macos/kyc_registration_tax_step_country_error.png"
    -  "61-kyc-registration-tax-tin-error=kyc/goldens/macos/kyc_registration_tax_step_tin_error.png"
    +  "01-welcome=screens/home/goldens/macos/home_page_default.png"
    +  "02-create-vs-restore=screens/welcome/goldens/macos/welcome_page_ios.png"
    +  "03-software-wallet-terms=screens/welcome/goldens/macos/welcome_page_second_step.png"
    +  "04-seed-hidden=screens/create_wallet/goldens/macos/create_wallet_page_default.png"
    +  "05-seed-revealed=screens/create_wallet/goldens/macos/create_wallet_page_revealed.png"
    +  "06-verify-seed=screens/verify_seed/goldens/macos/verify_seed_page_default.png"
    +  "07-onboarding-completed=screens/onboarding/goldens/macos/onboarding_completed_page_default.png"
    +  "08-pin-setup=screens/pin/goldens/macos/setup_pin_page_default.png"
    +  "09-pin-confirm=screens/pin/goldens/macos/setup_pin_page_confirming.png"
    +  "10-biometric-prompt=screens/pin/goldens/macos/biometric_prompt_sheet_default.png"
    +  "11-dashboard=screens/home/goldens/macos/home_page_loaded.png"
    +  "12-settings=screens/settings/goldens/macos/settings_page_default.png"
    +  "13-settings-languages=screens/settings_languages/goldens/macos/settings_languages_page_default.png"
    +  "14-settings-currency=screens/settings_currencies/goldens/macos/settings_currencies_page_default.png"
    +  "15-settings-network=screens/settings_network/goldens/macos/settings_network_page_default.png"
    +  "16-settings-wallet-address=screens/settings_wallet_address/goldens/macos/settings_wallet_address_page_default.png"
    +  "17-settings-backup-pin=screens/pin/goldens/macos/verify_pin_page_seed_backup.png"
    +  "18-settings-seed-hidden=screens/settings_seed/goldens/macos/settings_seed_page_default.png"
    +  "19-settings-seed-revealed=screens/settings_seed/goldens/macos/settings_seed_page_revealed.png"
    +  "20-settings-legal-documents=screens/settings_legal_documents/goldens/macos/settings_legal_documents_page_default.png"
    +  "21-settings-aktionariat-documents=screens/settings_legal_documents/goldens/macos/settings_aktionariat_documents_page_default.png"
    +  "22-settings-dfx-documents=screens/settings_legal_documents/goldens/macos/settings_dfx_documents_page_default.png"
    +  "23-settings-contact=screens/settings_contact/goldens/macos/settings_contact_page_default.png"
    +  "24-settings-delete-wallet=screens/settings/goldens/macos/settings_confirm_logout_wallet_sheet_default.png"
    +  "25-restore-wallet=screens/restore_wallet/goldens/macos/restore_wallet_page_default.png"
    +  "26-terms=screens/legal/goldens/macos/legal_document_page_terms_loaded.png"
    +  "27-software-wallet-disclaimer-step-0=screens/legal/goldens/macos/legal_disclaimer_step_0.png"
    +  "28-software-wallet-disclaimer-step-1=screens/legal/goldens/macos/legal_disclaimer_step_1.png"
    +  "29-legal-documents-step=screens/legal/goldens/macos/legal_documents_step_default.png"
    +  "30-legal-aktionariat-step=screens/legal/goldens/macos/legal_aktionariat_step_default.png"
    +  "31-legal-dfx-step=screens/legal/goldens/macos/legal_dfx_step_default.png"
    +  "32-restore-wallet-valid=screens/restore_wallet/goldens/macos/restore_wallet_page_valid.png"
    +  "33-restore-wallet-invalid=screens/restore_wallet/goldens/macos/restore_wallet_page_invalid.png"
    +  "34-verify-seed-completed=screens/verify_seed/goldens/macos/verify_seed_page_all_entered.png"
    +  "35-dashboard-with-balance=screens/dashboard/goldens/macos/dashboard_with_balance.png"
    +  "36-transaction-history=screens/transaction_history/goldens/macos/transaction_history_page_with_transactions.png"
    +  "37-kyc-email-loading=screens/kyc/goldens/macos/kyc_email_page_loading.png"
    +  "38-kyc-email-mismatch=screens/kyc/goldens/macos/kyc_email_page_does_not_match.png"
    +  "39-kyc-email-error=screens/kyc/goldens/macos/kyc_email_page_unknown_error.png"
    +  "40-kyc-registration-personal=screens/kyc/goldens/macos/kyc_registration_personal_step_default.png"
    +  "41-kyc-registration-address=screens/kyc/goldens/macos/kyc_registration_address_step_default.png"
    +  "42-settings-tax-report-loading=screens/settings_tax_report/goldens/macos/settings_tax_report_page_loading.png"
    +  "43-settings-tax-report-failure=screens/settings_tax_report/goldens/macos/settings_tax_report_page_failure.png"
    +  "44-buy-loading=screens/buy/goldens/macos/buy_payment_info_loading.png"
    +  "45-buy-registration-required=screens/buy/goldens/macos/buy_registration_required.png"
    +  "46-buy-kyc-required=screens/buy/goldens/macos/buy_kyc_required.png"
    +  "47-buy-min-amount=screens/buy/goldens/macos/buy_min_amount_not_met.png"
    +  "48-buy-unknown-error=screens/buy/goldens/macos/buy_unknown_error.png"
    +  "49-sell-loading=screens/sell/goldens/macos/sell_payment_info_loading.png"
    +  "50-sell-kyc-required=screens/sell/goldens/macos/sell_kyc_required.png"
    +  "51-sell-min-amount=screens/sell/goldens/macos/sell_min_amount_not_met.png"
    +  "52-sell-unknown-error=screens/sell/goldens/macos/sell_unknown_error.png"
    +  "53-buy-payment-details=screens/buy/goldens/macos/buy_payment_details_default.png"
    +  "54-kyc-registration-personal-dropdown=screens/kyc/goldens/macos/kyc_registration_personal_step_dropdown_open.png"
    +  "55-kyc-registration-address-dropdown=screens/kyc/goldens/macos/kyc_registration_address_step_dropdown_open.png"
    +  "56-kyc-registration-tax=screens/kyc/goldens/macos/kyc_registration_tax_step_default.png"
    +  "57-kyc-registration-tax-dropdown=screens/kyc/goldens/macos/kyc_registration_tax_step_dropdown_open.png"
    +  "58-kyc-registration-tax-swiss=screens/kyc/goldens/macos/kyc_registration_tax_step_swiss.png"
    +  "59-kyc-registration-tax-non-swiss=screens/kyc/goldens/macos/kyc_registration_tax_step_non_swiss.png"
    +  "60-kyc-registration-tax-country-error=screens/kyc/goldens/macos/kyc_registration_tax_step_country_error.png"
    +  "61-kyc-registration-tax-tin-error=screens/kyc/goldens/macos/kyc_registration_tax_step_tin_error.png"
    +  "61b-kyc-registration-tax-multi=screens/kyc/goldens/macos/kyc_registration_tax_step_multi.png"
    +  "61c-kyc-tax-scenario-s1-ch-only=screens/kyc/goldens/macos/kyc_tax_scenario_s1_ch_only.png"
    +  "61d-kyc-tax-scenario-s2-de-only-empty-tin=screens/kyc/goldens/macos/kyc_tax_scenario_s2_de_only_empty_tin.png"
    +  "61e-kyc-tax-scenario-s2-de-only-filled=screens/kyc/goldens/macos/kyc_tax_scenario_s2_de_only_filled.png"
    +  "61f-kyc-tax-scenario-s2-de-only-tin-error=screens/kyc/goldens/macos/kyc_tax_scenario_s2_de_only_tin_error.png"
    +  "61g-kyc-tax-scenario-s3-ch-fr=screens/kyc/goldens/macos/kyc_tax_scenario_s3_ch_fr.png"
    +  "61h-kyc-tax-scenario-s3-ch-fr-tin-error=screens/kyc/goldens/macos/kyc_tax_scenario_s3_ch_fr_tin_error.png"
    +  "61i-kyc-tax-scenario-s4-de-ch=screens/kyc/goldens/macos/kyc_tax_scenario_s4_de_ch.png"
    +  "61j-kyc-tax-scenario-s5-de-fr-us=screens/kyc/goldens/macos/kyc_tax_scenario_s5_de_fr_us.png"
    +  "61k-kyc-tax-scenario-s5-de-fr-us-partial-tin-error=screens/kyc/goldens/macos/kyc_tax_scenario_s5_de_fr_us_partial_tin_error.png"
    +  "62-welcome-page-android=screens/welcome/goldens/macos/welcome_page_android.png"
    +  "63-create-wallet-page-loading=screens/create_wallet/goldens/macos/create_wallet_page_loading.png"
    +  "64-verify-seed-page-verifying=screens/verify_seed/goldens/macos/verify_seed_page_verifying.png"
    +  "65-verify-seed-page-verified=screens/verify_seed/goldens/macos/verify_seed_page_verified.png"
    +  "66-verify-seed-page-error=screens/verify_seed/goldens/macos/verify_seed_page_error.png"
    +  "67-verify-seed-page-commit-failed=screens/verify_seed/goldens/macos/verify_seed_page_commit_failed.png"
    +  "68-verify-seed-page-empty=screens/verify_seed/goldens/macos/verify_seed_page_empty.png"
    +  "69-restore-wallet-page-complete=screens/restore_wallet/goldens/macos/restore_wallet_page_complete.png"
    +  "70-restore-wallet-page-restoring=screens/restore_wallet/goldens/macos/restore_wallet_page_restoring.png"
    +  "71-restore-wallet-page-success=screens/restore_wallet/goldens/macos/restore_wallet_page_success.png"
    +  "72-restore-wallet-page-failed=screens/restore_wallet/goldens/macos/restore_wallet_page_failed.png"
    +  "73-setup-pin-page-submitting=screens/pin/goldens/macos/setup_pin_page_submitting.png"
    +  "74-setup-pin-page-confirm-mismatch=screens/pin/goldens/macos/setup_pin_page_confirm_mismatch.png"
    +  "75-setup-pin-page-store-failed=screens/pin/goldens/macos/setup_pin_page_store_failed.png"
    +  "76-verify-pin-page-default=screens/pin/goldens/macos/verify_pin_page_default.png"
    +  "77-verify-pin-page-verifying=screens/pin/goldens/macos/verify_pin_page_verifying.png"
    +  "78-verify-pin-page-biometric-button=screens/pin/goldens/macos/verify_pin_page_biometric_button.png"
    +  "79-verify-pin-page-biometric-hint=screens/pin/goldens/macos/verify_pin_page_biometric_hint.png"
    +  "80-verify-pin-page-biometric-hint-not-enrolled=screens/pin/goldens/macos/verify_pin_page_biometric_hint_not_enrolled.png"
    +  "81-verify-pin-page-biometric-hint-unavailable=screens/pin/goldens/macos/verify_pin_page_biometric_hint_unavailable.png"
    +  "82-verify-pin-page-failure=screens/pin/goldens/macos/verify_pin_page_failure.png"
    +  "83-verify-pin-page-temporarily-locked=screens/pin/goldens/macos/verify_pin_page_temporarily_locked.png"
    +  "84-verify-pin-page-locked=screens/pin/goldens/macos/verify_pin_page_locked.png"
    +  "85-verify-pin-page-unverifiable=screens/pin/goldens/macos/verify_pin_page_unverifiable.png"
    +  "86-verify-pin-page-app-lock=screens/pin/goldens/macos/verify_pin_page_app_lock.png"
    +  "87-verify-pin-page-unverifiable-app-lock=screens/pin/goldens/macos/verify_pin_page_unverifiable_app_lock.png"
    +  "88-forgot-pin-bottom-sheet-default=screens/pin/goldens/macos/forgot_pin_bottom_sheet_default.png"
    +  "89-dashboard-empty=screens/dashboard/goldens/macos/dashboard_empty.png"
    +  "90-dashboard-price-chart=screens/dashboard/goldens/macos/dashboard_price_chart.png"
    +  "91-dashboard-portfolio-chart=screens/dashboard/goldens/macos/dashboard_portfolio_chart.png"
    +  "92-dashboard-pending-transactions=screens/dashboard/goldens/macos/dashboard_pending_transactions.png"
    +  "93-dashboard-recent-transactions=screens/dashboard/goldens/macos/dashboard_recent_transactions.png"
    +  "94-dashboard-hidden-amounts=screens/dashboard/goldens/macos/dashboard_hidden_amounts.png"
    +  "95-transaction-history-page-default=screens/transaction_history/goldens/macos/transaction_history_page_default.png"
    +  "96-transaction-history-page-list=screens/transaction_history/goldens/macos/transaction_history_page_list.png"
    +  "97-transaction-history-page-date-picker=screens/transaction_history/goldens/macos/transaction_history_page_date_picker.png"
    +  "98-transaction-history-row-receipt-loading=screens/transaction_history/goldens/macos/transaction_history_row_receipt_loading.png"
    +  "99-transaction-history-row-receipt-failure=screens/transaction_history/goldens/macos/transaction_history_row_receipt_failure.png"
    +  "100-transaction-history-multi-receipt-loading=screens/transaction_history/goldens/macos/transaction_history_multi_receipt_loading.png"
    +  "101-receive-page-default=screens/receive/goldens/macos/receive_page_default.png"
    +  "102-receive-page-full-page=screens/receive/goldens/macos/receive_page_full_page.png"
    +  "103-buy-initial=screens/buy/goldens/macos/buy_initial.png"
    +  "104-buy-payment-info-loaded=screens/buy/goldens/macos/buy_payment_info_loaded.png"
    +  "105-buy-currency-picker-open=screens/buy/goldens/macos/buy_currency_picker_open.png"
    +  "106-buy-currency-load-failed=screens/buy/goldens/macos/buy_currency_load_failed.png"
    +  "107-buy-primary-email-required=screens/buy/goldens/macos/buy_primary_email_required.png"
    +  "108-buy-bitbox-disconnected=screens/buy/goldens/macos/buy_bitbox_disconnected.png"
    +  "109-buy-price-source-unavailable=screens/buy/goldens/macos/buy_price_source_unavailable.png"
    +  "110-buy-confirm-loading=screens/buy/goldens/macos/buy_confirm_loading.png"
    +  "111-buy-confirm-failed-aktionariat=screens/buy/goldens/macos/buy_confirm_failed_aktionariat.png"
    +  "112-buy-confirm-failed-amount-too-low=screens/buy/goldens/macos/buy_confirm_failed_amount_too_low.png"
    +  "113-buy-confirm-failed-unknown=screens/buy/goldens/macos/buy_confirm_failed_unknown.png"
    +  "114-buy-payment-details-qr-details-tab=screens/buy/goldens/macos/buy_payment_details_qr_details_tab.png"
    +  "115-buy-payment-details-qr-code-tab=screens/buy/goldens/macos/buy_payment_details_qr_code_tab.png"
    +  "116-buy-payment-details-qr-code-tab-svg=screens/buy/goldens/macos/buy_payment_details_qr_code_tab_svg.png"
    +  "117-buy-payment-details-no-purpose=screens/buy/goldens/macos/buy_payment_details_no_purpose.png"
    +  "118-sell-no-account-zero-balance=screens/sell/goldens/macos/sell_no_account_zero_balance.png"
    +  "119-sell-with-balance=screens/sell/goldens/macos/sell_with_balance.png"
    +  "120-sell-bank-account-selected=screens/sell/goldens/macos/sell_bank_account_selected.png"
    +  "121-sell-bank-account-selection-page-default=screens/sell/goldens/macos/sell_bank_account_selection_page_default.png"
    +  "122-sell-add-bank-account-sheet=screens/sell/goldens/macos/sell_add_bank_account_sheet.png"
    +  "123-sell-confirm-sheet=screens/sell/goldens/macos/sell_confirm_sheet.png"
    +  "124-sell-confirm-sheet-loading=screens/sell/goldens/macos/sell_confirm_sheet_loading.png"
    +  "125-sell-executed-sheet=screens/sell/goldens/macos/sell_executed_sheet.png"
    +  "126-sell-bitbox-page-default=screens/sell_bitbox/goldens/macos/sell_bitbox_page_default.png"
    +  "127-sell-bitbox-waiting-for-eth=screens/sell_bitbox/goldens/macos/sell_bitbox_waiting_for_eth.png"
    +  "128-sell-bitbox-eth-ready=screens/sell_bitbox/goldens/macos/sell_bitbox_eth_ready.png"
    +  "129-sell-bitbox-preparing-swap=screens/sell_bitbox/goldens/macos/sell_bitbox_preparing_swap.png"
    +  "130-sell-bitbox-awaiting-swap-confirm=screens/sell_bitbox/goldens/macos/sell_bitbox_awaiting_swap_confirm.png"
    +  "131-sell-bitbox-swapping=screens/sell_bitbox/goldens/macos/sell_bitbox_swapping.png"
    +  "132-sell-bitbox-awaiting-deposit-confirm=screens/sell_bitbox/goldens/macos/sell_bitbox_awaiting_deposit_confirm.png"
    +  "133-sell-bitbox-depositing=screens/sell_bitbox/goldens/macos/sell_bitbox_depositing.png"
    +  "134-sell-bitbox-retrying-deposit=screens/sell_bitbox/goldens/macos/sell_bitbox_retrying_deposit.png"
    +  "135-sell-bitbox-deposit-retry=screens/sell_bitbox/goldens/macos/sell_bitbox_deposit_retry.png"
    +  "136-sell-bitbox-confirm-retry=screens/sell_bitbox/goldens/macos/sell_bitbox_confirm_retry.png"
    +  "137-connect-bitbox-page-default=screens/hardware_connect_bitbox/goldens/macos/connect_bitbox_page_default.png"
    +  "138-connect-bitbox-page-default-ios=screens/hardware_connect_bitbox/goldens/macos/connect_bitbox_page_default_ios.png"
    +  "139-connect-bitbox-page-connecting=screens/hardware_connect_bitbox/goldens/macos/connect_bitbox_page_connecting.png"
    +  "140-connect-bitbox-page-check-hash=screens/hardware_connect_bitbox/goldens/macos/connect_bitbox_page_check_hash.png"
    +  "141-connect-bitbox-page-pairing=screens/hardware_connect_bitbox/goldens/macos/connect_bitbox_page_pairing.png"
    +  "142-connect-bitbox-page-capturing-signature=screens/hardware_connect_bitbox/goldens/macos/connect_bitbox_page_capturing_signature.png"
    +  "143-connect-bitbox-page-connected=screens/hardware_connect_bitbox/goldens/macos/connect_bitbox_page_connected.png"
    +  "144-connect-bitbox-page-not-initialized=screens/hardware_connect_bitbox/goldens/macos/connect_bitbox_page_not_initialized.png"
    +  "145-connect-bitbox-page-signature-failed=screens/hardware_connect_bitbox/goldens/macos/connect_bitbox_page_signature_failed.png"
    +  "146-connect-bitbox-page-failed-snackbar=screens/hardware_connect_bitbox/goldens/macos/connect_bitbox_page_failed_snackbar.png"
    +  "147-bitbox-address-recovery-page-default=screens/hardware_connect_bitbox/goldens/macos/bitbox_address_recovery_page_default.png"
    +  "148-kyc-email-page-default=screens/kyc/goldens/macos/kyc_email_page_default.png"
    +  "149-kyc-email-page-error-snackbar-does-not-match=screens/kyc/goldens/macos/kyc_email_page_error_snackbar_does_not_match.png"
    +  "150-kyc-email-page-error-snackbar-unknown=screens/kyc/goldens/macos/kyc_email_page_error_snackbar_unknown.png"
    +  "151-kyc-confirm-email-page-default=screens/kyc/goldens/macos/kyc_confirm_email_page_default.png"
    +  "152-kyc-email-verification-page-default=screens/kyc/goldens/macos/kyc_email_verification_page_default.png"
    +  "153-kyc-email-verification-page-loading=screens/kyc/goldens/macos/kyc_email_verification_page_loading.png"
    +  "154-kyc-email-verification-page-loading-bitbox=screens/kyc/goldens/macos/kyc_email_verification_page_loading_bitbox.png"
    +  "155-kyc-email-verification-page-error-snackbar=screens/kyc/goldens/macos/kyc_email_verification_page_error_snackbar.png"
    +  "156-kyc-2fa-page-default=screens/kyc/goldens/macos/kyc_2fa_page_default.png"
    +  "157-kyc-2fa-page-verify-loading=screens/kyc/goldens/macos/kyc_2fa_page_verify_loading.png"
    +  "158-kyc-2fa-page-resend-loading=screens/kyc/goldens/macos/kyc_2fa_page_resend_loading.png"
    +  "159-kyc-2fa-page-verify-failure=screens/kyc/goldens/macos/kyc_2fa_page_verify_failure.png"
    +  "160-kyc-2fa-page-request-code-failure=screens/kyc/goldens/macos/kyc_2fa_page_request_code_failure.png"
    +  "161-kyc-registration-page-default=screens/kyc/goldens/macos/kyc_registration_page_default.png"
    +  "162-kyc-registration-page-prefilled=screens/kyc/goldens/macos/kyc_registration_page_prefilled.png"
    +  "163-kyc-registration-personal-step-account-type-open=screens/kyc/goldens/macos/kyc_registration_personal_step_account_type_open.png"
    +  "164-kyc-registration-personal-step-phone-prefix-open=screens/kyc/goldens/macos/kyc_registration_personal_step_phone_prefix_open.png"
    +  "165-kyc-registration-personal-step-validation-error=screens/kyc/goldens/macos/kyc_registration_personal_step_validation_error.png"
    +  "166-kyc-registration-page-address-step=screens/kyc/goldens/macos/kyc_registration_page_address_step.png"
    +  "167-kyc-registration-address-step-validation-error=screens/kyc/goldens/macos/kyc_registration_address_step_validation_error.png"
    +  "168-kyc-registration-page-tax-step=screens/kyc/goldens/macos/kyc_registration_page_tax_step.png"
    +  "169-kyc-registration-page-submit-loading=screens/kyc/goldens/macos/kyc_registration_page_submit_loading.png"
    +  "170-kyc-registration-page-submit-failure-snackbar=screens/kyc/goldens/macos/kyc_registration_page_submit_failure_snackbar.png"
    +  "171-kyc-registration-page-forwarding-failed-snackbar=screens/kyc/goldens/macos/kyc_registration_page_forwarding_failed_snackbar.png"
    +  "172-kyc-registration-page-bitbox-required=screens/kyc/goldens/macos/kyc_registration_page_bitbox_required.png"
    +  "173-kyc-nationality-page-default=screens/kyc/goldens/macos/kyc_nationality_page_default.png"
    +  "174-kyc-nationality-page-dropdown-open=screens/kyc/goldens/macos/kyc_nationality_page_dropdown_open.png"
    +  "175-kyc-nationality-page-country-loading=screens/kyc/goldens/macos/kyc_nationality_page_country_loading.png"
    +  "176-kyc-nationality-page-country-error=screens/kyc/goldens/macos/kyc_nationality_page_country_error.png"
    +  "177-kyc-nationality-page-submit-loading=screens/kyc/goldens/macos/kyc_nationality_page_submit_loading.png"
    +  "178-kyc-nationality-page-submit-failure=screens/kyc/goldens/macos/kyc_nationality_page_submit_failure.png"
    +  "179-kyc-nationality-page-validation-error=screens/kyc/goldens/macos/kyc_nationality_page_validation_error.png"
    +  "180-kyc-financial-data-page-default=screens/kyc/goldens/macos/kyc_financial_data_page_default.png"
    +  "181-kyc-financial-data-loading-page-default=screens/kyc/goldens/macos/kyc_financial_data_loading_page_default.png"
    +  "182-kyc-financial-data-page-fallback=screens/kyc/goldens/macos/kyc_financial_data_page_fallback.png"
    +  "183-kyc-financial-data-failure-page-default=screens/kyc/goldens/macos/kyc_financial_data_failure_page_default.png"
    +  "184-kyc-financial-data-page-submit-failure=screens/kyc/goldens/macos/kyc_financial_data_page_submit_failure.png"
    +  "185-kyc-financial-data-questions-page-default=screens/kyc/goldens/macos/kyc_financial_data_questions_page_default.png"
    +  "186-kyc-financial-data-questions-page-no-description=screens/kyc/goldens/macos/kyc_financial_data_questions_page_no_description.png"
    +  "187-kyc-financial-data-questions-page-answered=screens/kyc/goldens/macos/kyc_financial_data_questions_page_answered.png"
    +  "188-kyc-financial-data-questions-page-single-choice=screens/kyc/goldens/macos/kyc_financial_data_questions_page_single_choice.png"
    +  "189-kyc-financial-data-questions-page-multiple-choice=screens/kyc/goldens/macos/kyc_financial_data_questions_page_multiple_choice.png"
    +  "190-kyc-financial-data-questions-page-checkbox=screens/kyc/goldens/macos/kyc_financial_data_questions_page_checkbox.png"
    +  "191-kyc-financial-data-questions-page-link-description=screens/kyc/goldens/macos/kyc_financial_data_questions_page_link_description.png"
    +  "192-kyc-financial-data-questions-page-not-last=screens/kyc/goldens/macos/kyc_financial_data_questions_page_not_last.png"
    +  "193-kyc-ident-page-default=screens/kyc/goldens/macos/kyc_ident_page_default.png"
    +  "194-kyc-ident-page-loading=screens/kyc/goldens/macos/kyc_ident_page_loading.png"
    +  "195-kyc-ident-page-error=screens/kyc/goldens/macos/kyc_ident_page_error.png"
    +  "196-kyc-ident-page-finally-rejected=screens/kyc/goldens/macos/kyc_ident_page_finally_rejected.png"
    +  "197-kyc-link-wallet-page-default=screens/kyc/goldens/macos/kyc_link_wallet_page_default.png"
    +  "198-kyc-link-wallet-page-bitbox-required=screens/kyc/goldens/macos/kyc_link_wallet_page_bitbox_required.png"
    +  "199-kyc-link-wallet-page-submitting=screens/kyc/goldens/macos/kyc_link_wallet_page_submitting.png"
    +  "200-kyc-link-wallet-page-success=screens/kyc/goldens/macos/kyc_link_wallet_page_success.png"
    +  "201-kyc-link-wallet-page-failure=screens/kyc/goldens/macos/kyc_link_wallet_page_failure.png"
    +  "202-kyc-link-wallet-page-missing-user-data=screens/kyc/goldens/macos/kyc_link_wallet_page_missing_user_data.png"
    +  "203-kyc-signature-unsupported-page-default=screens/kyc/goldens/macos/kyc_signature_unsupported_page_default.png"
    +  "204-kyc-loading-page-default=screens/kyc/goldens/macos/kyc_loading_page_default.png"
    +  "205-kyc-pending-page-default=screens/kyc/goldens/macos/kyc_pending_page_default.png"
    +  "206-kyc-completed-page-default=screens/kyc/goldens/macos/kyc_completed_page_default.png"
    +  "207-kyc-failure-page-default=screens/kyc/goldens/macos/kyc_failure_page_default.png"
    +  "208-kyc-manual-review-page-default=screens/kyc/goldens/macos/kyc_manual_review_page_default.png"
    +  "209-kyc-account-merge-page-default=screens/kyc/goldens/macos/kyc_account_merge_page_default.png"
    +  "210-kyc-merge-processing-page-default=screens/kyc/goldens/macos/kyc_merge_processing_page_default.png"
    +  "211-settings-page-bitbox=screens/settings/goldens/macos/settings_page_bitbox.png"
    +  "212-settings-confirm-logout-wallet-sheet-checked=screens/settings/goldens/macos/settings_confirm_logout_wallet_sheet_checked.png"
    +  "213-settings-languages-page-loading=screens/settings_languages/goldens/macos/settings_languages_page_loading.png"
    +  "214-settings-languages-page-error=screens/settings_languages/goldens/macos/settings_languages_page_error.png"
    +  "215-settings-currencies-page-loading=screens/settings_currencies/goldens/macos/settings_currencies_page_loading.png"
    +  "216-settings-currencies-page-error=screens/settings_currencies/goldens/macos/settings_currencies_page_error.png"
    +  "217-settings-network-page-switching=screens/settings_network/goldens/macos/settings_network_page_switching.png"
    +  "218-settings-seed-page-loading=screens/settings_seed/goldens/macos/settings_seed_page_loading.png"
    +  "219-settings-security-page-default=screens/settings_security/goldens/macos/settings_security_page_default.png"
    +  "220-settings-security-page-biometrics-disabled=screens/settings_security/goldens/macos/settings_security_page_biometrics_disabled.png"
    +  "221-settings-security-page-no-biometrics=screens/settings_security/goldens/macos/settings_security_page_no_biometrics.png"
    +  "222-settings-security-page-busy=screens/settings_security/goldens/macos/settings_security_page_busy.png"
    +  "223-settings-security-page-error-snackbar=screens/settings_security/goldens/macos/settings_security_page_error_snackbar.png"
    +  "224-settings-tax-report-page-default=screens/settings_tax_report/goldens/macos/settings_tax_report_page_default.png"
    +  "225-settings-tax-report-page-date-picker=screens/settings_tax_report/goldens/macos/settings_tax_report_page_date_picker.png"
    +  "226-settings-tax-report-page-failure-snackbar=screens/settings_tax_report/goldens/macos/settings_tax_report_page_failure_snackbar.png"
    +  "227-settings-user-data-page-default=screens/settings_user_data/goldens/macos/settings_user_data_page_default.png"
    +  "228-settings-user-data-page-editable=screens/settings_user_data/goldens/macos/settings_user_data_page_editable.png"
    +  "229-settings-user-data-page-pending=screens/settings_user_data/goldens/macos/settings_user_data_page_pending.png"
    +  "230-settings-user-data-page-no-birthday=screens/settings_user_data/goldens/macos/settings_user_data_page_no_birthday.png"
    +  "231-settings-user-data-page-email-only=screens/settings_user_data/goldens/macos/settings_user_data_page_email_only.png"
    +  "232-settings-user-data-page-empty=screens/settings_user_data/goldens/macos/settings_user_data_page_empty.png"
    +  "233-settings-user-data-page-loading=screens/settings_user_data/goldens/macos/settings_user_data_page_loading.png"
    +  "234-settings-user-data-page-failure=screens/settings_user_data/goldens/macos/settings_user_data_page_failure.png"
    +  "235-settings-user-data-page-bitbox-disconnected=screens/settings_user_data/goldens/macos/settings_user_data_page_bitbox_disconnected.png"
    +  "236-settings-edit-name-page-default=screens/settings_user_data/goldens/macos/settings_edit_name_page_default.png"
    +  "237-settings-edit-phone-number-page-default=screens/settings_user_data/goldens/macos/settings_edit_phone_number_page_default.png"
    +  "238-settings-edit-address-page-default=screens/settings_user_data/goldens/macos/settings_edit_address_page_default.png"
    +  "239-settings-edit-loading-page-default=screens/settings_user_data/goldens/macos/settings_edit_loading_page_default.png"
    +  "240-settings-edit-pending-page-default=screens/settings_user_data/goldens/macos/settings_edit_pending_page_default.png"
    +  "241-settings-edit-failure-page-default=screens/settings_user_data/goldens/macos/settings_edit_failure_page_default.png"
    +  "242-legal-disclaimer-page-default=screens/legal/goldens/macos/legal_disclaimer_page_default.png"
    +  "243-legal-document-page-default=screens/legal/goldens/macos/legal_document_page_default.png"
    +  "244-legal-document-page-loaded-with-pdf=screens/legal/goldens/macos/legal_document_page_loaded_with_pdf.png"
    +  "245-legal-document-page-load-error=screens/legal/goldens/macos/legal_document_page_load_error.png"
    +  "246-support-page-default=screens/support/goldens/macos/support_page_default.png"
    +  "247-support-email-capture-page-default=screens/support/goldens/macos/support_email_capture_page_default.png"
    +  "248-support-email-capture-page-submitting=screens/support/goldens/macos/support_email_capture_page_submitting.png"
    +  "249-support-email-capture-page-buy-flow=screens/support/goldens/macos/support_email_capture_page_buy_flow.png"
    +  "250-support-create-ticket-page-default=screens/support/goldens/macos/support_create_ticket_page_default.png"
    +  "251-support-create-ticket-page-filled=screens/support/goldens/macos/support_create_ticket_page_filled.png"
    +  "252-support-create-ticket-page-submitting=screens/support/goldens/macos/support_create_ticket_page_submitting.png"
    +  "253-support-tickets-page-default=screens/support/goldens/macos/support_tickets_page_default.png"
    +  "254-support-tickets-page-loading=screens/support/goldens/macos/support_tickets_page_loading.png"
    +  "255-support-tickets-page-loaded=screens/support/goldens/macos/support_tickets_page_loaded.png"
    +  "256-support-tickets-page-error=screens/support/goldens/macos/support_tickets_page_error.png"
    +  "257-support-chat-page-default=screens/support/goldens/macos/support_chat_page_default.png"
    +  "258-support-chat-page-loaded=screens/support/goldens/macos/support_chat_page_loaded.png"
    +  "259-support-chat-page-sending=screens/support/goldens/macos/support_chat_page_sending.png"
    +  "260-support-chat-page-closed=screens/support/goldens/macos/support_chat_page_closed.png"
    +  "261-support-chat-page-error=screens/support/goldens/macos/support_chat_page_error.png"
    +  "262-debug-auth-page-default=screens/debug_auth/goldens/macos/debug_auth_page_default.png"
    +  "263-debug-auth-page-fetching=screens/debug_auth/goldens/macos/debug_auth_page_fetching.png"
    +  "264-debug-auth-page-error=screens/debug_auth/goldens/macos/debug_auth_page_error.png"
    +  "265-debug-auth-page-sign-message=screens/debug_auth/goldens/macos/debug_auth_page_sign_message.png"
    +  "266-debug-auth-page-clipboard-snackbar=screens/debug_auth/goldens/macos/debug_auth_page_clipboard_snackbar.png"
    +  "267-debug-auth-page-authenticating=screens/debug_auth/goldens/macos/debug_auth_page_authenticating.png"
    +  "268-phone-number-field-default=widgets/form/goldens/macos/phone_number_field_default.png"
     )
     
     missing=()
    diff --git a/scripts/assemble-handbook-store-listing.py b/scripts/assemble-handbook-store-listing.py
    index 4cd3cddf2..e313009bb 100755
    --- a/scripts/assemble-handbook-store-listing.py
    +++ b/scripts/assemble-handbook-store-listing.py
    @@ -161,6 +161,7 @@ def read(p: Path) -> str:
         ctx = {
             "ios_name": html.escape(read(ios / "name.txt")),
             "ios_subtitle": html.escape(read(ios / "subtitle.txt")),
    +        "ios_promotional_text": html.escape(read(ios / "promotional_text.txt")),
             "ios_description": html.escape(read(ios / "description.txt")),
             "ios_keywords": html.escape(read(ios / "keywords.txt")),
             "ios_marketing_url": html.escape(read(ios / "marketing_url.txt")),
    diff --git a/scripts/check-coverage-visibility.sh b/scripts/check-coverage-visibility.sh
    new file mode 100755
    index 000000000..9cfe8f15b
    --- /dev/null
    +++ b/scripts/check-coverage-visibility.sh
    @@ -0,0 +1,98 @@
    +#!/usr/bin/env bash
    +#
    +# Coverage visibility gate — closes the "never-loaded in-scope file" blind spot.
    +#
    +# `flutter test --coverage` only records libraries that were actually LOADED
    +# during the run. An in-scope .dart file that no test ever imports contributes
    +# 0/0 to lcov and is therefore INVISIBLE to the scoped line-coverage summary:
    +# the floor gate stays green at "100 %" while the file is entirely untested.
    +# The scoped % says nothing about it because it is in neither the numerator nor
    +# the denominator. This check enumerates the on-disk activated surface and fails
    +# when a file that should carry coverage produced no `SF:` record in the scoped
    +# tracefile.
    +#
    +# Files that legitimately produce no coverable lines — pure abstract interfaces
    +# / ports, export-only barrels, const/enum-only declarations — are listed in the
    +# committed allowlist. The set is a ratchet: a NEW invisible in-scope file fails
    +# the build until it is either tested (preferred) or, only if it genuinely has
    +# no coverable lines, added to the allowlist in the same PR with a reason.
    +#
    +# Usage: check-coverage-visibility.sh  
    +set -euo pipefail
    +
    +TRACEFILE="${1:?usage: check-coverage-visibility.sh  }"
    +ALLOWLIST="${2:?usage: check-coverage-visibility.sh  }"
    +
    +if [ ! -f "$TRACEFILE" ]; then
    +  echo "::error::scoped tracefile '$TRACEFILE' not found"
    +  exit 1
    +fi
    +if [ ! -f "$ALLOWLIST" ]; then
    +  echo "::error::coverage visibility allowlist '$ALLOWLIST' not found"
    +  exit 1
    +fi
    +
    +# Byte collation so the sort order the `comm` calls below rely on is identical
    +# regardless of the runner's locale.
    +export LC_ALL=C
    +
    +ondisk="$(mktemp)"
    +loaded="$(mktemp)"
    +invisible="$(mktemp)"
    +allow="$(mktemp)"
    +trap 'rm -f "$ondisk" "$loaded" "$invisible" "$allow"' EXIT
    +
    +# On-disk activated surface. The `case` patterns are the exact lcov --extract
    +# patterns from the "Filter coverage to README scope" step in
    +# pull-request.yaml; shell `case` globbing lets `*` span `/`, matching lcov's
    +# fnmatch. `*.g.dart` is excluded to mirror the `lcov --remove '*.g.dart'` that
    +# follows the extract. Keep these patterns in lockstep with that step.
    +{
    +  find lib -name '*.dart' -type f | while IFS= read -r f; do
    +    case "$f" in
    +      *.g.dart) continue ;;
    +    esac
    +    case "$f" in
    +      lib/packages/*|lib/screens/*/cubit/*|lib/screens/*/cubits/*|lib/screens/*/bloc/*)
    +        printf '%s\n' "$f"
    +        ;;
    +    esac
    +  done
    +} | sort -u > "$ondisk"
    +
    +# SF: paths from the scoped tracefile, normalised to repo-relative `lib/…`. The
    +# `|| true` keeps a tracefile with no SF: record (an upstream failure the floor
    +# gate already reds) from aborting the pipeline under `pipefail` with no
    +# diagnostic — it instead flows through as every in-scope file being reported.
    +# The sed only rewrites ABSOLUTE paths (leading `/`); Flutter emits relative
    +# `lib/…` paths, which pass through untouched.
    +{ grep '^SF:' "$TRACEFILE" || true; } | sed 's/^SF://' | sed -E 's#^/.*/lib/#lib/#' | sort -u > "$loaded"
    +
    +# Invisible = in-scope on disk but absent from the tracefile (never loaded, or
    +# loaded with no coverable line — either way it contributes no coverage signal).
    +comm -23 "$ondisk" "$loaded" > "$invisible"
    +
    +# Allowlist, minus comment/blank lines. The `|| true` is load-bearing: once
    +# every entry has been tested and pruned, an all-comment allowlist makes `grep`
    +# exit 1, which under `pipefail` would abort the whole gate with a cryptic
    +# non-zero exactly in the "everything covered" state where it must stay green.
    +{ grep -vE '^[[:space:]]*(#|$)' "$ALLOWLIST" || true; } | sort -u > "$allow"
    +
    +# Stale allowlist entries (now covered, or deleted from the tree) — surfaced so
    +# the ratchet does not rot. Informational, never a failure.
    +stale="$(comm -13 "$invisible" "$allow" || true)"
    +if [ -n "$stale" ]; then
    +  echo "::warning::coverage visibility allowlist has stale entries (now covered or removed) — prune them from $ALLOWLIST:"
    +  printf '%s\n' "$stale" | sed 's/^/::warning::  /'
    +fi
    +
    +# Fail on any invisible file that is not allow-listed.
    +offenders="$(comm -23 "$invisible" "$allow" || true)"
    +if [ -n "$offenders" ]; then
    +  echo "::error::in-scope file(s) never exercised by any test — invisible to the coverage gate (0/0, not counted in the scoped %):"
    +  printf '%s\n' "$offenders" | sed 's/^/::error::  /'
    +  echo "::error::Fix: add a test that imports and exercises the file (preferred). Only if it genuinely has no coverable lines, add it to $ALLOWLIST with a one-line reason."
    +  exit 1
    +fi
    +
    +echo "coverage visibility OK: $(wc -l < "$ondisk" | tr -d ' ') in-scope file(s), $(wc -l < "$invisible" | tr -d ' ') lineless/allow-listed, 0 unexpected blind spots"
    diff --git a/scripts/templates/store-listing.html.tmpl b/scripts/templates/store-listing.html.tmpl
    index 44cd03450..1939bb5b9 100644
    --- a/scripts/templates/store-listing.html.tmpl
    +++ b/scripts/templates/store-listing.html.tmpl
    @@ -31,6 +31,7 @@
       
    App-Name (max 30)
    {{ ios_name }}
    Untertitel (max 30)
    {{ ios_subtitle }}
    +
    Werbetext (max 170)
    {{ ios_promotional_text }}
    Beschreibung (max 4000)
    {{ ios_description }}
    Schlagwörter (max 100, kommasepariert)
    {{ ios_keywords }}
    Marketing-URL
    {{ ios_marketing_url }}
    diff --git a/test/goldens/screens/buy/buy_golden_test.dart b/test/goldens/screens/buy/buy_golden_test.dart index 2df467e47..f56940b75 100644 --- a/test/goldens/screens/buy/buy_golden_test.dart +++ b/test/goldens/screens/buy/buy_golden_test.dart @@ -207,6 +207,25 @@ void main() { }, ); + goldenTest( + 'primary email not confirmed failure', + fileName: 'buy_primary_email_not_confirmed', + constraints: const BoxConstraints.tightFor(width: 390, height: 844), + builder: () { + when(() => paymentInfoCubit.state).thenReturn( + const BuyPaymentInfoFailure(PaymentInfoError.primaryEmailNotConfirmed), + ); + when(() => converterCubit.state).thenReturn( + const BuyConverterState( + fiatText: '100', + sharesText: '1.00', + currency: Currency.chf, + ), + ); + return wrapForGolden(buildSubject()); + }, + ); + goldenTest( 'min amount not met failure', fileName: 'buy_min_amount_not_met', diff --git a/test/goldens/screens/buy/buy_payment_details_states_golden_test.dart b/test/goldens/screens/buy/buy_payment_details_states_golden_test.dart new file mode 100644 index 000000000..e6b501816 --- /dev/null +++ b/test/goldens/screens/buy/buy_payment_details_states_golden_test.dart @@ -0,0 +1,138 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/buy/buy_payment_info.dart'; +import 'package:realunit_wallet/screens/buy/buy_payment_details_page.dart'; +import 'package:realunit_wallet/styles/currency.dart'; + +import '../../../helper/helper.dart'; + +const _info = BuyPaymentInfo( + amount: 300, + id: 1, + iban: 'CH00 0000 0000 0000 0000 0', + bic: 'BICCBIC', + name: 'RealUnit AG', + street: 'Bahnhofstrasse', + number: '1', + zip: '8001', + city: 'Zurich', + country: 'Switzerland', + currency: Currency.chf, +); + +// A plain (non-SVG) payment request drives the `QrImageView` branch of the card +// (`payment_details_card.dart:128-134`). The QR encoding is a pure function of +// this fixed string, so the render is byte-stable (same determinism the +// `receive_page_default` golden relies on). +const _qrPayload = 'SPC\n0200\n1\n' + 'CH0000000000000000000\nRealUnit AG\nBahnhofstrasse 1\n8001 Zurich\nCH\n' + '100.00\nCHF\nRU-2026-000123'; + +// A payment request that *contains* `' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + ''; + +void main() { + // `buy_payment_details_default` (no QR, purpose present) lives in + // `buy_payment_details_golden_test.dart`. This file adds the QR-tab surfaces + // and the empty-purpose row omission. + group('$BuyPaymentDetailsPage', () { + // With a payment request the TabSelector row appears + // (`payment_details_card.dart:45-64`); the default tab is `text`, so the + // Details table is what shows underneath. + goldenTest( + 'QR available — Details tab active, TabSelector visible', + fileName: 'buy_payment_details_qr_details_tab', + constraints: phoneConstraints, + builder: () => wrapForGolden( + const BuyPaymentDetailsPage( + params: BuyPaymentDetailsParams( + buyPaymentInfo: _info, + amount: '100', + purposeOfPayment: 'RU-2026-000123', + paymentRequest: _qrPayload, + ), + ), + ), + ); + + // Tapping the QR-Code tab flips the ValueNotifier to `qrCode` + // (`payment_details_card.dart:119-135`) — QrImageView variant. + goldenTest( + 'QR-Code tab selected — QrImageView', + fileName: 'buy_payment_details_qr_code_tab', + constraints: phoneConstraints, + pumpBeforeTest: (tester) async { + await tester.pumpAndSettle(); + await tester.tap(find.text('QR-Code')); + await tester.pumpAndSettle(); + }, + builder: () => wrapForGolden( + const BuyPaymentDetailsPage( + params: BuyPaymentDetailsParams( + buyPaymentInfo: _info, + amount: '100', + purposeOfPayment: 'RU-2026-000123', + paymentRequest: _qrPayload, + ), + ), + ), + ); + + // Same tab, SVG-string variant (`payment_details_card.dart:122-127`). The + // extra frames give flutter_svg's string loader time to decode. + goldenTest( + 'QR-Code tab selected — SvgPicture.string', + fileName: 'buy_payment_details_qr_code_tab_svg', + constraints: phoneConstraints, + pumpBeforeTest: (tester) async { + await tester.pumpAndSettle(); + await tester.tap(find.text('QR-Code')); + await tester.pumpAndSettle(); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 100)); + }, + builder: () => wrapForGolden( + const BuyPaymentDetailsPage( + params: BuyPaymentDetailsParams( + buyPaymentInfo: _info, + amount: '100', + purposeOfPayment: 'RU-2026-000123', + paymentRequest: _qrSvg, + ), + ), + ), + ); + + // Empty `purposeOfPayment` omits the purpose row + // (`payment_details_card.dart:82`). + goldenTest( + 'purpose row hidden — empty purposeOfPayment', + fileName: 'buy_payment_details_no_purpose', + constraints: phoneConstraints, + builder: () => wrapForGolden( + const BuyPaymentDetailsPage( + params: BuyPaymentDetailsParams( + buyPaymentInfo: _info, + amount: '100', + purposeOfPayment: '', + ), + ), + ), + ); + }); +} diff --git a/test/goldens/screens/buy/buy_states_golden_test.dart b/test/goldens/screens/buy/buy_states_golden_test.dart new file mode 100644 index 000000000..dcb51c035 --- /dev/null +++ b/test/goldens/screens/buy/buy_states_golden_test.dart @@ -0,0 +1,249 @@ +import 'package:alchemist/alchemist.dart' show precacheImages; +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/packages/config/api_config.dart'; +import 'package:realunit_wallet/packages/repository/cache_repository.dart'; +import 'package:realunit_wallet/packages/repository/supported_fiat_repository.dart'; +import 'package:realunit_wallet/packages/service/app_store.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_brokerbot_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_price_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/buy/buy_payment_info.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/payment_info_error.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_buy_payment_info_service.dart'; +import 'package:realunit_wallet/packages/service/session_cache.dart'; +import 'package:realunit_wallet/screens/buy/buy_page.dart'; +import 'package:realunit_wallet/screens/buy/cubits/buy_confirm/buy_confirm_cubit.dart'; +import 'package:realunit_wallet/screens/buy/cubits/buy_converter/buy_converter_cubit.dart'; +import 'package:realunit_wallet/screens/buy/cubits/buy_payment_info/buy_payment_info_cubit.dart'; +import 'package:realunit_wallet/screens/buy/widgets/buy_confirm_button.dart'; +import 'package:realunit_wallet/styles/currency.dart'; + +import '../../../helper/helper.dart'; + +class _MockBuyConfirmCubit extends MockCubit + implements BuyConfirmCubit {} + +class _MockBuyConverterCubit extends MockCubit + implements BuyConverterCubit {} + +class _MockBuyPaymentInfoCubit extends MockCubit + implements BuyPaymentInfoCubit {} + +class _MockDfxBrokerbotService extends Mock implements DfxBrokerbotService {} + +class _MockRealUnitBuyPaymentInfoService extends Mock + implements RealUnitBuyPaymentInfoService {} + +class _MockDfxPriceService extends Mock implements DFXPriceService {} + +class _MockApiConfig extends Mock implements ApiConfig {} + +class _MockCacheRepository extends Mock implements CacheRepository {} + +class _MockSupportedFiatRepository extends Mock + implements SupportedFiatRepository {} + +// Shared buy quote the confirm CTA renders and confirms. +const _paymentInfo = BuyPaymentInfo( + amount: 300, + id: 1, + iban: 'CH00 0000 0000 0000 0000 0', + bic: 'BICCBIC', + name: 'RealUnit AG', + street: 'Bahnhofstrasse', + number: '1', + zip: '8001', + city: 'Zurich', + country: 'Switzerland', + currency: Currency.chf, +); + +void main() { + late _MockSupportedFiatRepository fiatRepo; + + setUpAll(() { + registerFallbackValue(Currency.chf); + final getIt = GetIt.instance; + getIt.registerSingleton( + AppStore(() => _MockApiConfig(), SessionCache(_MockCacheRepository())), + ); + getIt.registerSingleton(_MockDfxBrokerbotService()); + getIt.registerSingleton( + _MockRealUnitBuyPaymentInfoService(), + ); + getIt.registerSingleton(_MockDfxPriceService()); + fiatRepo = _MockSupportedFiatRepository(); + getIt.registerSingleton(fiatRepo); + }); + + tearDownAll(() async => GetIt.instance.reset()); + + // ---- BuyConfirmButtonView: the binding-buy CTA after a valid quote ---- + // Rendered in isolation with a mocked BuyConfirmCubit because the production + // widget (`buy_confirm_button.dart:27`) wraps the view in its own + // `BlocProvider(create:)`, which would shadow any injected cubit and start at + // `BuyConfirmInitial` — the transient loading / failure surfaces are only + // reproducible by driving a mocked cubit directly (#815). + group('$BuyConfirmButtonView', () { + late _MockBuyConfirmCubit confirmCubit; + + setUp(() { + confirmCubit = _MockBuyConfirmCubit(); + }); + + Widget buildCta() => wrapForGolden( + Scaffold( + body: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + BlocProvider.value( + value: confirmCubit, + child: const BuyConfirmButtonView(buyPaymentInfo: _paymentInfo), + ), + ], + ), + ), + ), + ); + + // `BuyConfirmLoading` drives the CTA's `state: .loading` branch + // (`buy_confirm_button.dart:82`), a `CupertinoActivityIndicator`; freeze it + // on the first frame instead of letting pumpAndSettle time out. + goldenTest( + 'confirm CTA loading — spinner in the buy button', + fileName: 'buy_confirm_loading', + constraints: phoneConstraints, + pumpBeforeTest: pumpOnce, + builder: () { + when(() => confirmCubit.state).thenReturn(const BuyConfirmLoading()); + return buildCta(); + }, + ); + + // Emitting `BuyConfirmFailure` drives the BlocConsumer listener + // (`buy_confirm_button.dart:64-73`) to show the failure SnackBar. Each + // error code's user-facing text is captured (aktionariat and + // primaryEmailRequired intentionally share the same copy). + // The `.state` getter follows the emission (BuyConfirmFailure) — the builder + // still renders the idle button, which is the real post-failure UI. + for (final (error, name) in const [ + (BuyConfirmError.aktionariat, 'buy_confirm_failed_aktionariat'), + (BuyConfirmError.amountTooLow, 'buy_confirm_failed_amount_too_low'), + (BuyConfirmError.primaryEmailRequired, 'buy_confirm_failed_primary_email_required'), + (BuyConfirmError.unknown, 'buy_confirm_failed_unknown'), + ]) { + goldenTest( + 'confirm failed SnackBar — ${error.name}', + fileName: name, + constraints: phoneConstraints, + pumpBeforeTest: (tester) async { + await tester.pump(); // deliver the emission to the listener + await tester.pumpAndSettle(); // run the SnackBar entrance to completion + }, + builder: () { + whenListen( + confirmCubit, + Stream.value(BuyConfirmFailure(error)), + initialState: const BuyConfirmInitial(), + ); + return buildCta(); + }, + ); + } + }); + + // ---- BuyView: gate / picker surfaces of the buy page ---- + group('$BuyView', () { + late _MockBuyConverterCubit converterCubit; + late _MockBuyPaymentInfoCubit paymentInfoCubit; + + setUp(() { + converterCubit = _MockBuyConverterCubit(); + paymentInfoCubit = _MockBuyPaymentInfoCubit(); + when(() => converterCubit.state) + .thenReturn(const BuyConverterState(currency: Currency.chf)); + when(() => paymentInfoCubit.state) + .thenReturn(const BuyPaymentInfoInitial()); + when( + () => paymentInfoCubit.getPaymentInfo( + amount: any(named: 'amount'), + currency: any(named: 'currency'), + ), + ).thenAnswer((_) => Future.value()); + // Deterministic, backend-authoritative picker list (no `now()`/random). + when(() => fiatRepo.getBuyable()) + .thenAnswer((_) async => const [Currency.chf, Currency.eur]); + when(() => fiatRepo.getSellable()) + .thenAnswer((_) async => const [Currency.chf]); + when(() => fiatRepo.getAll()) + .thenAnswer((_) async => const [Currency.chf, Currency.eur]); + }); + + Widget buildSubject() => wrapForGolden( + MultiBlocProvider( + providers: [ + BlocProvider.value(value: converterCubit), + BlocProvider.value(value: paymentInfoCubit), + ], + child: const BuyView(), + ), + ); + + // `PaymentInfoError.bitboxDisconnected` renders the info block + // ("BitBox ist nicht verbunden", `payment_information.dart:35-39`) plus the + // reconnect CTA (`payment_action_button.dart:122-138`). + goldenTest( + 'bitbox disconnected — info block + reconnect CTA', + fileName: 'buy_bitbox_disconnected', + constraints: phoneConstraints, + builder: () { + when(() => paymentInfoCubit.state).thenReturn( + const BuyPaymentInfoFailure(PaymentInfoError.bitboxDisconnected), + ); + return buildSubject(); + }, + ); + + // Tapping the currency PopupMenuButton (`payment_converter.dart:126`) opens + // the overlay with the buyable currencies. `precacheImages` loads the RealU + // logo asset AND drains the `getBuyable()` future so the picker is enabled + // before the tap. + goldenTest( + 'currency picker popup open — CHF/EUR items', + fileName: 'buy_currency_picker_open', + constraints: phoneConstraints, + pumpBeforeTest: (tester) async { + await precacheImages(tester); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('buy-currency-picker'))); + await tester.pumpAndSettle(); + }, + builder: () => buildSubject(), + ); + + // `getBuyable()` failing drives the initState onError branch + // (`payment_converter.dart:46-68`): the red SnackBar + // ("Währungsliste konnte nicht geladen werden") and the disabled picker + // (key `buy-currency-picker-disabled`, `enabled: false`). + goldenTest( + 'currency list load failed — red SnackBar + disabled picker', + fileName: 'buy_currency_load_failed', + constraints: phoneConstraints, + pumpBeforeTest: (tester) async { + await precacheImages(tester); + await tester.pumpAndSettle(); + }, + builder: () { + when(() => fiatRepo.getBuyable()) + .thenAnswer((_) async => throw Exception('load failed')); + return buildSubject(); + }, + ); + }); +} diff --git a/test/goldens/screens/buy/goldens/macos/buy_bitbox_disconnected.png b/test/goldens/screens/buy/goldens/macos/buy_bitbox_disconnected.png new file mode 100644 index 000000000..0837c7063 Binary files /dev/null and b/test/goldens/screens/buy/goldens/macos/buy_bitbox_disconnected.png differ diff --git a/test/goldens/screens/buy/goldens/macos/buy_confirm_failed_aktionariat.png b/test/goldens/screens/buy/goldens/macos/buy_confirm_failed_aktionariat.png new file mode 100644 index 000000000..5684fa073 Binary files /dev/null and b/test/goldens/screens/buy/goldens/macos/buy_confirm_failed_aktionariat.png differ diff --git a/test/goldens/screens/buy/goldens/macos/buy_confirm_failed_amount_too_low.png b/test/goldens/screens/buy/goldens/macos/buy_confirm_failed_amount_too_low.png new file mode 100644 index 000000000..3cb61109b Binary files /dev/null and b/test/goldens/screens/buy/goldens/macos/buy_confirm_failed_amount_too_low.png differ diff --git a/test/goldens/screens/buy/goldens/macos/buy_confirm_failed_primary_email_required.png b/test/goldens/screens/buy/goldens/macos/buy_confirm_failed_primary_email_required.png new file mode 100644 index 000000000..5684fa073 Binary files /dev/null and b/test/goldens/screens/buy/goldens/macos/buy_confirm_failed_primary_email_required.png differ diff --git a/test/goldens/screens/buy/goldens/macos/buy_confirm_failed_unknown.png b/test/goldens/screens/buy/goldens/macos/buy_confirm_failed_unknown.png new file mode 100644 index 000000000..85a2cc606 Binary files /dev/null and b/test/goldens/screens/buy/goldens/macos/buy_confirm_failed_unknown.png differ diff --git a/test/goldens/screens/buy/goldens/macos/buy_confirm_loading.png b/test/goldens/screens/buy/goldens/macos/buy_confirm_loading.png new file mode 100644 index 000000000..6de03b6f2 Binary files /dev/null and b/test/goldens/screens/buy/goldens/macos/buy_confirm_loading.png differ diff --git a/test/goldens/screens/buy/goldens/macos/buy_currency_load_failed.png b/test/goldens/screens/buy/goldens/macos/buy_currency_load_failed.png new file mode 100644 index 000000000..0e956c1d3 Binary files /dev/null and b/test/goldens/screens/buy/goldens/macos/buy_currency_load_failed.png differ diff --git a/test/goldens/screens/buy/goldens/macos/buy_currency_picker_open.png b/test/goldens/screens/buy/goldens/macos/buy_currency_picker_open.png new file mode 100644 index 000000000..3804267e3 Binary files /dev/null and b/test/goldens/screens/buy/goldens/macos/buy_currency_picker_open.png differ diff --git a/test/goldens/screens/buy/goldens/macos/buy_payment_details_no_purpose.png b/test/goldens/screens/buy/goldens/macos/buy_payment_details_no_purpose.png new file mode 100644 index 000000000..d85d0e8b1 Binary files /dev/null and b/test/goldens/screens/buy/goldens/macos/buy_payment_details_no_purpose.png differ diff --git a/test/goldens/screens/buy/goldens/macos/buy_payment_details_qr_code_tab.png b/test/goldens/screens/buy/goldens/macos/buy_payment_details_qr_code_tab.png new file mode 100644 index 000000000..a3b00b8f4 Binary files /dev/null and b/test/goldens/screens/buy/goldens/macos/buy_payment_details_qr_code_tab.png differ diff --git a/test/goldens/screens/buy/goldens/macos/buy_payment_details_qr_code_tab_svg.png b/test/goldens/screens/buy/goldens/macos/buy_payment_details_qr_code_tab_svg.png new file mode 100644 index 000000000..e7d6e3bd2 Binary files /dev/null and b/test/goldens/screens/buy/goldens/macos/buy_payment_details_qr_code_tab_svg.png differ diff --git a/test/goldens/screens/buy/goldens/macos/buy_payment_details_qr_details_tab.png b/test/goldens/screens/buy/goldens/macos/buy_payment_details_qr_details_tab.png new file mode 100644 index 000000000..0bcacfcd4 Binary files /dev/null and b/test/goldens/screens/buy/goldens/macos/buy_payment_details_qr_details_tab.png differ diff --git a/test/goldens/screens/buy/goldens/macos/buy_primary_email_not_confirmed.png b/test/goldens/screens/buy/goldens/macos/buy_primary_email_not_confirmed.png new file mode 100644 index 000000000..bb9a2e41e Binary files /dev/null and b/test/goldens/screens/buy/goldens/macos/buy_primary_email_not_confirmed.png differ diff --git a/test/goldens/screens/create_wallet/create_wallet_golden_test.dart b/test/goldens/screens/create_wallet/create_wallet_golden_test.dart index cd33f7711..800252975 100644 --- a/test/goldens/screens/create_wallet/create_wallet_golden_test.dart +++ b/test/goldens/screens/create_wallet/create_wallet_golden_test.dart @@ -49,5 +49,18 @@ void main() { return wrapForGolden(buildSubject()); }, ); + + goldenTest( + 'loading state before the wallet is generated shows a centered spinner', + fileName: 'create_wallet_page_loading', + // wallet == null renders a CupertinoActivityIndicator that never settles; + // pump once to capture the initial frame instead of hanging pumpAndSettle. + pumpBeforeTest: pumpOnce, + constraints: const BoxConstraints.tightFor(width: 390, height: 844), + builder: () { + when(() => cubit.state).thenReturn(const CreateWalletState()); + return wrapForGolden(buildSubject()); + }, + ); }); } diff --git a/test/goldens/screens/create_wallet/goldens/macos/create_wallet_page_loading.png b/test/goldens/screens/create_wallet/goldens/macos/create_wallet_page_loading.png new file mode 100644 index 000000000..27b9af0a7 Binary files /dev/null and b/test/goldens/screens/create_wallet/goldens/macos/create_wallet_page_loading.png differ diff --git a/test/goldens/screens/dashboard/dashboard_states_golden_test.dart b/test/goldens/screens/dashboard/dashboard_states_golden_test.dart new file mode 100644 index 000000000..eabea2a80 --- /dev/null +++ b/test/goldens/screens/dashboard/dashboard_states_golden_test.dart @@ -0,0 +1,275 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/models/balance.dart'; +import 'package:realunit_wallet/models/portfolio_value_point.dart'; +import 'package:realunit_wallet/models/price_point.dart'; +import 'package:realunit_wallet/models/transaction.dart'; +import 'package:realunit_wallet/packages/config/api_config.dart'; +import 'package:realunit_wallet/packages/repository/transaction_repository.dart'; +import 'package:realunit_wallet/packages/service/app_store.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/transactions/dto/transactions_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_pdf_service.dart'; +import 'package:realunit_wallet/packages/utils/default_assets.dart'; +import 'package:realunit_wallet/screens/dashboard/bloc/balance_cubit.dart'; +import 'package:realunit_wallet/screens/dashboard/bloc/dashboard_bloc.dart'; +import 'package:realunit_wallet/screens/dashboard/bloc/pending_transactions_cubit.dart'; +import 'package:realunit_wallet/screens/dashboard/dashboard_page.dart'; +import 'package:realunit_wallet/screens/settings/bloc/settings_bloc.dart'; +import 'package:realunit_wallet/styles/currency.dart'; + +import '../../../helper/helper.dart'; + +// `dashboard_empty` (zero balance) and `dashboard_with_balance` live in +// `dashboard_golden_test.dart`. This file adds the state surfaces the base +// file leaves uncovered: the loaded price chart, the portfolio-history header +// variant, the pending-transactions section, the recent-transactions section, +// and the hide-amounts masking. +// +// Determinism note — charts: `PriceChartCubit` / `PortfolioChartCubit` +// (`price_chart_cubit.dart:45`, `portfolio_chart_cubit.dart:51`) call +// `DateTime.now()`, but the value is only consumed by the non-`all` period +// branches of their `switch`. The default `selectedPeriod` is `TimePeriod.all` +// (`price_chart_cubit.dart:12`), whose `minX`/`maxX` come purely from the first +// / last fixture `PricePoint.time`. No period selector is tapped, so `now()` +// never reaches the rendered spots — the curve, gradient and axis labels are a +// pure function of the fixed fixtures below. + +class _MockDashboardBloc extends MockBloc + implements DashboardBloc {} + +class _MockBalanceCubit extends MockCubit implements BalanceCubit {} + +class _MockPendingTransactionsCubit extends MockCubit> + implements PendingTransactionsCubit {} + +class _MockTransactionRepository extends Mock implements TransactionRepository {} + +class _MockRealUnitPdfService extends Mock implements RealUnitPdfService {} + +class _MockApiConfig extends Mock implements ApiConfig {} + +void main() { + const walletAddress = '0xcabd3f4b10a7089986e708d19140bfc98e5880c0'; + const counterparty = '0x1234567890abcdef1234567890abcdef12345678'; + + late _MockDashboardBloc dashboardBloc; + late _MockBalanceCubit balanceCubit; + late _MockPendingTransactionsCubit pendingTxCubit; + late MockSettingsBloc settingsBloc; + final transactionRepository = _MockTransactionRepository(); + + // Price in rappen (2 implied decimals): 153 -> "1.53" CHF. + final price = BigInt.from(153); + + // Fixed, ascending monthly price points. UTC instants keep the millisecond + // X-values host-independent; the chart draws a stable curve from them. + final priceChart = [ + PricePoint(asset: realUnitAsset, price: BigInt.from(148), time: DateTime.utc(2025, 11)), + PricePoint(asset: realUnitAsset, price: BigInt.from(150), time: DateTime.utc(2025, 12)), + PricePoint(asset: realUnitAsset, price: BigInt.from(149), time: DateTime.utc(2026, 1)), + PricePoint(asset: realUnitAsset, price: BigInt.from(152), time: DateTime.utc(2026, 2)), + PricePoint(asset: realUnitAsset, price: BigInt.from(151), time: DateTime.utc(2026, 3)), + PricePoint(asset: realUnitAsset, price: BigInt.from(155), time: DateTime.utc(2026, 4)), + PricePoint(asset: realUnitAsset, price: BigInt.from(153), time: DateTime.utc(2026, 5)), + ]; + + // Portfolio value points (value in rappen, balance in REALU shares). + final portfolioHistory = [ + PortfolioValuePoint(value: BigInt.from(14800), balance: BigInt.from(100), time: DateTime.utc(2025, 11)), + PortfolioValuePoint(value: BigInt.from(15000), balance: BigInt.from(100), time: DateTime.utc(2025, 12)), + PortfolioValuePoint(value: BigInt.from(14900), balance: BigInt.from(100), time: DateTime.utc(2026, 1)), + PortfolioValuePoint(value: BigInt.from(15200), balance: BigInt.from(100), time: DateTime.utc(2026, 2)), + PortfolioValuePoint(value: BigInt.from(15100), balance: BigInt.from(100), time: DateTime.utc(2026, 3)), + PortfolioValuePoint(value: BigInt.from(15500), balance: BigInt.from(100), time: DateTime.utc(2026, 4)), + PortfolioValuePoint(value: BigInt.from(15300), balance: BigInt.from(100), time: DateTime.utc(2026, 5)), + ]; + + Balance zeroBalance() => Balance( + chainId: realUnitAsset.chainId, + contractAddress: realUnitAsset.address, + walletAddress: walletAddress, + balance: BigInt.zero, + asset: realUnitAsset, + ); + + Balance heldBalance() => Balance( + chainId: realUnitAsset.chainId, + contractAddress: realUnitAsset.address, + walletAddress: walletAddress, + balance: BigInt.from(100), + asset: realUnitAsset, + ); + + DashboardState dashboardState({ + List history = const [], + }) => + DashboardState( + price: price, + priceChart: priceChart, + portfolioHistory: history, + currency: Currency.chf, + ); + + // decimals of realUnitAsset is 0, so amounts are plain share counts. + Transaction buy(String txId, int shares, DateTime timestamp) => Transaction( + height: 200, + txId: txId, + chainId: 1, + senderAddress: counterparty, + receiverAddress: walletAddress, + amount: BigInt.from(shares), + asset: realUnitAsset, + type: TransactionTypes.tokenTransfer, + note: null, + data: null, + timestamp: timestamp, + ); + + Transaction sell(String txId, int shares, DateTime timestamp) => Transaction( + height: 199, + txId: txId, + chainId: 1, + senderAddress: walletAddress, + receiverAddress: counterparty, + amount: BigInt.from(shares), + asset: realUnitAsset, + type: TransactionTypes.tokenTransfer, + note: null, + data: null, + timestamp: timestamp, + ); + + final recentTransactions = [ + buy('0xrecent1', 50, DateTime.utc(2026, 5, 20, 10, 30)), + sell('0xrecent2', 20, DateTime.utc(2026, 5, 18, 14)), + buy('0xrecent3', 100, DateTime.utc(2026, 5, 15, 9, 15)), + ]; + + final pendingTransactions = [ + TransactionDto( + id: 1, + type: TransactionType.buy, + state: TransactionState.processing, + inputAmount: 500, + inputAsset: 'CHF', + date: DateTime.utc(2026, 5, 21, 8), + ), + TransactionDto( + id: 2, + type: TransactionType.sell, + state: TransactionState.waitingForPayment, + inputAmount: 30, + inputAsset: 'REALU', + date: DateTime.utc(2026, 5, 20, 12), + ), + ]; + + setUpAll(() { + final getIt = GetIt.instance; + final apiConfig = _MockApiConfig(); + final appStore = MockAppStore(); + when(() => apiConfig.asset).thenReturn(realUnitAsset); + when(() => appStore.apiConfig).thenReturn(apiConfig); + when(() => appStore.primaryAddress).thenReturn(walletAddress); + getIt.registerSingleton(appStore); + getIt.registerSingleton(_MockRealUnitPdfService()); + getIt.registerSingleton(transactionRepository); + }); + + tearDownAll(() async => GetIt.instance.reset()); + + setUp(() { + dashboardBloc = _MockDashboardBloc(); + balanceCubit = _MockBalanceCubit(); + pendingTxCubit = _MockPendingTransactionsCubit(); + settingsBloc = MockSettingsBloc(); + + when(() => dashboardBloc.state).thenReturn(dashboardState()); + when(() => balanceCubit.state).thenReturn(zeroBalance()); + when(() => balanceCubit.asset).thenReturn(realUnitAsset); + when(() => pendingTxCubit.state).thenReturn(const []); + when(() => settingsBloc.state).thenReturn(const SettingsState()); + // Default: no recent transactions. Overridden per-builder where needed. + when(() => transactionRepository.watchTransactionsOfAssets(any(), any(), any())) + .thenAnswer((_) => const Stream>.empty()); + }); + + Widget buildSubject() => MultiBlocProvider( + providers: [ + BlocProvider.value(value: settingsBloc), + BlocProvider.value(value: dashboardBloc), + BlocProvider.value(value: balanceCubit), + BlocProvider.value(value: pendingTxCubit), + ], + child: const DashboardView(), + ); + + group('$DashboardView', () { + goldenTest( + 'price chart loaded — price text, curve, gradient, axis labels', + fileName: 'dashboard_price_chart', + constraints: phoneConstraints, + builder: () => wrapForGolden(buildSubject()), + ); + + goldenTest( + 'portfolio history header variant', + fileName: 'dashboard_portfolio_chart', + constraints: phoneConstraints, + builder: () { + when(() => dashboardBloc.state) + .thenReturn(dashboardState(history: portfolioHistory)); + return wrapForGolden(buildSubject()); + }, + ); + + goldenTest( + 'pending transactions section', + fileName: 'dashboard_pending_transactions', + constraints: phoneConstraints, + // The pending rows host a CupertinoActivityIndicator (never settles) and + // the empty-balance body an illustration SVG. Give the SVG a couple of + // deterministic frames to decode, then stop before pumpAndSettle would + // time out on the spinner (same pattern as settings_seed_page_loading). + pumpBeforeTest: (tester) async { + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 100)); + }, + builder: () { + when(() => pendingTxCubit.state).thenReturn(pendingTransactions); + return wrapForGolden(buildSubject()); + }, + ); + + goldenTest( + 'recent transactions section (held balance)', + fileName: 'dashboard_recent_transactions', + constraints: phoneConstraints, + builder: () { + when(() => balanceCubit.state).thenReturn(heldBalance()); + when(() => transactionRepository.watchTransactionsOfAssets(any(), any(), any())) + .thenAnswer((_) => Stream.value(recentTransactions)); + return wrapForGolden(buildSubject()); + }, + ); + + goldenTest( + 'hide amounts — masked balance and transaction amounts', + fileName: 'dashboard_hidden_amounts', + constraints: phoneConstraints, + builder: () { + when(() => settingsBloc.state) + .thenReturn(const SettingsState(hideAmounts: true)); + when(() => balanceCubit.state).thenReturn(heldBalance()); + when(() => transactionRepository.watchTransactionsOfAssets(any(), any(), any())) + .thenAnswer((_) => Stream.value(recentTransactions)); + return wrapForGolden(buildSubject()); + }, + ); + }); +} diff --git a/test/goldens/screens/dashboard/goldens/macos/dashboard_hidden_amounts.png b/test/goldens/screens/dashboard/goldens/macos/dashboard_hidden_amounts.png new file mode 100644 index 000000000..b183d7dd2 Binary files /dev/null and b/test/goldens/screens/dashboard/goldens/macos/dashboard_hidden_amounts.png differ diff --git a/test/goldens/screens/dashboard/goldens/macos/dashboard_pending_transactions.png b/test/goldens/screens/dashboard/goldens/macos/dashboard_pending_transactions.png new file mode 100644 index 000000000..a902fce68 Binary files /dev/null and b/test/goldens/screens/dashboard/goldens/macos/dashboard_pending_transactions.png differ diff --git a/test/goldens/screens/dashboard/goldens/macos/dashboard_portfolio_chart.png b/test/goldens/screens/dashboard/goldens/macos/dashboard_portfolio_chart.png new file mode 100644 index 000000000..fcae8f175 Binary files /dev/null and b/test/goldens/screens/dashboard/goldens/macos/dashboard_portfolio_chart.png differ diff --git a/test/goldens/screens/dashboard/goldens/macos/dashboard_price_chart.png b/test/goldens/screens/dashboard/goldens/macos/dashboard_price_chart.png new file mode 100644 index 000000000..52746d52d Binary files /dev/null and b/test/goldens/screens/dashboard/goldens/macos/dashboard_price_chart.png differ diff --git a/test/goldens/screens/dashboard/goldens/macos/dashboard_recent_transactions.png b/test/goldens/screens/dashboard/goldens/macos/dashboard_recent_transactions.png new file mode 100644 index 000000000..146496f50 Binary files /dev/null and b/test/goldens/screens/dashboard/goldens/macos/dashboard_recent_transactions.png differ diff --git a/test/goldens/screens/debug_auth/debug_auth_states_golden_test.dart b/test/goldens/screens/debug_auth/debug_auth_states_golden_test.dart new file mode 100644 index 000000000..84802fbe8 --- /dev/null +++ b/test/goldens/screens/debug_auth/debug_auth_states_golden_test.dart @@ -0,0 +1,149 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/screens/debug_auth/cubit/debug_auth_cubit.dart'; +import 'package:realunit_wallet/screens/debug_auth/debug_auth_view.dart'; + +import '../../../helper/helper.dart'; + +class _MockDebugAuthCubit extends MockCubit + implements DebugAuthCubit {} + +// Deterministic fixtures — a fixed address, sign-message challenge and saved +// signature so the loaded/loading/error goldens stay byte-stable across regens. +const _address = '0x9F5713DEacB8e9CAB6c2d3FaE1AFc2715F8D2D71'; +const _signMessage = + 'Sign this message to authenticate with RealUnit.\nNonce: 4f8c1a2b9d3e'; +const _signature = + '0x1babe187a5c3f0d29e4c8b7a6f5e4d3c2b1a0f9e8d7c6b5a4938271605f4e3d2'; + +void main() { + // Sibling `debug_auth_golden_test.dart` covers the default (address field + + // get-message button only). This file adds the sign-message block, the error + // box, both loading branches and the copy-to-clipboard SnackBar of + // `DebugAuthView` (`debug_auth_view.dart`). + late _MockDebugAuthCubit cubit; + + setUp(() { + cubit = _MockDebugAuthCubit(); + // Stub `Clipboard.setData` (routed over SystemChannels.platform) so the + // copy tap does not throw MissingPluginException headless. Returning null + // is the correct no-op for every platform call in this tree. + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, (_) async => null); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, null); + }); + + Widget buildSubject() => wrapForGolden( + BlocProvider.value( + value: cubit, + child: const DebugAuthView(), + ), + ); + + group('$DebugAuthView', () { + // signMessage present: Divider + "Signierte Nachricht:" label + copyable + // box + signature field + Authenticate button (view:66-111). + goldenTest( + 'sign message fetched', + fileName: 'debug_auth_page_sign_message', + constraints: phoneConstraints, + builder: () { + when(() => cubit.state).thenReturn( + const DebugAuthState( + address: _address, + signMessage: _signMessage, + savedSignature: _signature, + ), + ); + return buildSubject(); + }, + ); + + // errorMessage present: red error box below the get-message button + // (view:112-125). + goldenTest( + 'error message', + fileName: 'debug_auth_page_error', + constraints: phoneConstraints, + builder: () { + when(() => cubit.state).thenReturn( + const DebugAuthState( + address: _address, + errorMessage: 'Authentifizierung fehlgeschlagen (401).', + ), + ); + return buildSubject(); + }, + ); + + // isLoading with signMessage present: the Authenticate AppFilledButton + // switches to its loading spinner (view:104-110). Freeze the + // CupertinoActivityIndicator on its first frame. + goldenTest( + 'authenticating — filled button loading', + fileName: 'debug_auth_page_authenticating', + constraints: phoneConstraints, + pumpBeforeTest: pumpOnce, + builder: () { + when(() => cubit.state).thenReturn( + const DebugAuthState( + address: _address, + signMessage: _signMessage, + savedSignature: _signature, + isLoading: true, + ), + ); + return buildSubject(); + }, + ); + + // isLoading with no signMessage yet: the get-sign-message TextButton is + // disabled (view:58-65) and the sign-message block is absent. + goldenTest( + 'fetching sign message — text button disabled', + fileName: 'debug_auth_page_fetching', + constraints: phoneConstraints, + builder: () { + when(() => cubit.state).thenReturn( + const DebugAuthState(address: _address, isLoading: true), + ); + return buildSubject(); + }, + ); + + // Tapping the copyable message box copies to the clipboard and shows the + // green "In die Zwischenablage kopiert" SnackBar (view:74-97). The box's + // GestureDetector and the SelectableText's own selection recognizer overlap, + // so tap the box's top padding — just above the SelectableText — to land on + // the box's GestureDetector rather than the text's caret handler. + goldenTest( + 'copied-to-clipboard SnackBar (green)', + fileName: 'debug_auth_page_clipboard_snackbar', + constraints: phoneConstraints, + pumpBeforeTest: (tester) async { + await tester.pumpAndSettle(); + final messageRect = tester.getRect(find.byType(SelectableText)); + await tester.tapAt(Offset(messageRect.center.dx, messageRect.top - 4)); + await tester.pumpAndSettle(); + }, + builder: () { + when(() => cubit.state).thenReturn( + const DebugAuthState( + address: _address, + signMessage: _signMessage, + savedSignature: _signature, + ), + ); + return buildSubject(); + }, + ); + }); +} diff --git a/test/goldens/screens/debug_auth/goldens/macos/debug_auth_page_authenticating.png b/test/goldens/screens/debug_auth/goldens/macos/debug_auth_page_authenticating.png new file mode 100644 index 000000000..eb688b24e Binary files /dev/null and b/test/goldens/screens/debug_auth/goldens/macos/debug_auth_page_authenticating.png differ diff --git a/test/goldens/screens/debug_auth/goldens/macos/debug_auth_page_clipboard_snackbar.png b/test/goldens/screens/debug_auth/goldens/macos/debug_auth_page_clipboard_snackbar.png new file mode 100644 index 000000000..26bada579 Binary files /dev/null and b/test/goldens/screens/debug_auth/goldens/macos/debug_auth_page_clipboard_snackbar.png differ diff --git a/test/goldens/screens/debug_auth/goldens/macos/debug_auth_page_error.png b/test/goldens/screens/debug_auth/goldens/macos/debug_auth_page_error.png new file mode 100644 index 000000000..fdbb343a2 Binary files /dev/null and b/test/goldens/screens/debug_auth/goldens/macos/debug_auth_page_error.png differ diff --git a/test/goldens/screens/debug_auth/goldens/macos/debug_auth_page_fetching.png b/test/goldens/screens/debug_auth/goldens/macos/debug_auth_page_fetching.png new file mode 100644 index 000000000..2edcfbc89 Binary files /dev/null and b/test/goldens/screens/debug_auth/goldens/macos/debug_auth_page_fetching.png differ diff --git a/test/goldens/screens/debug_auth/goldens/macos/debug_auth_page_sign_message.png b/test/goldens/screens/debug_auth/goldens/macos/debug_auth_page_sign_message.png new file mode 100644 index 000000000..ff297d5bd Binary files /dev/null and b/test/goldens/screens/debug_auth/goldens/macos/debug_auth_page_sign_message.png differ diff --git a/test/goldens/screens/hardware_connect_bitbox/connect_bitbox_states_golden_test.dart b/test/goldens/screens/hardware_connect_bitbox/connect_bitbox_states_golden_test.dart new file mode 100644 index 000000000..f0e0f6fdd --- /dev/null +++ b/test/goldens/screens/hardware_connect_bitbox/connect_bitbox_states_golden_test.dart @@ -0,0 +1,169 @@ +import 'package:bitbox_flutter/bitbox_flutter.dart' as sdk; +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/packages/wallet/wallet.dart'; +import 'package:realunit_wallet/screens/hardware_connect_bitbox/bloc/connect_bitbox_cubit.dart'; +import 'package:realunit_wallet/screens/hardware_connect_bitbox/connect_bitbox_view.dart'; + +import '../../../helper/helper.dart'; + +class _MockConnectBitboxCubit extends MockCubit + implements ConnectBitboxCubit {} + +class _FakeBitboxDevice extends Fake implements sdk.BitboxDevice {} + +class _MockBitboxWallet extends Mock implements BitboxWallet {} + +void main() { + // Sibling `connect_bitbox_golden_test.dart` covers the not-connected default + // (Android copy), connecting, check-hash and signature-failed states. This + // file adds the remaining rendered branches of `ConnectBitboxView` + // (`connect_bitbox_view.dart`): the iOS copy variant, pairing, + // not-initialized, capturing-signature and connected, plus the + // `connectBitboxFailed` SnackBar the BlocListener shows on the transition + // into `BitboxNotConnected`. + // + // The real `ConnectBitboxCubit` starts a USB scan timer in its constructor, + // so — as in the sibling file — a mocked cubit drives the view directly + // instead of the page + real cubit. + late _MockConnectBitboxCubit cubit; + late _FakeBitboxDevice device; + late _MockBitboxWallet wallet; + + setUp(() { + cubit = _MockConnectBitboxCubit(); + device = _FakeBitboxDevice(); + wallet = _MockBitboxWallet(); + }); + + // Cross-test safety net for the iOS copy golden's `defaultTargetPlatform` + // override (each golden also resets it in its own body — see below — but this + // guarantees the override never leaks into a sibling test if a body throws + // before it resets). + tearDown(() => debugDefaultTargetPlatformOverride = null); + + void stubState(BitboxConnectionState state) { + when(() => cubit.state).thenReturn(state); + whenListen( + cubit, + const Stream.empty(), + initialState: state, + ); + } + + Widget buildSubject() => Scaffold( + body: BlocProvider.value( + value: cubit, + child: ConnectBitboxView(onFinish: (_) {}), + ), + ); + + group('$ConnectBitboxView', () { + // BitboxNotConnected on iOS: the default branch swaps `connectBitboxContent` + // for `connectBitboxContentIos` (the extra "enable Bluetooth" line) via + // `DeviceInfo.instance.isIOS` (view:188-190). + goldenTest( + 'not connected default state — iOS copy variant', + fileName: 'connect_bitbox_page_default_ios', + constraints: phoneConstraints, + // The default `pumpBeforeTest` (precacheImages) pumps while the iOS + // override set in the builder is still active, so the snapshot renders the + // iOS copy + SVG. `whilePerforming`'s returned cleanup runs AFTER the + // golden is captured (override still iOS at capture time) but BEFORE the + // framework's end-of-body `debugAssertAllFoundationVarsUnset` invariant — + // the only hook that resets the global in time without a rebuild switching + // the copy back to Android. (Resetting before capture rebuilds to the + // Android copy; a plain tearDown runs too late, after the invariant.) + whilePerforming: (tester) async { + return () async { + debugDefaultTargetPlatformOverride = null; + }; + }, + builder: () { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + stubState(BitboxNotConnected()); + return wrapForGolden(buildSubject()); + }, + ); + + // BitboxPairing: connected illustration + "check pairing code" copy + + // spinner, no buttons (view:107-123). Freeze the CupertinoActivityIndicator + // on its first frame — pumpAndSettle would time out on the endless spin. + goldenTest( + 'pairing state — spinner, no buttons', + fileName: 'connect_bitbox_page_pairing', + constraints: phoneConstraints, + pumpBeforeTest: pumpOnce, + builder: () { + stubState(BitboxPairing(device)); + return wrapForGolden(buildSubject()); + }, + ); + + // BitboxNotInitialized: dedicated title + retry (confirm) and cancel buttons + // (view:124-137). + goldenTest( + 'device not initialized — retry + cancel', + fileName: 'connect_bitbox_page_not_initialized', + constraints: phoneConstraints, + builder: () { + stubState(BitboxNotInitialized(device)); + return wrapForGolden(buildSubject()); + }, + ); + + // BitboxCapturingSignature: "confirm on device" title + spinner, no buttons + // (view:138-154). Freeze the CupertinoActivityIndicator on its first frame. + goldenTest( + 'capturing signature — spinner, no buttons', + fileName: 'connect_bitbox_page_capturing_signature', + constraints: phoneConstraints, + pumpBeforeTest: pumpOnce, + builder: () { + stubState(BitboxCapturingSignature(wallet)); + return wrapForGolden(buildSubject()); + }, + ); + + // BitboxConnected: "Verbunden" title + single confirm button, no cancel + // (view:171-182). + goldenTest( + 'connected — confirm only', + fileName: 'connect_bitbox_page_connected', + constraints: phoneConstraints, + builder: () { + stubState(BitboxConnected(wallet)); + return wrapForGolden(buildSubject()); + }, + ); + + // The BlocListener shows the `connectBitboxFailed` SnackBar on any + // transition into `BitboxNotConnected` (view:35-43) — the connect/pairing + // failure path. Seed `BitboxConnecting` and emit `BitboxNotConnected` so the + // listener fires; the single `pump` delivers the emission (leaving the + // spinner-free default view) and `pumpAndSettle` runs the SnackBar entrance + // to completion (the 4s auto-dismiss is a Timer, not a frame, so it stays + // visible). + goldenTest( + 'connect failure SnackBar (connectBitboxFailed)', + fileName: 'connect_bitbox_page_failed_snackbar', + constraints: phoneConstraints, + pumpBeforeTest: (tester) async { + await tester.pump(); + await tester.pumpAndSettle(); + }, + builder: () { + whenListen( + cubit, + Stream.value(BitboxNotConnected()), + initialState: BitboxConnecting(device), + ); + return wrapForGolden(buildSubject()); + }, + ); + }); +} diff --git a/test/goldens/screens/hardware_connect_bitbox/goldens/macos/bitbox_address_recovery_page_default.png b/test/goldens/screens/hardware_connect_bitbox/goldens/macos/bitbox_address_recovery_page_default.png index 3fcd1a1b8..8e7994652 100644 Binary files a/test/goldens/screens/hardware_connect_bitbox/goldens/macos/bitbox_address_recovery_page_default.png and b/test/goldens/screens/hardware_connect_bitbox/goldens/macos/bitbox_address_recovery_page_default.png differ diff --git a/test/goldens/screens/hardware_connect_bitbox/goldens/macos/connect_bitbox_page_capturing_signature.png b/test/goldens/screens/hardware_connect_bitbox/goldens/macos/connect_bitbox_page_capturing_signature.png new file mode 100644 index 000000000..fb25c8e31 Binary files /dev/null and b/test/goldens/screens/hardware_connect_bitbox/goldens/macos/connect_bitbox_page_capturing_signature.png differ diff --git a/test/goldens/screens/hardware_connect_bitbox/goldens/macos/connect_bitbox_page_check_hash.png b/test/goldens/screens/hardware_connect_bitbox/goldens/macos/connect_bitbox_page_check_hash.png index ddfc92ea9..262c8dbf5 100644 Binary files a/test/goldens/screens/hardware_connect_bitbox/goldens/macos/connect_bitbox_page_check_hash.png and b/test/goldens/screens/hardware_connect_bitbox/goldens/macos/connect_bitbox_page_check_hash.png differ diff --git a/test/goldens/screens/hardware_connect_bitbox/goldens/macos/connect_bitbox_page_connected.png b/test/goldens/screens/hardware_connect_bitbox/goldens/macos/connect_bitbox_page_connected.png new file mode 100644 index 000000000..43e3ba315 Binary files /dev/null and b/test/goldens/screens/hardware_connect_bitbox/goldens/macos/connect_bitbox_page_connected.png differ diff --git a/test/goldens/screens/hardware_connect_bitbox/goldens/macos/connect_bitbox_page_connecting.png b/test/goldens/screens/hardware_connect_bitbox/goldens/macos/connect_bitbox_page_connecting.png index 759c216ce..25233ff7e 100644 Binary files a/test/goldens/screens/hardware_connect_bitbox/goldens/macos/connect_bitbox_page_connecting.png and b/test/goldens/screens/hardware_connect_bitbox/goldens/macos/connect_bitbox_page_connecting.png differ diff --git a/test/goldens/screens/hardware_connect_bitbox/goldens/macos/connect_bitbox_page_default.png b/test/goldens/screens/hardware_connect_bitbox/goldens/macos/connect_bitbox_page_default.png index 3fcd1a1b8..8e7994652 100644 Binary files a/test/goldens/screens/hardware_connect_bitbox/goldens/macos/connect_bitbox_page_default.png and b/test/goldens/screens/hardware_connect_bitbox/goldens/macos/connect_bitbox_page_default.png differ diff --git a/test/goldens/screens/hardware_connect_bitbox/goldens/macos/connect_bitbox_page_default_ios.png b/test/goldens/screens/hardware_connect_bitbox/goldens/macos/connect_bitbox_page_default_ios.png new file mode 100644 index 000000000..4b3373582 Binary files /dev/null and b/test/goldens/screens/hardware_connect_bitbox/goldens/macos/connect_bitbox_page_default_ios.png differ diff --git a/test/goldens/screens/hardware_connect_bitbox/goldens/macos/connect_bitbox_page_failed_snackbar.png b/test/goldens/screens/hardware_connect_bitbox/goldens/macos/connect_bitbox_page_failed_snackbar.png new file mode 100644 index 000000000..ab4bd1fe6 Binary files /dev/null and b/test/goldens/screens/hardware_connect_bitbox/goldens/macos/connect_bitbox_page_failed_snackbar.png differ diff --git a/test/goldens/screens/hardware_connect_bitbox/goldens/macos/connect_bitbox_page_not_initialized.png b/test/goldens/screens/hardware_connect_bitbox/goldens/macos/connect_bitbox_page_not_initialized.png new file mode 100644 index 000000000..d5a7df83d Binary files /dev/null and b/test/goldens/screens/hardware_connect_bitbox/goldens/macos/connect_bitbox_page_not_initialized.png differ diff --git a/test/goldens/screens/hardware_connect_bitbox/goldens/macos/connect_bitbox_page_pairing.png b/test/goldens/screens/hardware_connect_bitbox/goldens/macos/connect_bitbox_page_pairing.png new file mode 100644 index 000000000..532e0efa5 Binary files /dev/null and b/test/goldens/screens/hardware_connect_bitbox/goldens/macos/connect_bitbox_page_pairing.png differ diff --git a/test/goldens/screens/hardware_connect_bitbox/goldens/macos/connect_bitbox_page_signature_failed.png b/test/goldens/screens/hardware_connect_bitbox/goldens/macos/connect_bitbox_page_signature_failed.png index 53c6eb9a6..af28d0c4e 100644 Binary files a/test/goldens/screens/hardware_connect_bitbox/goldens/macos/connect_bitbox_page_signature_failed.png and b/test/goldens/screens/hardware_connect_bitbox/goldens/macos/connect_bitbox_page_signature_failed.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_2fa_page_request_code_failure.png b/test/goldens/screens/kyc/goldens/macos/kyc_2fa_page_request_code_failure.png new file mode 100644 index 000000000..ba2181242 Binary files /dev/null and b/test/goldens/screens/kyc/goldens/macos/kyc_2fa_page_request_code_failure.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_2fa_page_resend_loading.png b/test/goldens/screens/kyc/goldens/macos/kyc_2fa_page_resend_loading.png new file mode 100644 index 000000000..ac23fac64 Binary files /dev/null and b/test/goldens/screens/kyc/goldens/macos/kyc_2fa_page_resend_loading.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_2fa_page_verify_failure.png b/test/goldens/screens/kyc/goldens/macos/kyc_2fa_page_verify_failure.png new file mode 100644 index 000000000..f146b4720 Binary files /dev/null and b/test/goldens/screens/kyc/goldens/macos/kyc_2fa_page_verify_failure.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_2fa_page_verify_loading.png b/test/goldens/screens/kyc/goldens/macos/kyc_2fa_page_verify_loading.png new file mode 100644 index 000000000..69f75fce5 Binary files /dev/null and b/test/goldens/screens/kyc/goldens/macos/kyc_2fa_page_verify_loading.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_account_merge_page_default.png b/test/goldens/screens/kyc/goldens/macos/kyc_account_merge_page_default.png index 505aee251..e7ec257e6 100644 Binary files a/test/goldens/screens/kyc/goldens/macos/kyc_account_merge_page_default.png and b/test/goldens/screens/kyc/goldens/macos/kyc_account_merge_page_default.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_completed_page_default.png b/test/goldens/screens/kyc/goldens/macos/kyc_completed_page_default.png index 3866ebe7e..a3fa85ab6 100644 Binary files a/test/goldens/screens/kyc/goldens/macos/kyc_completed_page_default.png and b/test/goldens/screens/kyc/goldens/macos/kyc_completed_page_default.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_email_page_error_snackbar_does_not_match.png b/test/goldens/screens/kyc/goldens/macos/kyc_email_page_error_snackbar_does_not_match.png new file mode 100644 index 000000000..2921c30f7 Binary files /dev/null and b/test/goldens/screens/kyc/goldens/macos/kyc_email_page_error_snackbar_does_not_match.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_email_page_error_snackbar_unknown.png b/test/goldens/screens/kyc/goldens/macos/kyc_email_page_error_snackbar_unknown.png new file mode 100644 index 000000000..f32236a48 Binary files /dev/null and b/test/goldens/screens/kyc/goldens/macos/kyc_email_page_error_snackbar_unknown.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_email_verification_page_error_snackbar.png b/test/goldens/screens/kyc/goldens/macos/kyc_email_verification_page_error_snackbar.png new file mode 100644 index 000000000..91716a6a5 Binary files /dev/null and b/test/goldens/screens/kyc/goldens/macos/kyc_email_verification_page_error_snackbar.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_email_verification_page_loading.png b/test/goldens/screens/kyc/goldens/macos/kyc_email_verification_page_loading.png new file mode 100644 index 000000000..176979790 Binary files /dev/null and b/test/goldens/screens/kyc/goldens/macos/kyc_email_verification_page_loading.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_email_verification_page_loading_bitbox.png b/test/goldens/screens/kyc/goldens/macos/kyc_email_verification_page_loading_bitbox.png new file mode 100644 index 000000000..d061dcc27 Binary files /dev/null and b/test/goldens/screens/kyc/goldens/macos/kyc_email_verification_page_loading_bitbox.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_financial_data_page_fallback.png b/test/goldens/screens/kyc/goldens/macos/kyc_financial_data_page_fallback.png new file mode 100644 index 000000000..3ba31519b Binary files /dev/null and b/test/goldens/screens/kyc/goldens/macos/kyc_financial_data_page_fallback.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_financial_data_page_submit_failure.png b/test/goldens/screens/kyc/goldens/macos/kyc_financial_data_page_submit_failure.png new file mode 100644 index 000000000..88a371b1a Binary files /dev/null and b/test/goldens/screens/kyc/goldens/macos/kyc_financial_data_page_submit_failure.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_financial_data_questions_page_answered.png b/test/goldens/screens/kyc/goldens/macos/kyc_financial_data_questions_page_answered.png new file mode 100644 index 000000000..4ff9711e2 Binary files /dev/null and b/test/goldens/screens/kyc/goldens/macos/kyc_financial_data_questions_page_answered.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_financial_data_questions_page_checkbox.png b/test/goldens/screens/kyc/goldens/macos/kyc_financial_data_questions_page_checkbox.png new file mode 100644 index 000000000..9f802df1f Binary files /dev/null and b/test/goldens/screens/kyc/goldens/macos/kyc_financial_data_questions_page_checkbox.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_financial_data_questions_page_link_description.png b/test/goldens/screens/kyc/goldens/macos/kyc_financial_data_questions_page_link_description.png new file mode 100644 index 000000000..4273f7aef Binary files /dev/null and b/test/goldens/screens/kyc/goldens/macos/kyc_financial_data_questions_page_link_description.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_financial_data_questions_page_multiple_choice.png b/test/goldens/screens/kyc/goldens/macos/kyc_financial_data_questions_page_multiple_choice.png new file mode 100644 index 000000000..0b31e4d97 Binary files /dev/null and b/test/goldens/screens/kyc/goldens/macos/kyc_financial_data_questions_page_multiple_choice.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_financial_data_questions_page_no_description.png b/test/goldens/screens/kyc/goldens/macos/kyc_financial_data_questions_page_no_description.png new file mode 100644 index 000000000..0dcf795ff Binary files /dev/null and b/test/goldens/screens/kyc/goldens/macos/kyc_financial_data_questions_page_no_description.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_financial_data_questions_page_not_last.png b/test/goldens/screens/kyc/goldens/macos/kyc_financial_data_questions_page_not_last.png new file mode 100644 index 000000000..e0cdbbcae Binary files /dev/null and b/test/goldens/screens/kyc/goldens/macos/kyc_financial_data_questions_page_not_last.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_financial_data_questions_page_single_choice.png b/test/goldens/screens/kyc/goldens/macos/kyc_financial_data_questions_page_single_choice.png new file mode 100644 index 000000000..ea4723f0a Binary files /dev/null and b/test/goldens/screens/kyc/goldens/macos/kyc_financial_data_questions_page_single_choice.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_ident_page_error.png b/test/goldens/screens/kyc/goldens/macos/kyc_ident_page_error.png new file mode 100644 index 000000000..8bcfcfb1c Binary files /dev/null and b/test/goldens/screens/kyc/goldens/macos/kyc_ident_page_error.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_ident_page_finally_rejected.png b/test/goldens/screens/kyc/goldens/macos/kyc_ident_page_finally_rejected.png new file mode 100644 index 000000000..a63fc5d17 Binary files /dev/null and b/test/goldens/screens/kyc/goldens/macos/kyc_ident_page_finally_rejected.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_ident_page_loading.png b/test/goldens/screens/kyc/goldens/macos/kyc_ident_page_loading.png new file mode 100644 index 000000000..887b4b2c4 Binary files /dev/null and b/test/goldens/screens/kyc/goldens/macos/kyc_ident_page_loading.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_link_wallet_page_bitbox_required.png b/test/goldens/screens/kyc/goldens/macos/kyc_link_wallet_page_bitbox_required.png index 34d7ea649..c598927a8 100644 Binary files a/test/goldens/screens/kyc/goldens/macos/kyc_link_wallet_page_bitbox_required.png and b/test/goldens/screens/kyc/goldens/macos/kyc_link_wallet_page_bitbox_required.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_link_wallet_page_default.png b/test/goldens/screens/kyc/goldens/macos/kyc_link_wallet_page_default.png index 34d7ea649..c598927a8 100644 Binary files a/test/goldens/screens/kyc/goldens/macos/kyc_link_wallet_page_default.png and b/test/goldens/screens/kyc/goldens/macos/kyc_link_wallet_page_default.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_link_wallet_page_failure.png b/test/goldens/screens/kyc/goldens/macos/kyc_link_wallet_page_failure.png new file mode 100644 index 000000000..4cb1a075a Binary files /dev/null and b/test/goldens/screens/kyc/goldens/macos/kyc_link_wallet_page_failure.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_link_wallet_page_missing_user_data.png b/test/goldens/screens/kyc/goldens/macos/kyc_link_wallet_page_missing_user_data.png new file mode 100644 index 000000000..36fa7a341 Binary files /dev/null and b/test/goldens/screens/kyc/goldens/macos/kyc_link_wallet_page_missing_user_data.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_link_wallet_page_submitting.png b/test/goldens/screens/kyc/goldens/macos/kyc_link_wallet_page_submitting.png new file mode 100644 index 000000000..0bcd54cec Binary files /dev/null and b/test/goldens/screens/kyc/goldens/macos/kyc_link_wallet_page_submitting.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_link_wallet_page_success.png b/test/goldens/screens/kyc/goldens/macos/kyc_link_wallet_page_success.png new file mode 100644 index 000000000..eeefc22df Binary files /dev/null and b/test/goldens/screens/kyc/goldens/macos/kyc_link_wallet_page_success.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_manual_review_page_default.png b/test/goldens/screens/kyc/goldens/macos/kyc_manual_review_page_default.png new file mode 100644 index 000000000..eae78a45b Binary files /dev/null and b/test/goldens/screens/kyc/goldens/macos/kyc_manual_review_page_default.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_merge_processing_page_default.png b/test/goldens/screens/kyc/goldens/macos/kyc_merge_processing_page_default.png index 1da6cee34..48a3e207f 100644 Binary files a/test/goldens/screens/kyc/goldens/macos/kyc_merge_processing_page_default.png and b/test/goldens/screens/kyc/goldens/macos/kyc_merge_processing_page_default.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_nationality_page_country_error.png b/test/goldens/screens/kyc/goldens/macos/kyc_nationality_page_country_error.png new file mode 100644 index 000000000..d290945f2 Binary files /dev/null and b/test/goldens/screens/kyc/goldens/macos/kyc_nationality_page_country_error.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_nationality_page_country_loading.png b/test/goldens/screens/kyc/goldens/macos/kyc_nationality_page_country_loading.png new file mode 100644 index 000000000..e2b8a1e38 Binary files /dev/null and b/test/goldens/screens/kyc/goldens/macos/kyc_nationality_page_country_loading.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_nationality_page_default.png b/test/goldens/screens/kyc/goldens/macos/kyc_nationality_page_default.png index bb1d746f9..c477385f6 100644 Binary files a/test/goldens/screens/kyc/goldens/macos/kyc_nationality_page_default.png and b/test/goldens/screens/kyc/goldens/macos/kyc_nationality_page_default.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_nationality_page_dropdown_open.png b/test/goldens/screens/kyc/goldens/macos/kyc_nationality_page_dropdown_open.png new file mode 100644 index 000000000..023e0e679 Binary files /dev/null and b/test/goldens/screens/kyc/goldens/macos/kyc_nationality_page_dropdown_open.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_nationality_page_submit_failure.png b/test/goldens/screens/kyc/goldens/macos/kyc_nationality_page_submit_failure.png new file mode 100644 index 000000000..9a1da3cb4 Binary files /dev/null and b/test/goldens/screens/kyc/goldens/macos/kyc_nationality_page_submit_failure.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_nationality_page_submit_loading.png b/test/goldens/screens/kyc/goldens/macos/kyc_nationality_page_submit_loading.png new file mode 100644 index 000000000..4e35c99c4 Binary files /dev/null and b/test/goldens/screens/kyc/goldens/macos/kyc_nationality_page_submit_loading.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_nationality_page_validation_error.png b/test/goldens/screens/kyc/goldens/macos/kyc_nationality_page_validation_error.png new file mode 100644 index 000000000..7069e9a28 Binary files /dev/null and b/test/goldens/screens/kyc/goldens/macos/kyc_nationality_page_validation_error.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_pending_page_default.png b/test/goldens/screens/kyc/goldens/macos/kyc_pending_page_default.png index 15c090de2..726a0c11b 100644 Binary files a/test/goldens/screens/kyc/goldens/macos/kyc_pending_page_default.png and b/test/goldens/screens/kyc/goldens/macos/kyc_pending_page_default.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_registration_address_step_default.png b/test/goldens/screens/kyc/goldens/macos/kyc_registration_address_step_default.png index 5b704521d..74d4b67d7 100644 Binary files a/test/goldens/screens/kyc/goldens/macos/kyc_registration_address_step_default.png and b/test/goldens/screens/kyc/goldens/macos/kyc_registration_address_step_default.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_registration_address_step_validation_error.png b/test/goldens/screens/kyc/goldens/macos/kyc_registration_address_step_validation_error.png new file mode 100644 index 000000000..5ac28b32b Binary files /dev/null and b/test/goldens/screens/kyc/goldens/macos/kyc_registration_address_step_validation_error.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_registration_page_address_step.png b/test/goldens/screens/kyc/goldens/macos/kyc_registration_page_address_step.png new file mode 100644 index 000000000..914fdabc4 Binary files /dev/null and b/test/goldens/screens/kyc/goldens/macos/kyc_registration_page_address_step.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_registration_page_bitbox_required.png b/test/goldens/screens/kyc/goldens/macos/kyc_registration_page_bitbox_required.png new file mode 100644 index 000000000..957836020 Binary files /dev/null and b/test/goldens/screens/kyc/goldens/macos/kyc_registration_page_bitbox_required.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_registration_page_default.png b/test/goldens/screens/kyc/goldens/macos/kyc_registration_page_default.png index 243ced101..b765ed6c1 100644 Binary files a/test/goldens/screens/kyc/goldens/macos/kyc_registration_page_default.png and b/test/goldens/screens/kyc/goldens/macos/kyc_registration_page_default.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_registration_page_forwarding_failed_snackbar.png b/test/goldens/screens/kyc/goldens/macos/kyc_registration_page_forwarding_failed_snackbar.png new file mode 100644 index 000000000..0efa30889 Binary files /dev/null and b/test/goldens/screens/kyc/goldens/macos/kyc_registration_page_forwarding_failed_snackbar.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_registration_page_prefilled.png b/test/goldens/screens/kyc/goldens/macos/kyc_registration_page_prefilled.png new file mode 100644 index 000000000..3c2272d60 Binary files /dev/null and b/test/goldens/screens/kyc/goldens/macos/kyc_registration_page_prefilled.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_registration_page_submit_failure_snackbar.png b/test/goldens/screens/kyc/goldens/macos/kyc_registration_page_submit_failure_snackbar.png new file mode 100644 index 000000000..2ffe0203c Binary files /dev/null and b/test/goldens/screens/kyc/goldens/macos/kyc_registration_page_submit_failure_snackbar.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_registration_page_submit_loading.png b/test/goldens/screens/kyc/goldens/macos/kyc_registration_page_submit_loading.png new file mode 100644 index 000000000..62caf6d4b Binary files /dev/null and b/test/goldens/screens/kyc/goldens/macos/kyc_registration_page_submit_loading.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_registration_page_submit_rejected_snackbar.png b/test/goldens/screens/kyc/goldens/macos/kyc_registration_page_submit_rejected_snackbar.png new file mode 100644 index 000000000..7d98dff00 Binary files /dev/null and b/test/goldens/screens/kyc/goldens/macos/kyc_registration_page_submit_rejected_snackbar.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_registration_page_tax_step.png b/test/goldens/screens/kyc/goldens/macos/kyc_registration_page_tax_step.png new file mode 100644 index 000000000..14b0c0f5a Binary files /dev/null and b/test/goldens/screens/kyc/goldens/macos/kyc_registration_page_tax_step.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_registration_personal_step_account_type_open.png b/test/goldens/screens/kyc/goldens/macos/kyc_registration_personal_step_account_type_open.png new file mode 100644 index 000000000..4cf953c4a Binary files /dev/null and b/test/goldens/screens/kyc/goldens/macos/kyc_registration_personal_step_account_type_open.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_registration_personal_step_default.png b/test/goldens/screens/kyc/goldens/macos/kyc_registration_personal_step_default.png index 7c29781ad..49683a12c 100644 Binary files a/test/goldens/screens/kyc/goldens/macos/kyc_registration_personal_step_default.png and b/test/goldens/screens/kyc/goldens/macos/kyc_registration_personal_step_default.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_registration_personal_step_phone_prefix_open.png b/test/goldens/screens/kyc/goldens/macos/kyc_registration_personal_step_phone_prefix_open.png new file mode 100644 index 000000000..aeefe0118 Binary files /dev/null and b/test/goldens/screens/kyc/goldens/macos/kyc_registration_personal_step_phone_prefix_open.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_registration_personal_step_validation_error.png b/test/goldens/screens/kyc/goldens/macos/kyc_registration_personal_step_validation_error.png new file mode 100644 index 000000000..7329b397a Binary files /dev/null and b/test/goldens/screens/kyc/goldens/macos/kyc_registration_personal_step_validation_error.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_registration_tax_step_country_error.png b/test/goldens/screens/kyc/goldens/macos/kyc_registration_tax_step_country_error.png index 5ea974740..51a57dbf4 100644 Binary files a/test/goldens/screens/kyc/goldens/macos/kyc_registration_tax_step_country_error.png and b/test/goldens/screens/kyc/goldens/macos/kyc_registration_tax_step_country_error.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_registration_tax_step_default.png b/test/goldens/screens/kyc/goldens/macos/kyc_registration_tax_step_default.png index e0f793087..5b2f72cde 100644 Binary files a/test/goldens/screens/kyc/goldens/macos/kyc_registration_tax_step_default.png and b/test/goldens/screens/kyc/goldens/macos/kyc_registration_tax_step_default.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_registration_tax_step_multi.png b/test/goldens/screens/kyc/goldens/macos/kyc_registration_tax_step_multi.png new file mode 100644 index 000000000..b72e8b218 Binary files /dev/null and b/test/goldens/screens/kyc/goldens/macos/kyc_registration_tax_step_multi.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_registration_tax_step_non_swiss.png b/test/goldens/screens/kyc/goldens/macos/kyc_registration_tax_step_non_swiss.png index b714c11d8..ef81f93b2 100644 Binary files a/test/goldens/screens/kyc/goldens/macos/kyc_registration_tax_step_non_swiss.png and b/test/goldens/screens/kyc/goldens/macos/kyc_registration_tax_step_non_swiss.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_registration_tax_step_swiss.png b/test/goldens/screens/kyc/goldens/macos/kyc_registration_tax_step_swiss.png index b28027e70..1ac353647 100644 Binary files a/test/goldens/screens/kyc/goldens/macos/kyc_registration_tax_step_swiss.png and b/test/goldens/screens/kyc/goldens/macos/kyc_registration_tax_step_swiss.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_registration_tax_step_tin_error.png b/test/goldens/screens/kyc/goldens/macos/kyc_registration_tax_step_tin_error.png index 646e9c87b..3deac432a 100644 Binary files a/test/goldens/screens/kyc/goldens/macos/kyc_registration_tax_step_tin_error.png and b/test/goldens/screens/kyc/goldens/macos/kyc_registration_tax_step_tin_error.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_tax_scenario_s1_ch_only.png b/test/goldens/screens/kyc/goldens/macos/kyc_tax_scenario_s1_ch_only.png new file mode 100644 index 000000000..1ac353647 Binary files /dev/null and b/test/goldens/screens/kyc/goldens/macos/kyc_tax_scenario_s1_ch_only.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_tax_scenario_s2_de_only_empty_tin.png b/test/goldens/screens/kyc/goldens/macos/kyc_tax_scenario_s2_de_only_empty_tin.png new file mode 100644 index 000000000..ef81f93b2 Binary files /dev/null and b/test/goldens/screens/kyc/goldens/macos/kyc_tax_scenario_s2_de_only_empty_tin.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_tax_scenario_s2_de_only_filled.png b/test/goldens/screens/kyc/goldens/macos/kyc_tax_scenario_s2_de_only_filled.png new file mode 100644 index 000000000..a018f9f9f Binary files /dev/null and b/test/goldens/screens/kyc/goldens/macos/kyc_tax_scenario_s2_de_only_filled.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_tax_scenario_s2_de_only_tin_error.png b/test/goldens/screens/kyc/goldens/macos/kyc_tax_scenario_s2_de_only_tin_error.png new file mode 100644 index 000000000..3deac432a Binary files /dev/null and b/test/goldens/screens/kyc/goldens/macos/kyc_tax_scenario_s2_de_only_tin_error.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_tax_scenario_s3_ch_fr.png b/test/goldens/screens/kyc/goldens/macos/kyc_tax_scenario_s3_ch_fr.png new file mode 100644 index 000000000..ff0f2d7df Binary files /dev/null and b/test/goldens/screens/kyc/goldens/macos/kyc_tax_scenario_s3_ch_fr.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_tax_scenario_s3_ch_fr_tin_error.png b/test/goldens/screens/kyc/goldens/macos/kyc_tax_scenario_s3_ch_fr_tin_error.png new file mode 100644 index 000000000..09db969a6 Binary files /dev/null and b/test/goldens/screens/kyc/goldens/macos/kyc_tax_scenario_s3_ch_fr_tin_error.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_tax_scenario_s4_de_ch.png b/test/goldens/screens/kyc/goldens/macos/kyc_tax_scenario_s4_de_ch.png new file mode 100644 index 000000000..37389da57 Binary files /dev/null and b/test/goldens/screens/kyc/goldens/macos/kyc_tax_scenario_s4_de_ch.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_tax_scenario_s5_de_fr_us.png b/test/goldens/screens/kyc/goldens/macos/kyc_tax_scenario_s5_de_fr_us.png new file mode 100644 index 000000000..d63132705 Binary files /dev/null and b/test/goldens/screens/kyc/goldens/macos/kyc_tax_scenario_s5_de_fr_us.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_tax_scenario_s5_de_fr_us_partial_tin_error.png b/test/goldens/screens/kyc/goldens/macos/kyc_tax_scenario_s5_de_fr_us_partial_tin_error.png new file mode 100644 index 000000000..4a3127b4d Binary files /dev/null and b/test/goldens/screens/kyc/goldens/macos/kyc_tax_scenario_s5_de_fr_us_partial_tin_error.png differ diff --git a/test/goldens/screens/kyc/kyc_2fa_states_golden_test.dart b/test/goldens/screens/kyc/kyc_2fa_states_golden_test.dart new file mode 100644 index 000000000..ca7c652b3 --- /dev/null +++ b/test/goldens/screens/kyc/kyc_2fa_states_golden_test.dart @@ -0,0 +1,131 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/screens/kyc/cubits/kyc/kyc_cubit.dart'; +import 'package:realunit_wallet/screens/kyc/steps/2fa/cubits/kyc_2fa/kyc_2fa_cubit.dart'; +import 'package:realunit_wallet/screens/kyc/steps/2fa/cubits/kyc_2fa_verify/kyc_2fa_verify_cubit.dart'; +import 'package:realunit_wallet/screens/kyc/steps/2fa/kyc_2fa_page.dart'; + +import '../../../helper/helper.dart'; + +class _MockKyc2FaCubit extends MockCubit implements Kyc2FaCubit {} + +class _MockKyc2FaVerifyCubit extends MockCubit + implements Kyc2FaVerifyCubit {} + +class _MockKycCubit extends MockCubit implements KycCubit {} + +void main() { + // The `kyc_2fa_page_default` idle baseline lives in `kyc_2fa_golden_test.dart`. + // This file covers the state-driven branches of `Kyc2FaView` + // (`kyc_2fa_page.dart`): the two loading spinners and the two red error + // SnackBars fired from the `MultiBlocListener`. + // + // Documented skip — the code-field validation messages (`twoFaCodeRequired` + // / `registerPhoneNumberOnlyDigits` / `twoFaCodeTooShort`, page:117-127) + // render only after the Next button runs `_formKey.currentState!.validate()`. + // The `Form` has no `autovalidateMode` and no state mirrors the validator + // output, so the message is purely interaction-driven with no State seam — a + // text-field validate() error, skipped per the golden-states convention. + late _MockKyc2FaCubit kyc2FaCubit; + late _MockKyc2FaVerifyCubit kyc2FaVerifyCubit; + late _MockKycCubit kycCubit; + + setUp(() { + kyc2FaCubit = _MockKyc2FaCubit(); + kyc2FaVerifyCubit = _MockKyc2FaVerifyCubit(); + kycCubit = _MockKycCubit(); + + when(() => kyc2FaCubit.state).thenReturn(const Kyc2FaInitial()); + when(() => kyc2FaCubit.requestCode()).thenAnswer((_) => Future.value()); + when(() => kyc2FaVerifyCubit.state).thenReturn(const Kyc2FaVerifyInitial()); + when(() => kycCubit.state).thenReturn(const KycInitial()); + }); + + Widget buildSubject() => wrapForGolden( + MultiBlocProvider( + providers: [ + BlocProvider.value(value: kyc2FaCubit), + BlocProvider.value(value: kyc2FaVerifyCubit), + BlocProvider.value(value: kycCubit), + ], + child: const Kyc2FaView(), + ), + ); + + // Deliver the whenListen emission to the MultiBlocListener (which fires + // showSnackBar), then run the SnackBar entrance to completion. The 4s + // auto-dismiss is a Timer, not a scheduled frame, so pumpAndSettle returns + // with the SnackBar at rest. + Future settleSnackBar(WidgetTester tester) async { + await tester.pump(); + await tester.pumpAndSettle(); + } + + group('$Kyc2FaView', () { + // Kyc2FaVerifyLoading → the Next AppFilledButton renders its loading variant + // (CupertinoActivityIndicator, page:135-136). Freeze the spinner on frame 0. + goldenTest( + 'verify in flight — next button spinner', + fileName: 'kyc_2fa_page_verify_loading', + constraints: phoneConstraints, + pumpBeforeTest: pumpOnce, + builder: () { + when(() => kyc2FaVerifyCubit.state) + .thenReturn(const Kyc2FaVerifyLoading()); + return buildSubject(); + }, + ); + + // Kyc2FaLoading → the resend AppTextButton is disabled and shows the + // "Sending…" label (page:146-158). No spinner, so the default settle applies. + goldenTest( + 'resend in flight — disabled sending label', + fileName: 'kyc_2fa_page_resend_loading', + constraints: phoneConstraints, + builder: () { + when(() => kyc2FaCubit.state).thenReturn(const Kyc2FaLoading()); + return buildSubject(); + }, + ); + + // Kyc2FaVerifyFailure → red `twoFaWrongCode` SnackBar (page:66-72). + goldenTest( + 'verify failure — red wrong-code snackbar', + fileName: 'kyc_2fa_page_verify_failure', + constraints: phoneConstraints, + pumpBeforeTest: settleSnackBar, + builder: () { + whenListen( + kyc2FaVerifyCubit, + Stream.value( + const Kyc2FaVerifyFailure(errorMessage: 'invalid code'), + ), + initialState: const Kyc2FaVerifyInitial(), + ); + return buildSubject(); + }, + ); + + // Kyc2FaFailure → red `twoFaSendCodeFailed` SnackBar (page:76-88). + goldenTest( + 'request-code failure — red send-failed snackbar', + fileName: 'kyc_2fa_page_request_code_failure', + constraints: phoneConstraints, + pumpBeforeTest: settleSnackBar, + builder: () { + whenListen( + kyc2FaCubit, + Stream.value( + const Kyc2FaFailure(errorMessage: 'rate limited'), + ), + initialState: const Kyc2FaInitial(), + ); + when(() => kyc2FaCubit.requestCode()).thenAnswer((_) => Future.value()); + return buildSubject(); + }, + ); + }); +} diff --git a/test/goldens/screens/kyc/kyc_email_states_golden_test.dart b/test/goldens/screens/kyc/kyc_email_states_golden_test.dart new file mode 100644 index 000000000..a71caf9f4 --- /dev/null +++ b/test/goldens/screens/kyc/kyc_email_states_golden_test.dart @@ -0,0 +1,104 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/screens/kyc/cubits/kyc/kyc_cubit.dart'; +import 'package:realunit_wallet/screens/kyc/steps/email/cubits/email_step/kyc_email_step_cubit.dart'; +import 'package:realunit_wallet/screens/kyc/steps/email/kyc_email_page.dart'; + +import '../../../helper/helper.dart'; + +class _MockKycEmailStepCubit extends MockCubit + implements KycEmailStepCubit {} + +class _MockKycCubit extends MockCubit implements KycCubit {} + +void main() { + // `kyc_email_page_{default,loading,does_not_match,unknown_error}` in + // `kyc_email_golden_test.dart` stub the failure through the `state` getter + // only, so the form's `BlocListener` never fires and no SnackBar is present + // (those goldens render the plain form and feed the handbook screenshots). + // This file renders the actual red error SnackBars by emitting the failure as + // a state *transition* through the real `BlocListener` + // (`KycEmailForm`, kyc_email_page.dart:58-70). + // + // Documented skip — inline field-validation errors (`registerEmailRequired` + // for an empty field, `registerEmailInvalid` for a malformed one): the + // `TextFormField` sets no `autovalidateMode` (`labeled_text_field.dart:47`) + // and the `Form` only validates from the Next-button `onPressed` + // (kyc_email_page.dart:127). There is no state- or autovalidate-driven path to + // the inline error, so rendering it would require simulating the button tap — + // an interaction-driven `Form.validate()` error, out of scope for these state + // baselines. + late _MockKycEmailStepCubit emailStepCubit; + late _MockKycCubit kycCubit; + + setUp(() { + emailStepCubit = _MockKycEmailStepCubit(); + kycCubit = _MockKycCubit(); + when(() => kycCubit.state).thenReturn(const KycInitial()); + }); + + Widget buildSubject() => wrapForGolden( + MultiBlocProvider( + providers: [ + BlocProvider.value(value: emailStepCubit), + BlocProvider.value(value: kycCubit), + ], + child: const KycEmailView(), + ), + ); + + Future pumpSnackBar(WidgetTester tester) async { + await tester.pump(); // deliver the whenListen emission to the BlocListener + await tester.pumpAndSettle(); // run the SnackBar entrance to completion + } + + group('$KycEmailView', () { + // error == emailDoesNotMatch → the listener shows the localized + // `registerEmailDoesNotMatch` copy (kyc_email_page.dart:61-63), not + // `state.message`. + goldenTest( + 'emailDoesNotMatch failure — red error SnackBar', + fileName: 'kyc_email_page_error_snackbar_does_not_match', + constraints: phoneConstraints, + pumpBeforeTest: pumpSnackBar, + builder: () { + whenListen( + emailStepCubit, + Stream.value( + const KycEmailStepFailure( + KycEmailStepError.emailDoesNotMatch, + 'unused — the listener renders registerEmailDoesNotMatch', + ), + ), + initialState: const KycEmailStepInitial(), + ); + return buildSubject(); + }, + ); + + // error == unknown → the listener shows `state.message` verbatim + // (kyc_email_page.dart:63). + goldenTest( + 'unknown failure — red error SnackBar', + fileName: 'kyc_email_page_error_snackbar_unknown', + constraints: phoneConstraints, + pumpBeforeTest: pumpSnackBar, + builder: () { + whenListen( + emailStepCubit, + Stream.value( + const KycEmailStepFailure( + KycEmailStepError.unknown, + 'Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut.', + ), + ), + initialState: const KycEmailStepInitial(), + ); + return buildSubject(); + }, + ); + }); +} diff --git a/test/goldens/screens/kyc/kyc_email_verification_states_golden_test.dart b/test/goldens/screens/kyc/kyc_email_verification_states_golden_test.dart new file mode 100644 index 000000000..956818e2f --- /dev/null +++ b/test/goldens/screens/kyc/kyc_email_verification_states_golden_test.dart @@ -0,0 +1,124 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/packages/hardware_wallet/bitbox_credentials.dart'; +import 'package:realunit_wallet/packages/wallet/wallet_account.dart'; +import 'package:realunit_wallet/screens/home/bloc/home_bloc.dart'; +import 'package:realunit_wallet/screens/kyc/steps/email/cubits/email_verification/kyc_email_verification_cubit.dart'; +import 'package:realunit_wallet/screens/kyc/steps/email/subpages/kyc_email_verification_page.dart'; + +import '../../../helper/helper.dart'; + +class _MockKycEmailVerificationCubit + extends MockCubit + implements KycEmailVerificationCubit {} + +void main() { + // `kyc_email_verification_page_default` (initial state) lives in + // `kyc_email_verification_golden_test.dart`. This file covers the loading and + // failure branches of `KycEmailVerificationView` + // (`kyc_email_verification_page.dart`). + // + // `isBitbox` is derived from the HomeBloc wallet, not the verification cubit + // (page:82-87): `openWallet?.currentAccount.primaryAddress is BitboxCredentials`. + // A null `openWallet` (software default) resolves to `false`; a + // `BitboxWalletAccount` whose `primaryAddress` is a real `BitboxCredentials` + // resolves to `true`, gating the multi-page BitBox sign hint (page:98-105). + late _MockKycEmailVerificationCubit verificationCubit; + late MockHomeBloc homeBloc; + + setUp(() { + verificationCubit = _MockKycEmailVerificationCubit(); + homeBloc = MockHomeBloc(); + when(() => verificationCubit.state) + .thenReturn(const KycEmailVerificationInitial()); + when(() => homeBloc.state).thenReturn(const HomeState()); + }); + + Widget buildSubject() => wrapForGolden( + MultiBlocProvider( + providers: [ + BlocProvider.value( + value: verificationCubit, + ), + BlocProvider.value(value: homeBloc), + ], + child: const KycEmailVerificationView(), + ), + ); + + // The illustration `SvgPicture.asset` loads asynchronously and the loading + // button hosts a `CupertinoActivityIndicator` that animates forever, so + // pumpAndSettle would hang. Pump a fixed set of frames instead: enough for + // flutter_svg's microtask-based asset load to resolve, with the spinner + // frozen at a deterministic phase (fixed elapsed time across regens). + Future pumpLoadingSpinner(WidgetTester tester) async { + await tester.pump(); // build + mount the SVG and the spinner + await tester.pump(const Duration(milliseconds: 32)); // resolve SVG load + await tester.pump(const Duration(milliseconds: 32)); // paint the SVG + } + + group('$KycEmailVerificationView', () { + // isLoading && !isBitbox → button spinner, no BitBox hint (software wallet). + goldenTest( + 'loading — button spinner, software wallet (no BitBox hint)', + fileName: 'kyc_email_verification_page_loading', + constraints: phoneConstraints, + pumpBeforeTest: pumpLoadingSpinner, + builder: () { + when(() => verificationCubit.state) + .thenReturn(const KycEmailVerificationLoading()); + return buildSubject(); + }, + ); + + // isLoading && isBitbox → button spinner plus the multi-page BitBox sign + // hint text. + goldenTest( + 'loading — button spinner + BitBox sign hint', + fileName: 'kyc_email_verification_page_loading_bitbox', + constraints: phoneConstraints, + pumpBeforeTest: pumpLoadingSpinner, + builder: () { + when(() => verificationCubit.state) + .thenReturn(const KycEmailVerificationLoading()); + final bitboxWallet = MockBitboxWallet(); + when(() => bitboxWallet.currentAccount).thenReturn( + BitboxWalletAccount( + 0, + BitboxCredentials('0x0000000000000000000000000000000000000001'), + ), + ); + when(() => homeBloc.state) + .thenReturn(HomeState(openWallet: bitboxWallet)); + return buildSubject(); + }, + ); + + // A KycEmailVerificationFailure emitted as a transition fires the + // BlocListener (page:34-42) → red "verification failed" SnackBar. The state + // is not loading, so the button is idle (no spinner) and pumpAndSettle runs + // the SnackBar entrance to completion. + goldenTest( + 'verification failed — red error SnackBar', + fileName: 'kyc_email_verification_page_error_snackbar', + constraints: phoneConstraints, + pumpBeforeTest: (tester) async { + await tester.pump(); // deliver the whenListen emission to the listener + await tester.pumpAndSettle(); // SVG load + SnackBar entrance + }, + builder: () { + whenListen( + verificationCubit, + Stream.value( + const KycEmailVerificationFailure(), + ), + initialState: const KycEmailVerificationInitial(), + ); + return buildSubject(); + }, + ); + }); +} diff --git a/test/goldens/screens/kyc/kyc_financial_data_questions_states_golden_test.dart b/test/goldens/screens/kyc/kyc_financial_data_questions_states_golden_test.dart new file mode 100644 index 000000000..1cd0d95b5 --- /dev/null +++ b/test/goldens/screens/kyc/kyc_financial_data_questions_states_golden_test.dart @@ -0,0 +1,224 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/kyc/dto/kyc_financial_data_dto.dart'; +import 'package:realunit_wallet/screens/kyc/steps/financial_data/cubits/kyc_financial_data_cubit.dart'; +import 'package:realunit_wallet/screens/kyc/steps/financial_data/subpages/kyc_financial_data_questions_page.dart'; + +import '../../../helper/helper.dart'; + +class _MockKycFinancialDataCubit extends MockCubit + implements KycFinancialDataCubit {} + +// Three fixed questions for the not-last (multi-question) progress golden. +const _threeQuestions = [ + KycFinancialQuestion( + key: 'income_source', + type: QuestionType.singleChoice, + title: 'What is your main source of income?', + options: [ + KycFinancialOption(key: 'employment', text: 'Employment'), + KycFinancialOption(key: 'investments', text: 'Investments'), + ], + ), + KycFinancialQuestion( + key: 'net_worth', + type: QuestionType.text, + title: 'What is your estimated net worth?', + ), + KycFinancialQuestion( + key: 'confirm_accuracy', + type: QuestionType.checkbox, + title: 'Confirmation', + options: [ + KycFinancialOption(key: 'accept', text: 'I confirm.'), + ], + ), +]; + +void main() { + // `kyc_financial_data_questions_page_default` (a text question, empty answer, + // last-of-1 → disabled "Complete") lives in + // `kyc_financial_data_questions_golden_test.dart`. This file covers the + // per-type rendering, the description branches, the answered/enabled button, + // and the not-last progress + "Next" label of `KycFinancialDataQuestionsPage` + // (`kyc_financial_data_questions_page.dart`). + // + // All questions use fixed keys/strings and fixed response maps so the goldens + // are deterministic across regens — no clock/random input. + late _MockKycFinancialDataCubit financialDataCubit; + + setUp(() { + financialDataCubit = _MockKycFinancialDataCubit(); + }); + + Widget buildSubject(KycFinancialDataLoadedSuccess state) { + when(() => financialDataCubit.state).thenReturn(state); + return wrapForGolden( + BlocProvider.value( + value: financialDataCubit, + child: KycFinancialDataQuestionsPage(state), + ), + ); + } + + KycFinancialDataLoadedSuccess singleQuestion( + KycFinancialQuestion question, { + Map responses = const {}, + }) => + KycFinancialDataLoadedSuccess( + allQuestions: [question], + visibleQuestions: [question], + responses: responses, + currentIndex: 0, + url: 'https://example.com', + ); + + group('$KycFinancialDataQuestionsPage', () { + // QuestionType.checkbox → CheckboxListTile rendering the option text + // (kyc_question_checkbox_widget.dart). + goldenTest( + 'checkbox question — CheckboxListTile', + fileName: 'kyc_financial_data_questions_page_checkbox', + constraints: phoneConstraints, + builder: () => buildSubject( + singleQuestion( + const KycFinancialQuestion( + key: 'confirm_accuracy', + type: QuestionType.checkbox, + title: 'Confirmation', + description: 'Tick to confirm the information provided is accurate.', + options: [ + KycFinancialOption( + key: 'accept', + text: 'I confirm the information provided is accurate.', + ), + ], + ), + ), + ), + ); + + // QuestionType.singleChoice → RadioGroup of ListTiles, none selected + // (kyc_question_single_choice_widget.dart). + goldenTest( + 'single-choice question — RadioGroup', + fileName: 'kyc_financial_data_questions_page_single_choice', + constraints: phoneConstraints, + builder: () => buildSubject( + singleQuestion( + const KycFinancialQuestion( + key: 'income_source', + type: QuestionType.singleChoice, + title: 'What is your main source of income?', + options: [ + KycFinancialOption(key: 'employment', text: 'Employment'), + KycFinancialOption(key: 'self_employment', text: 'Self-employment'), + KycFinancialOption(key: 'investments', text: 'Investments'), + ], + ), + ), + ), + ); + + // QuestionType.multipleChoice → a list of CheckboxListTiles, none selected + // (kyc_question_multiple_choice_widget.dart). + goldenTest( + 'multiple-choice question — CheckboxListTile list', + fileName: 'kyc_financial_data_questions_page_multiple_choice', + constraints: phoneConstraints, + builder: () => buildSubject( + singleQuestion( + const KycFinancialQuestion( + key: 'asset_types', + type: QuestionType.multipleChoice, + title: 'Which asset types do you hold?', + options: [ + KycFinancialOption(key: 'stocks', text: 'Stocks'), + KycFinancialOption(key: 'bonds', text: 'Bonds'), + KycFinancialOption(key: 'crypto', text: 'Crypto assets'), + ], + ), + ), + ), + ); + + // key == 'tnc' → the description renders as a blue, underlined link + // (kyc_financial_data_questions_page.dart:109-134). + goldenTest( + 'link description — blue underlined (tnc)', + fileName: 'kyc_financial_data_questions_page_link_description', + constraints: phoneConstraints, + builder: () => buildSubject( + singleQuestion( + const KycFinancialQuestion( + key: 'tnc', + type: QuestionType.checkbox, + title: 'Terms and Conditions', + description: 'I accept the terms and conditions.', + options: [ + KycFinancialOption( + key: 'accept', + text: 'I accept the terms and conditions.', + ), + ], + ), + ), + ), + ); + + // description == null → the description block is omitted (page:78-80). + goldenTest( + 'question without description', + fileName: 'kyc_financial_data_questions_page_no_description', + constraints: phoneConstraints, + builder: () => buildSubject( + singleQuestion( + const KycFinancialQuestion( + key: 'net_worth', + type: QuestionType.text, + title: 'What is your estimated net worth?', + ), + ), + ), + ); + + // hasAnswer == true (a non-empty response) → the "Complete" button is + // enabled (page:89-95); the text field shows the retained answer. + goldenTest( + 'answer present — enabled button', + fileName: 'kyc_financial_data_questions_page_answered', + constraints: phoneConstraints, + builder: () => buildSubject( + singleQuestion( + const KycFinancialQuestion( + key: 'annual_income', + type: QuestionType.text, + title: 'What is your annual income?', + description: 'Please provide a rough estimate.', + ), + responses: const {'annual_income': '120000'}, + ), + ), + ); + + // currentIndex 0 of 3 visible → isLastQuestion false → button label is + // "Next" (page:92-94) and the progress reads "Frage 1 von 3" (page:55-60). + goldenTest( + 'not-last question — Next label + progress', + fileName: 'kyc_financial_data_questions_page_not_last', + constraints: phoneConstraints, + builder: () => buildSubject( + const KycFinancialDataLoadedSuccess( + allQuestions: _threeQuestions, + visibleQuestions: _threeQuestions, + responses: {}, + currentIndex: 0, + url: 'https://example.com', + ), + ), + ); + }); +} diff --git a/test/goldens/screens/kyc/kyc_financial_data_states_golden_test.dart b/test/goldens/screens/kyc/kyc_financial_data_states_golden_test.dart new file mode 100644 index 000000000..4369e84d9 --- /dev/null +++ b/test/goldens/screens/kyc/kyc_financial_data_states_golden_test.dart @@ -0,0 +1,101 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/kyc/dto/kyc_financial_data_dto.dart'; +import 'package:realunit_wallet/screens/kyc/cubits/kyc/kyc_cubit.dart'; +import 'package:realunit_wallet/screens/kyc/steps/financial_data/cubits/kyc_financial_data_cubit.dart'; +import 'package:realunit_wallet/screens/kyc/steps/financial_data/kyc_financial_data_page.dart'; + +import '../../../helper/helper.dart'; + +class _MockKycFinancialDataCubit extends MockCubit + implements KycFinancialDataCubit {} + +class _MockKycCubit extends MockCubit implements KycCubit {} + +void main() { + // `kyc_financial_data_page_default` (the loading spinner) lives in + // `kyc_financial_data_golden_test.dart`. This file covers the remaining + // rendered branches of the `KycFinancialDataView` switch + // (`kyc_financial_data_page.dart:60-68`). + late _MockKycFinancialDataCubit financialDataCubit; + late _MockKycCubit kycCubit; + + const question = KycFinancialQuestion( + key: 'annual_income', + type: QuestionType.text, + title: 'What is your annual income?', + description: 'Please provide a rough estimate.', + ); + + const loadedState = KycFinancialDataLoadedSuccess( + allQuestions: [question], + visibleQuestions: [question], + responses: {'annual_income': '100000'}, + currentIndex: 0, + url: 'https://example.com', + ); + + setUp(() { + financialDataCubit = _MockKycFinancialDataCubit(); + kycCubit = _MockKycCubit(); + when(() => kycCubit.state).thenReturn(const KycInitial()); + }); + + Widget buildSubject() => wrapForGolden( + MultiBlocProvider( + providers: [ + BlocProvider.value(value: financialDataCubit), + BlocProvider.value(value: kycCubit), + ], + child: const KycFinancialDataView(), + ), + ); + + group('$KycFinancialDataView', () { + // KycFinancialDataSubmitFailure is a LoadedSuccess subtype: the questions + // page stays mounted (answers retained) and the View's BlocListener + // (page:46-58) surfaces the retained-answers submit error as a red SnackBar. + // Emitting it as a transition from a plain LoadedSuccess fires the listener; + // pumpAndSettle runs the SnackBar entrance to completion. + goldenTest( + 'submit failure — questions retained + red SnackBar', + fileName: 'kyc_financial_data_page_submit_failure', + constraints: phoneConstraints, + pumpBeforeTest: (tester) async { + await tester.pump(); // deliver the whenListen emission to the listener + await tester.pumpAndSettle(); // run the SnackBar entrance to completion + }, + builder: () { + whenListen( + financialDataCubit, + Stream.value( + KycFinancialDataSubmitFailure.from( + loadedState, + 'Übermittlung fehlgeschlagen. Bitte versuchen Sie es erneut.', + ), + ), + initialState: loadedState, + ); + return buildSubject(); + }, + ); + + // KycFinancialDataInitial (and KycFinancialDataSubmitSuccess) fall through to + // the `(_) => const Scaffold()` fallback branch (page:66): an intentionally + // blank scaffold shown for the brief pre-load / post-submit frames before + // checkKyc routes away. Locks that the fallback stays blank. + goldenTest( + 'initial/submit-success fallback — blank scaffold', + fileName: 'kyc_financial_data_page_fallback', + constraints: phoneConstraints, + builder: () { + when(() => financialDataCubit.state) + .thenReturn(const KycFinancialDataInitial()); + return buildSubject(); + }, + ); + }); +} diff --git a/test/goldens/screens/kyc/kyc_ident_states_golden_test.dart b/test/goldens/screens/kyc/kyc_ident_states_golden_test.dart new file mode 100644 index 000000000..2e31b1cb3 --- /dev/null +++ b/test/goldens/screens/kyc/kyc_ident_states_golden_test.dart @@ -0,0 +1,132 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/screens/kyc/cubits/kyc/kyc_cubit.dart'; +import 'package:realunit_wallet/screens/kyc/steps/ident/cubits/kyc_ident/kyc_ident_cubit.dart'; +import 'package:realunit_wallet/screens/kyc/steps/ident/kyc_ident_page.dart'; +import 'package:realunit_wallet/screens/settings/bloc/settings_bloc.dart'; + +import '../../../helper/helper.dart'; + +class _MockKycIdentCubit extends MockCubit + implements KycIdentCubit {} + +class _MockKycCubit extends MockCubit implements KycCubit {} + +void main() { + // The `kyc_ident_page_default` idle baseline lives in + // `kyc_ident_golden_test.dart`. This file covers the state-driven branches of + // `KycIdentView` (`kyc_ident_page.dart`): the loading spinner and the two + // failure surfaces (finallyRejected disables Next permanently; error/other + // shows the SnackBar over an otherwise idle body). + late _MockKycIdentCubit kycIdentCubit; + late _MockKycCubit kycCubit; + late MockSettingsBloc settingsBloc; + + setUp(() { + kycIdentCubit = _MockKycIdentCubit(); + kycCubit = _MockKycCubit(); + settingsBloc = MockSettingsBloc(); + + when(() => kycIdentCubit.state).thenReturn(const KycIdentInitial()); + when(() => kycCubit.state).thenReturn(const KycInitial()); + when(() => settingsBloc.state).thenReturn(const SettingsState()); + }); + + Widget buildSubject() => wrapForGolden( + MultiBlocProvider( + providers: [ + BlocProvider.value(value: kycIdentCubit), + BlocProvider.value(value: kycCubit), + BlocProvider.value(value: settingsBloc), + ], + child: const KycIdentView(accessToken: 'fake-token'), + ), + ); + + // The page mounts an Image.asset illustration. alchemist's precacheImages + // decodes assets inside runAsync — a plain pump cannot await that I/O — so + // replicate the runAsync precache here before freezing/settling. + Future precacheIllustration(WidgetTester tester) async { + await tester.runAsync(() async { + for (final element in find.byType(Image).evaluate()) { + await precacheImage((element.widget as Image).image, element); + } + }); + } + + // Precache, then a single pump freezes the loading button's + // CupertinoActivityIndicator on frame 0 (pumpAndSettle would time out on it). + Future precacheThenFreeze(WidgetTester tester) async { + await precacheIllustration(tester); + await tester.pump(); + } + + // Precache, deliver the whenListen emission (fires showSnackBar), then settle + // the SnackBar entrance. The failure bodies host no spinner, so settling is + // safe. + Future precacheThenSettle(WidgetTester tester) async { + await precacheIllustration(tester); + await tester.pump(); + await tester.pumpAndSettle(); + } + + group('$KycIdentView', () { + // KycIdentLoading → the Next AppFilledButton renders its loading spinner + // (page:121-122). + goldenTest( + 'ident launch in flight — next button spinner', + fileName: 'kyc_ident_page_loading', + constraints: phoneConstraints, + pumpBeforeTest: precacheThenFreeze, + builder: () { + when(() => kycIdentCubit.state).thenReturn(const KycIdentLoading()); + return buildSubject(); + }, + ); + + // KycIdentFailure(finallyRejected) → the Next button is permanently disabled + // (page:113-119) and the red `identityCheckFinallyFailed` SnackBar fires + // (page:47-56). + goldenTest( + 'finally rejected — disabled next + red snackbar', + fileName: 'kyc_ident_page_finally_rejected', + constraints: phoneConstraints, + pumpBeforeTest: precacheThenSettle, + builder: () { + whenListen( + kycIdentCubit, + Stream.value( + const KycIdentFailure(status: FailureStatus.finallyRejected), + ), + initialState: const KycIdentInitial(), + ); + return buildSubject(); + }, + ); + + // KycIdentFailure(error) → the red `identityCheckFailed` SnackBar fires + // (page:57-65) while the body stays on the idle Next button (page:121-130). + goldenTest( + 'error — red snackbar over the idle body', + fileName: 'kyc_ident_page_error', + constraints: phoneConstraints, + pumpBeforeTest: precacheThenSettle, + builder: () { + whenListen( + kycIdentCubit, + Stream.value( + const KycIdentFailure( + status: FailureStatus.error, + errorMessage: 'network unreachable', + ), + ), + initialState: const KycIdentInitial(), + ); + return buildSubject(); + }, + ); + }); +} diff --git a/test/goldens/screens/kyc/kyc_link_wallet_states_golden_test.dart b/test/goldens/screens/kyc/kyc_link_wallet_states_golden_test.dart new file mode 100644 index 000000000..9d1e4032e --- /dev/null +++ b/test/goldens/screens/kyc/kyc_link_wallet_states_golden_test.dart @@ -0,0 +1,153 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/packages/service/app_store.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/registration/kyc/kyc_personal_data.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/user/dto/real_unit_user_data_dto.dart'; +import 'package:realunit_wallet/packages/wallet/wallet.dart'; +import 'package:realunit_wallet/screens/kyc/cubits/kyc/kyc_cubit.dart'; +import 'package:realunit_wallet/screens/kyc/steps/link_wallet/cubits/kyc_link_wallet_cubit.dart'; +import 'package:realunit_wallet/screens/kyc/steps/link_wallet/kyc_link_wallet_page.dart'; + +import '../../../helper/helper.dart'; + +class _MockKycLinkWalletCubit extends MockCubit + implements KycLinkWalletCubit {} + +class _MockKycCubit extends MockCubit implements KycCubit {} + +class _MockAppStore extends Mock implements AppStore {} + +const _kycData = KycPersonalData( + accountType: KycAccountType.personal, + firstName: 'Ada', + lastName: 'Lovelace', + phone: '+41790000000', + address: KycAddress(street: 'S', zip: '8000', city: 'Zurich', country: 41), +); + +const _userData = RealUnitUserDataDto( + email: 'ada@example.com', + name: 'Ada Lovelace', + type: 'HUMAN', + phoneNumber: '+41790000000', + birthday: '1815-12-10', + nationality: 'CH', + addressStreet: 'S', + addressPostalCode: '8000', + addressCity: 'Zurich', + addressCountry: 'CH', + swissTaxResidence: true, + lang: 'de', + kycData: _kycData, +); + +const _debugAddress = '0xfaeefaeefaeefaeefaeefaeefaeefaeefaeeb6a0'; + +void main() { + // The `kyc_link_wallet_page_default` (ready) and `_bitbox_required` baselines + // live in `kyc_link_wallet_golden_test.dart`. This file covers the remaining + // BlocConsumer branches of `KycLinkWalletView` plus the defensive + // missing-userData page (`kyc_link_wallet_page.dart`). + late _MockKycLinkWalletCubit linkCubit; + late _MockKycCubit kycCubit; + + setUpAll(() { + final appStore = _MockAppStore(); + when(() => appStore.wallet) + .thenReturn(DebugWallet(1, 'Test', _debugAddress)); + GetIt.instance.registerSingleton(appStore); + }); + + tearDownAll(() async => GetIt.instance.reset()); + + setUp(() { + linkCubit = _MockKycLinkWalletCubit(); + kycCubit = _MockKycCubit(); + when(() => kycCubit.state).thenReturn(const KycInitial()); + }); + + Widget buildView() => wrapForGolden( + MultiBlocProvider( + providers: [ + BlocProvider.value(value: kycCubit), + BlocProvider.value(value: linkCubit), + ], + child: const KycLinkWalletView(), + ), + ); + + group('$KycLinkWalletView', () { + // KycLinkWalletSubmitting → _LinkWalletBody(isSubmitting: true): the submit + // AppFilledButton renders its loading spinner (page:139-145). Freeze it. + goldenTest( + 'submitting — submit button spinner', + fileName: 'kyc_link_wallet_page_submitting', + constraints: phoneConstraints, + pumpBeforeTest: pumpOnce, + builder: () { + when(() => linkCubit.state) + .thenReturn(const KycLinkWalletSubmitting(_userData)); + return buildView(); + }, + ); + + // KycLinkWalletSuccess → the transient centered spinner the builder renders + // while the parent KycCubit re-routes (page:107). Static state, so the + // listener's checkKyc() never fires; freeze the spinner. + goldenTest( + 'success — transient centered spinner', + fileName: 'kyc_link_wallet_page_success', + constraints: phoneConstraints, + pumpBeforeTest: pumpOnce, + builder: () { + when(() => linkCubit.state).thenReturn(const KycLinkWalletSuccess()); + return buildView(); + }, + ); + + // KycLinkWalletFailure → the catch-all builder branch shows the centered + // spinner (page:108) while the listener fires the red `registrationFailed` + // SnackBar (page:59-66). Spinner + SnackBar cannot pumpAndSettle, so deliver + // the emission then advance a fixed 500ms — well past the SnackBar entrance + // — freezing the spinner at a deterministic frame. + goldenTest( + 'failure — centered spinner + red snackbar', + fileName: 'kyc_link_wallet_page_failure', + constraints: phoneConstraints, + pumpBeforeTest: (tester) async { + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + }, + builder: () { + whenListen( + linkCubit, + Stream.value( + const KycLinkWalletFailure('network error'), + ), + initialState: const KycLinkWalletSubmitting(_userData), + ); + return buildView(); + }, + ); + + // userData == null → KycLinkWalletPage short-circuits to the defensive + // `_LinkWalletMissingUserDataPage` (kycFailure copy + Refresh button, + // page:180-210). No cubit is created, so drive the page directly with only + // the parent KycCubit in scope for the Refresh handler. + goldenTest( + 'missing user data — defensive refresh page', + fileName: 'kyc_link_wallet_page_missing_user_data', + constraints: phoneConstraints, + builder: () => wrapForGolden( + BlocProvider.value( + value: kycCubit, + child: const KycLinkWalletPage(userData: null), + ), + ), + ); + }); +} diff --git a/test/goldens/screens/kyc/kyc_manual_review_golden_test.dart b/test/goldens/screens/kyc/kyc_manual_review_golden_test.dart new file mode 100644 index 000000000..a405422d9 --- /dev/null +++ b/test/goldens/screens/kyc/kyc_manual_review_golden_test.dart @@ -0,0 +1,34 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/screens/kyc/cubits/kyc/kyc_cubit.dart'; +import 'package:realunit_wallet/screens/kyc/subpages/kyc_manual_review_page.dart'; + +import '../../../helper/helper.dart'; + +class _MockKycCubit extends MockCubit implements KycCubit {} + +void main() { + + late _MockKycCubit kycCubit; + + setUp(() { + kycCubit = _MockKycCubit(); + when(() => kycCubit.state).thenReturn(const KycInitial()); + }); + + group('$KycManualReviewPage', () { + goldenTest( + 'default state', + fileName: 'kyc_manual_review_page_default', + constraints: phoneConstraints, + builder: () => wrapForGolden( + BlocProvider.value( + value: kycCubit, + child: const KycManualReviewPage(), + ), + ), + ); + }); +} diff --git a/test/goldens/screens/kyc/kyc_nationality_states_golden_test.dart b/test/goldens/screens/kyc/kyc_nationality_states_golden_test.dart new file mode 100644 index 000000000..118e675a0 --- /dev/null +++ b/test/goldens/screens/kyc/kyc_nationality_states_golden_test.dart @@ -0,0 +1,182 @@ +import 'dart:async'; + +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_country_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/country/country.dart'; +import 'package:realunit_wallet/screens/kyc/cubits/kyc/kyc_cubit.dart'; +import 'package:realunit_wallet/screens/kyc/steps/nationality/cubit/kyc_nationality/kyc_nationality_cubit.dart'; +import 'package:realunit_wallet/screens/kyc/steps/nationality/kyc_nationality_page.dart'; +import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart'; + +import '../../../helper/helper.dart'; + +class _MockKycNationalityCubit extends MockCubit + implements KycNationalityCubit {} + +class _MockKycCubit extends MockCubit implements KycCubit {} + +void main() { + // The `kyc_nationality_page_default` idle baseline (loaded, nothing picked) + // lives in `kyc_nationality_golden_test.dart`. This file covers the + // state-driven branches of `KycNationalityView` (submit spinner, submit-failed + // SnackBar) and the three CountryField surfaces the default golden can't show + // (in-field spinner, load error, open dropdown), plus the empty-selection + // validation border. + late _MockKycNationalityCubit kycNationalityCubit; + late _MockKycCubit kycCubit; + + setUp(() { + kycNationalityCubit = _MockKycNationalityCubit(); + kycCubit = _MockKycCubit(); + when(() => kycNationalityCubit.state) + .thenReturn(const KycNationalityInitial()); + when(() => kycCubit.state).thenReturn(const KycInitial()); + }); + + tearDown(() async => GetIt.instance.reset()); + + // Each state needs its own DfxCountryService: the service caches the country + // list on the instance, so a shared one would defeat the loading/error + // branches. Register fresh per test; tearDown resets getIt. The country data + // itself is never mocktail-stubbed — it flows through the real service over a + // MockClient HTTP seam (see docs/testing.md "Country data in tests"). + void useCountryService(DfxCountryService service) { + final getIt = GetIt.instance; + if (getIt.isRegistered()) { + getIt.unregister(); + } + getIt.registerSingleton(service); + } + + Widget buildSubject() => wrapForGolden( + MultiBlocProvider( + providers: [ + BlocProvider.value(value: kycNationalityCubit), + BlocProvider.value(value: kycCubit), + ], + child: const KycNationalityView(url: 'https://example.com'), + ), + ); + + // Tap the closed country field so the dropdown menu route opens (CH/DE/IT/FR + // are floated to the top by CountryField's priority sort). Mirror of the + // registration-step goldens. + Future openCountryDropdown(WidgetTester tester) async { + await tester.pumpAndSettle(); + await tester.tap(find.byType(DropdownButtonFormField)); + await tester.pumpAndSettle(); + } + + group('$KycNationalityView', () { + // KycNationalityLoading → the Next AppFilledButton spins (page:80-81). Let + // the fixture country future resolve into the dropdown first, then freeze + // the button spinner on a fixed frame (pumpAndSettle would never end). + goldenTest( + 'submit in flight — next button spinner', + fileName: 'kyc_nationality_page_submit_loading', + constraints: phoneConstraints, + pumpBeforeTest: (tester) async { + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + }, + builder: () { + useCountryService(fixtureCountryService()); + when(() => kycNationalityCubit.state) + .thenReturn(const KycNationalityLoading()); + return buildSubject(); + }, + ); + + // KycNationalityFailure → red `setNationalityFailed` SnackBar (page:51-58) + // over the loaded, idle body. + goldenTest( + 'submit failure — red snackbar', + fileName: 'kyc_nationality_page_submit_failure', + constraints: phoneConstraints, + pumpBeforeTest: (tester) async { + await tester.pump(); + await tester.pumpAndSettle(); + }, + builder: () { + useCountryService(fixtureCountryService()); + whenListen( + kycNationalityCubit, + Stream.value( + const KycNationalityFailure('network error'), + ), + initialState: const KycNationalityInitial(), + ); + return buildSubject(); + }, + ); + + // CountryField waiting on the country list → the in-field + // CupertinoActivityIndicator (country_field.dart:59-68). A never-completing + // MockClient keeps the FutureBuilder in ConnectionState.waiting; a single + // pump freezes the spinner. + goldenTest( + 'country field loading — in-field spinner', + fileName: 'kyc_nationality_page_country_loading', + constraints: phoneConstraints, + pumpBeforeTest: pumpOnce, + builder: () { + useCountryService( + countryServiceWithClient( + MockClient((_) => Completer().future), + ), + ); + return buildSubject(); + }, + ); + + // CountryField load failure → the red `countriesLoadFailed` text + Retry + // button, dropdown hidden (country_field.dart:70-88). + goldenTest( + 'country field load error — red text + retry', + fileName: 'kyc_nationality_page_country_error', + constraints: phoneConstraints, + builder: () { + useCountryService(failingCountryService()); + return buildSubject(); + }, + ); + + // The open dropdown menu route — the selectable country list overlay. + goldenTest( + 'country dropdown open — the selectable country list', + fileName: 'kyc_nationality_page_dropdown_open', + constraints: phoneConstraints, + pumpBeforeTest: openCountryDropdown, + builder: () { + useCountryService(fixtureCountryService()); + return buildSubject(); + }, + ); + + // Tapping Next with nothing picked runs Form.validate(); the country field's + // DropdownField validator returns '' → red border, no message text + // (country_field.dart:108, _StatusField:169-184). Mirror of the tax-step + // country_error golden. + goldenTest( + 'validation error — empty selection red border', + fileName: 'kyc_nationality_page_validation_error', + constraints: phoneConstraints, + pumpBeforeTest: (tester) async { + await tester.pumpAndSettle(); + await tester.tap(find.byType(AppFilledButton)); + await tester.pumpAndSettle(); + }, + builder: () { + useCountryService(fixtureCountryService()); + return buildSubject(); + }, + ); + }); +} diff --git a/test/goldens/screens/kyc/kyc_registration_address_step_states_golden_test.dart b/test/goldens/screens/kyc/kyc_registration_address_step_states_golden_test.dart new file mode 100644 index 000000000..f1f55d273 --- /dev/null +++ b/test/goldens/screens/kyc/kyc_registration_address_step_states_golden_test.dart @@ -0,0 +1,75 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_country_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/country/country.dart'; +import 'package:realunit_wallet/screens/kyc/steps/registration/cubits/registration_step/kyc_registration_step_cubit.dart'; +import 'package:realunit_wallet/screens/kyc/steps/registration/steps/kyc_registration_address_step.dart'; +import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart'; + +import '../../../helper/helper.dart'; + +class _MockKycRegistrationStepCubit extends MockCubit + implements KycRegistrationStepCubit {} + +void main() { + late _MockKycRegistrationStepCubit stepCubit; + + setUpAll(() { + GetIt.instance.registerSingleton(fixtureCountryService()); + }); + + tearDownAll(() async => GetIt.instance.reset()); + + setUp(() { + stepCubit = _MockKycRegistrationStepCubit(); + when(() => stepCubit.state).thenReturn( + const KycRegistrationStepState( + step: KycRegistrationStep.address, + steps: [ + KycRegistrationStep.personal, + KycRegistrationStep.address, + KycRegistrationStep.taxResidence, + ], + ), + ); + }); + + Widget buildSubject() => wrapForGolden( + BlocProvider.value( + value: stepCubit, + child: Scaffold( + body: KycRegistrationAddressStep( + addressStreetCtrl: TextEditingController(), + addressNumberCtrl: TextEditingController(), + postalCodeCtrl: TextEditingController(), + cityCtrl: TextEditingController(), + countryCtrl: ValueNotifier(null), + ), + ), + ), + ); + + group('$KycRegistrationAddressStep', () { + goldenTest( + 'validation error after Next — red error borders on every field + country', + fileName: 'kyc_registration_address_step_validation_error', + constraints: phoneConstraints, + // Tapping 'Next' with empty controllers runs Form.validate(): every text + // field returns the empty sentinel and the residence CountryField reports + // its "no country picked" error, so all four inputs plus the country + // dropdown flip to red (hideErrorText keeps them frame-only). + pumpBeforeTest: (tester) async { + await tester.pumpAndSettle(); + final next = find.byType(AppFilledButton); + await tester.ensureVisible(next); + await tester.tap(next); + await tester.pumpAndSettle(); + }, + builder: buildSubject, + ); + }); +} diff --git a/test/goldens/screens/kyc/kyc_registration_page_states_golden_test.dart b/test/goldens/screens/kyc/kyc_registration_page_states_golden_test.dart new file mode 100644 index 000000000..8a86c49c7 --- /dev/null +++ b/test/goldens/screens/kyc/kyc_registration_page_states_golden_test.dart @@ -0,0 +1,349 @@ +import 'package:bitbox_flutter/bitbox_flutter.dart' as sdk; +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/packages/hardware_wallet/bitbox.dart'; +import 'package:realunit_wallet/packages/service/app_store.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_country_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_kyc_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/registration_rejected_exception.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/country/country.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/registration/kyc/kyc_personal_data.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/registration/registration.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/registration/registration_status.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/registration/registration_user_type.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/user/dto/real_unit_user_data_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_registration_service.dart'; +import 'package:realunit_wallet/packages/service/wallet_service.dart'; +import 'package:realunit_wallet/packages/wallet/exceptions/signing_cancelled_exception.dart'; +import 'package:realunit_wallet/packages/wallet/wallet.dart'; +import 'package:realunit_wallet/screens/home/bloc/home_bloc.dart'; +import 'package:realunit_wallet/screens/kyc/cubits/kyc/kyc_cubit.dart'; +import 'package:realunit_wallet/screens/kyc/steps/registration/cubits/registration_step/kyc_registration_step_cubit.dart'; +import 'package:realunit_wallet/screens/kyc/steps/registration/cubits/registration_submit/kyc_registration_submit_cubit.dart'; +import 'package:realunit_wallet/screens/kyc/steps/registration/kyc_registration_page.dart'; + +import '../../../helper/helper.dart'; + +class _MockRegistrationStepCubit extends MockCubit + implements KycRegistrationStepCubit {} + +class _MockRegistrationSubmitCubit extends MockCubit + implements KycRegistrationSubmitCubit {} + +class _MockKycCubit extends MockCubit implements KycCubit {} + +class _MockHomeBloc extends MockBloc implements HomeBloc {} + +class _MockAppStore extends Mock implements AppStore {} + +class _MockRealUnitRegistrationService extends Mock + implements RealUnitRegistrationService {} + +class _MockDfxKycService extends Mock implements DfxKycService {} + +class _MockWalletService extends Mock implements WalletService {} + +class _MockBitboxService extends Mock implements BitboxService {} + +class _MockAWallet extends Mock implements AWallet {} + +const _country = Country(id: 41, symbol: 'CH', name: 'Switzerland', kycAllowed: true); + +// Mirrors the placeholder fixture from +// test/screens/kyc/steps/kyc_registration_page_test.dart (Ada Lovelace, never +// customer data), but with the birthday year bumped to 1990: unlike that widget +// test, this golden actually renders the birthday dropdowns, whose year list is +// only `now().year … now().year-140` (birthday_field.dart). Ada's historical 1815 +// falls outside that window and would render an empty year field; 1990 sits inside +// it for any plausible run date, so the closed dropdown shows the static selected +// value (not the `now()`-derived list) and the render stays byte-stable. +const _kycPersonalData = KycPersonalData( + accountType: KycAccountType.personal, + firstName: 'Ada', + lastName: 'Lovelace', + phone: '+41790000000', + address: KycAddress(street: 'S', zip: '8000', city: 'Zurich', country: 41), +); + +const _fixtureUserData = RealUnitUserDataDto( + email: 'ada@example.com', + name: 'Ada Lovelace', + type: 'HUMAN', + phoneNumber: '+41790000000', + birthday: '1990-12-10', + nationality: 'CH', + addressStreet: 'S', + addressPostalCode: '8000', + addressCity: 'Zurich', + addressCountry: 'CH', + swissTaxResidence: true, + lang: 'de', + kycData: _kycPersonalData, +); + +const _registrationFixture = Registration( + type: RegistrationUserType.human, + email: 'ada@example.com', + firstName: 'Ada', + lastName: 'Lovelace', + phoneNumber: '+41790000000', + birthday: '1990-12-10', + nationality: _country, + addressStreet: 'S', + addressStreetNumber: '1', + addressPostalCode: '8000', + addressCity: 'Zurich', + addressCountry: _country, + swissTaxResidence: true, +); + +void main() { + late _MockRegistrationStepCubit registrationStepCubit; + late _MockRegistrationSubmitCubit registrationSubmitCubit; + late _MockKycCubit kycCubit; + late _MockHomeBloc homeBloc; + + const personalState = KycRegistrationStepState( + step: KycRegistrationStep.personal, + steps: [ + KycRegistrationStep.personal, + KycRegistrationStep.address, + KycRegistrationStep.taxResidence, + ], + ); + + const addressState = KycRegistrationStepState( + step: KycRegistrationStep.address, + steps: [ + KycRegistrationStep.personal, + KycRegistrationStep.address, + KycRegistrationStep.taxResidence, + ], + ); + + const taxState = KycRegistrationStepState( + step: KycRegistrationStep.taxResidence, + steps: [ + KycRegistrationStep.personal, + KycRegistrationStep.address, + KycRegistrationStep.taxResidence, + ], + ); + + setUp(() { + registrationStepCubit = _MockRegistrationStepCubit(); + registrationSubmitCubit = _MockRegistrationSubmitCubit(); + kycCubit = _MockKycCubit(); + homeBloc = _MockHomeBloc(); + + when(() => registrationStepCubit.state).thenReturn(personalState); + when(() => registrationSubmitCubit.state) + .thenReturn(KycRegistrationSubmitInitial()); + when(() => kycCubit.state).thenReturn(const KycInitial()); + when(() => kycCubit.checkKyc()).thenAnswer((_) => Future.value()); + }); + + // Full DI so every state renders: the prefill state constructs a real + // `KycRegistrationPage` (which looks up the registration/country/kyc services + // via getIt), and the BitBox-required state opens the real `ConnectBitboxPage` + // (which resolves a Bitbox/Wallet service on build). The country service is the + // fixture-backed real service so the citizenship/residence dropdowns populate. + setUpAll(() { + final getIt = GetIt.instance; + getIt.registerSingleton( + _MockRealUnitRegistrationService(), + ); + getIt.registerSingleton(fixtureCountryService()); + getIt.registerSingleton(_MockDfxKycService()); + final appStore = _MockAppStore(); + when(() => appStore.wallet).thenReturn(_MockAWallet()); + getIt.registerSingleton(appStore); + final bitboxService = _MockBitboxService(); + when(() => bitboxService.startScan()).thenAnswer((_) async => false); + when(() => bitboxService.getAllUsbDevices()) + .thenAnswer((_) async => []); + getIt.registerSingleton(bitboxService); + getIt.registerSingleton(_MockWalletService()); + }); + + tearDownAll(() async => GetIt.instance.reset()); + + Widget buildView(Widget child) => wrapForGolden( + MultiBlocProvider( + providers: [ + BlocProvider.value(value: kycCubit), + BlocProvider.value( + value: registrationStepCubit, + ), + BlocProvider.value( + value: registrationSubmitCubit, + ), + BlocProvider.value(value: homeBloc), + ], + child: child, + ), + ); + + // Land the PageView on the step the (mocked) step cubit reports. The mocked + // cubit emits nothing, so `initState`'s stream subscription never animates the + // pager — jumping it here is the seam the widget test uses too + // (kyc_registration_page_test.dart:208). + Future jumpToActiveStep(WidgetTester tester) async { + await tester.pumpAndSettle(); + (tester.widget(find.byType(PageView)) as PageView) + .controller + ?.jumpToPage(registrationStepCubit.state.index); + await tester.pumpAndSettle(); + } + + group('$KycRegistrationView', () { + goldenTest( + 'address step active — AppBar "Residence", pager on page 2', + fileName: 'kyc_registration_page_address_step', + constraints: phoneConstraints, + pumpBeforeTest: jumpToActiveStep, + builder: () { + when(() => registrationStepCubit.state).thenReturn(addressState); + return buildView(const KycRegistrationView()); + }, + ); + + goldenTest( + 'tax residence step active — AppBar "Tax residence", pager on page 3', + fileName: 'kyc_registration_page_tax_step', + constraints: phoneConstraints, + pumpBeforeTest: jumpToActiveStep, + builder: () { + when(() => registrationStepCubit.state).thenReturn(taxState); + return buildView(const KycRegistrationView()); + }, + ); + + goldenTest( + 'prefilled form — initialUserData seeds the personal step', + fileName: 'kyc_registration_page_prefilled', + constraints: phoneConstraints, + // Real `KycRegistrationPage`: the scalar fields seed synchronously in + // initState and the two country symbols resolve through the + // fixture-backed DfxCountryService; the default `precacheImages` + // pump-and-settle lets both country lookups complete before capture. + builder: () => wrapForGolden( + const KycRegistrationPage(initialUserData: _fixtureUserData), + ), + ); + + goldenTest( + 'submit loading overlay — white full-bleed overlay + spinner over the pager', + fileName: 'kyc_registration_page_submit_loading', + constraints: phoneConstraints, + // CupertinoActivityIndicator animates indefinitely; a single pump captures + // the overlay at its first frame (pumpAndSettle would time out). + pumpBeforeTest: pumpOnce, + builder: () { + when(() => registrationSubmitCubit.state) + .thenReturn(KycRegistrationSubmitLoading()); + return buildView(const KycRegistrationView()); + }, + ); + + goldenTest( + 'submit failure SnackBar — signingCancelled (red)', + fileName: 'kyc_registration_page_submit_failure_snackbar', + constraints: phoneConstraints, + pumpBeforeTest: (tester) async { + await tester.pump(); // deliver the whenListen emission to the listener + await tester.pumpAndSettle(); // run the SnackBar entrance to completion + }, + builder: () { + whenListen( + registrationSubmitCubit, + Stream.fromIterable([ + const KycRegistrationSubmitFailure( + 'registration rejected', + cause: SigningCancelledException(), + ), + ]), + initialState: KycRegistrationSubmitInitial(), + ); + return buildView(const KycRegistrationView()); + }, + ); + + goldenTest( + 'submit rejected SnackBar — structured 4xx with server reason', + fileName: 'kyc_registration_page_submit_rejected_snackbar', + constraints: phoneConstraints, + pumpBeforeTest: (tester) async { + await tester.pump(); // deliver the whenListen emission to the listener + await tester.pumpAndSettle(); // run the SnackBar entrance to completion + }, + builder: () { + whenListen( + registrationSubmitCubit, + Stream.fromIterable([ + const KycRegistrationSubmitFailure( + 'registration rejected', + cause: RegistrationRejectedException( + statusCode: 400, + code: 'UNKNOWN', + message: 'Registration date must be today', + ), + ), + ]), + initialState: KycRegistrationSubmitInitial(), + ); + return buildView(const KycRegistrationView()); + }, + ); + + goldenTest( + 'forwarding-failed SnackBar — shown after a successful submit', + fileName: 'kyc_registration_page_forwarding_failed_snackbar', + constraints: phoneConstraints, + pumpBeforeTest: (tester) async { + await tester.pump(); // deliver the Success(forwardingFailed) emission + await tester.pumpAndSettle(); // run the SnackBar entrance to completion + }, + builder: () { + whenListen( + registrationSubmitCubit, + Stream.fromIterable([ + const KycRegistrationSubmitSuccess(RegistrationStatus.forwardingFailed), + ]), + initialState: KycRegistrationSubmitInitial(), + ); + return buildView(const KycRegistrationView()); + }, + ); + + goldenTest( + 'BitBox-required — the modal ConnectBitbox sheet opened over the pager', + fileName: 'kyc_registration_page_bitbox_required', + constraints: phoneConstraints, + // The BitboxRequired emission drives the listener's showModalBottomSheet; + // pumpAndSettle opens the sheet and loads its SVG. The real + // ConnectBitboxCubit stays in BitboxNotConnected because getAllUsbDevices + // is stubbed empty, so the rendered pixels are stable across regens. + pumpBeforeTest: (tester) async { + await tester.pump(); // deliver the BitboxRequired emission + await tester.pumpAndSettle(); // open + settle the modal sheet + }, + builder: () { + whenListen( + registrationSubmitCubit, + Stream.fromIterable([ + const KycRegistrationSubmitBitboxRequired( + registration: _registrationFixture, + ), + ]), + initialState: KycRegistrationSubmitInitial(), + ); + return buildView(const KycRegistrationView()); + }, + ); + }); +} diff --git a/test/goldens/screens/kyc/kyc_registration_personal_step_states_golden_test.dart b/test/goldens/screens/kyc/kyc_registration_personal_step_states_golden_test.dart new file mode 100644 index 000000000..69c380b6d --- /dev/null +++ b/test/goldens/screens/kyc/kyc_registration_personal_step_states_golden_test.dart @@ -0,0 +1,116 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_country_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/country/country.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/registration/registration_user_type.dart'; +import 'package:realunit_wallet/screens/kyc/steps/registration/cubits/registration_step/kyc_registration_step_cubit.dart'; +import 'package:realunit_wallet/screens/kyc/steps/registration/steps/kyc_registration_personal_step.dart'; +import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart'; + +import '../../../helper/helper.dart'; + +class _MockKycRegistrationStepCubit extends MockCubit + implements KycRegistrationStepCubit {} + +void main() { + late _MockKycRegistrationStepCubit stepCubit; + + setUpAll(() { + GetIt.instance.registerSingleton(fixtureCountryService()); + }); + + tearDownAll(() async => GetIt.instance.reset()); + + setUp(() { + stepCubit = _MockKycRegistrationStepCubit(); + when(() => stepCubit.state).thenReturn( + const KycRegistrationStepState( + step: KycRegistrationStep.personal, + steps: [ + KycRegistrationStep.personal, + KycRegistrationStep.address, + ], + ), + ); + }); + + // Empty controllers so `_formKey.currentState.validate()` fails on every + // required field once 'Next' is tapped (empty first/last name, no birthday, no + // nationality). The account-type dropdown pre-selects `human` so it stays + // valid; the phone prefix defaults to '+41'. + Widget buildSubject() => wrapForGolden( + BlocProvider.value( + value: stepCubit, + child: Scaffold( + body: KycRegistrationPersonalStep( + typeCtrl: ValueNotifier( + RegistrationUserType.human, + ), + firstNameCtrl: TextEditingController(), + lastNameCtrl: TextEditingController(), + phoneCtrl: ValueNotifier(null), + nationalityCtrl: ValueNotifier(null), + birthdayCtrl: ValueNotifier(null), + ), + ), + ), + ); + + group('$KycRegistrationPersonalStep', () { + goldenTest( + 'validation error after Next — red error borders on the empty fields', + fileName: 'kyc_registration_personal_step_validation_error', + constraints: phoneConstraints, + // The 'Next' tap runs Form.validate(); the empty first/last-name, birthday + // and nationality validators return the empty sentinel, flipping their + // borders red. hideErrorText suppresses the message on all but the phone + // number field (phone_number_field.dart:89), so most fields show a bare + // red frame. + pumpBeforeTest: (tester) async { + await tester.pumpAndSettle(); + final next = find.byType(AppFilledButton); + await tester.ensureVisible(next); + await tester.tap(next); + await tester.pumpAndSettle(); + }, + builder: buildSubject, + ); + + goldenTest( + 'account-type dropdown open — the single-item overlay menu', + fileName: 'kyc_registration_personal_step_account_type_open', + constraints: phoneConstraints, + pumpBeforeTest: (tester) async { + await tester.pumpAndSettle(); + final field = find.byType(DropdownButtonFormField); + await tester.ensureVisible(field); + await tester.tap(field); + await tester.pumpAndSettle(); + }, + builder: buildSubject, + ); + + goldenTest( + 'phone-prefix dropdown open — the +41 / +49 overlay menu', + fileName: 'kyc_registration_personal_step_phone_prefix_open', + constraints: phoneConstraints, + pumpBeforeTest: (tester) async { + await tester.pumpAndSettle(); + // The prefix dropdown renders its selected value '+41'; the birthday + // String dropdowns show day/month/year hints instead, so this is unique. + final field = find.widgetWithText( + DropdownButtonFormField, + '+41', + ); + await tester.ensureVisible(field); + await tester.tap(field); + await tester.pumpAndSettle(); + }, + builder: buildSubject, + ); + }); +} diff --git a/test/goldens/screens/kyc/kyc_registration_tax_step_golden_test.dart b/test/goldens/screens/kyc/kyc_registration_tax_step_golden_test.dart index 952ccfb52..bb80c7632 100644 --- a/test/goldens/screens/kyc/kyc_registration_tax_step_golden_test.dart +++ b/test/goldens/screens/kyc/kyc_registration_tax_step_golden_test.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:get_it/get_it.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_country_service.dart'; import 'package:realunit_wallet/packages/service/dfx/models/country/country.dart'; import 'package:realunit_wallet/screens/kyc/steps/registration/steps/kyc_registration_tax_step.dart'; @@ -8,6 +9,12 @@ import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart'; import '../../../helper/helper.dart'; +const _switzerland = Country(id: 41, symbol: 'CH', name: 'Switzerland', kycAllowed: true); +const _germany = Country(id: 55, symbol: 'DE', name: 'Germany', kycAllowed: true); + +/// Tall phone for multi-row tax scenarios so every row is fully visible. +const tallPhoneConstraints = BoxConstraints.tightFor(width: 390, height: 1200); + void main() { setUpAll(() { GetIt.instance.registerSingleton(fixtureCountryService()); @@ -15,42 +22,76 @@ void main() { tearDownAll(() async => GetIt.instance.reset()); - Widget buildSubject() => wrapForGolden( + Widget buildSubject({Country? residenceCountry}) => wrapForGolden( Scaffold( body: KycRegistrationTaxStep( - taxCountryCtrl: ValueNotifier(null), - tinCtrl: TextEditingController(), - onSubmit: () async {}, + residenceCountry: residenceCountry, + initialTaxResidences: const [], + onSubmit: (_) async {}, ), ), ); - // Tap the closed country field so the dropdown menu route opens and the - // selectable country list is on screen (CH/DE/IT/FR are floated to the top - // by CountryField's priority sort). + Finder pageScrollable() => find + .descendant( + of: find.byType(KycRegistrationTaxStep), + matching: find.byType(Scrollable), + ) + .first; + Future openCountryDropdown(WidgetTester tester) async { await tester.pumpAndSettle(); - await tester.tap(find.byType(DropdownButtonFormField)); + await tester.tap(find.byType(DropdownButtonFormField).last); await tester.pumpAndSettle(); } - Future selectCountry(WidgetTester tester, String name) async { + Future selectFreeCountry(WidgetTester tester, String name) async { await openCountryDropdown(tester); + final nameFinder = find.text(name); + if (nameFinder.evaluate().isEmpty || find.text(name).hitTestable().evaluate().isEmpty) { + await tester.dragUntilVisible( + nameFinder, + find.byType(Scrollable).last, + const Offset(0, -300), + maxIteration: 120, + ); + await tester.pumpAndSettle(); + } await tester.tap(find.text(name).last); await tester.pumpAndSettle(); } Future tapComplete(WidgetTester tester) async { - await tester.tap(find.byType(AppFilledButton)); + final button = find.byType(AppFilledButton); + await tester.scrollUntilVisible(button, 100, scrollable: pageScrollable()); + await tester.tap(button); + await tester.pumpAndSettle(); + } + + Future addTaxResidence(WidgetTester tester) async { + final context = tester.element(find.byType(KycRegistrationTaxStep)); + final addButton = find.text(S.of(context).addTaxResidence); + await tester.scrollUntilVisible(addButton, 100, scrollable: pageScrollable()); + await tester.tap(addButton); await tester.pumpAndSettle(); } + Future enterTinAt(WidgetTester tester, int index, String value) async { + final context = tester.element(find.byType(KycRegistrationTaxStep)); + final tinFields = find.widgetWithText(TextFormField, S.of(context).tinHint); + final field = tinFields.at(index); + await tester.scrollUntilVisible(field, 100, scrollable: pageScrollable()); + await tester.enterText(field, value); + await tester.pump(); + } + group('$KycRegistrationTaxStep', () { + // Empty fallback: no residence country yet (address not filled). goldenTest( 'empty — nothing picked, no TIN', fileName: 'kyc_registration_tax_step_default', constraints: phoneConstraints, - builder: buildSubject, + builder: () => buildSubject(), ); goldenTest( @@ -58,45 +99,179 @@ void main() { fileName: 'kyc_registration_tax_step_dropdown_open', constraints: phoneConstraints, pumpBeforeTest: openCountryDropdown, - builder: buildSubject, + builder: () => buildSubject(), ); goldenTest( - 'Swiss tax residence — no TIN', + 'Swiss tax residence locked from address — no TIN', fileName: 'kyc_registration_tax_step_swiss', constraints: phoneConstraints, - pumpBeforeTest: (tester) => selectCountry(tester, 'Switzerland'), - builder: buildSubject, + builder: () => buildSubject(residenceCountry: _switzerland), ); goldenTest( - 'non-Swiss tax residence — TIN revealed', + 'non-Swiss tax residence locked from address — TIN revealed', fileName: 'kyc_registration_tax_step_non_swiss', constraints: phoneConstraints, - pumpBeforeTest: (tester) => selectCountry(tester, 'Germany'), - builder: buildSubject, + builder: () => buildSubject(residenceCountry: _germany), ); goldenTest( - 'validation error — no tax-residence country picked', + 'validation error — no tax-residence country picked (empty fallback)', fileName: 'kyc_registration_tax_step_country_error', constraints: phoneConstraints, pumpBeforeTest: (tester) async { await tester.pumpAndSettle(); await tapComplete(tester); }, - builder: buildSubject, + builder: () => buildSubject(), ); goldenTest( - 'validation error — non-Swiss residence with an empty TIN', + 'validation error — locked non-Swiss residence with an empty TIN', fileName: 'kyc_registration_tax_step_tin_error', constraints: phoneConstraints, pumpBeforeTest: (tester) async { - await selectCountry(tester, 'Germany'); + await tester.pumpAndSettle(); + await tapComplete(tester); + }, + builder: () => buildSubject(residenceCountry: _germany), + ); + + goldenTest( + 'multi tax residence — locked DE plus free second country', + fileName: 'kyc_registration_tax_step_multi', + constraints: phoneConstraints, + pumpBeforeTest: (tester) async { + await tester.pumpAndSettle(); + await addTaxResidence(tester); + await selectFreeCountry(tester, 'Switzerland'); + }, + builder: () => buildSubject(residenceCountry: _germany), + ); + }); + + // --------------------------------------------------------------------------- + // Customer scenarios S1–S5 (locked address country + additional tax rows) + // --------------------------------------------------------------------------- + group('$KycRegistrationTaxStep customer scenarios S1–S5', () { + // S1 — CH only, ready to complete + goldenTest( + 'S1 CH only — locked Switzerland ready to complete', + fileName: 'kyc_tax_scenario_s1_ch_only', + constraints: phoneConstraints, + builder: () => buildSubject(residenceCountry: _switzerland), + ); + + // S2 — DE only, empty TIN (pre-submit) + goldenTest( + 'S2 DE only — locked Germany with empty TIN', + fileName: 'kyc_tax_scenario_s2_de_only_empty_tin', + constraints: phoneConstraints, + builder: () => buildSubject(residenceCountry: _germany), + ); + + // S2 — DE only, TIN filled + goldenTest( + 'S2 DE only — locked Germany with TIN filled', + fileName: 'kyc_tax_scenario_s2_de_only_filled', + constraints: phoneConstraints, + pumpBeforeTest: (tester) async { + await tester.pumpAndSettle(); + await enterTinAt(tester, 0, 'DE123456789'); + }, + builder: () => buildSubject(residenceCountry: _germany), + ); + + // S2 — DE only, empty TIN validation error + goldenTest( + 'S2 DE only — empty TIN validation error after complete', + fileName: 'kyc_tax_scenario_s2_de_only_tin_error', + constraints: phoneConstraints, + pumpBeforeTest: (tester) async { + await tester.pumpAndSettle(); + await tapComplete(tester); + }, + builder: () => buildSubject(residenceCountry: _germany), + ); + + // S3 — CH locked + FR selected with TIN filled + goldenTest( + 'S3 CH + FR — Switzerland locked, France with TIN filled', + fileName: 'kyc_tax_scenario_s3_ch_fr', + constraints: phoneConstraints, + pumpBeforeTest: (tester) async { + await tester.pumpAndSettle(); + await addTaxResidence(tester); + await selectFreeCountry(tester, 'France'); + await enterTinAt(tester, 0, 'FR999'); + }, + builder: () => buildSubject(residenceCountry: _switzerland), + ); + + // S3 — FR added but TIN empty → error + goldenTest( + 'S3 CH + FR — France TIN empty, complete shows error', + fileName: 'kyc_tax_scenario_s3_ch_fr_tin_error', + constraints: phoneConstraints, + pumpBeforeTest: (tester) async { + await tester.pumpAndSettle(); + await addTaxResidence(tester); + await selectFreeCountry(tester, 'France'); + await tapComplete(tester); + }, + builder: () => buildSubject(residenceCountry: _switzerland), + ); + + // S4 — DE locked TIN filled + CH added + goldenTest( + 'S4 DE + CH — Germany locked TIN filled, Switzerland added', + fileName: 'kyc_tax_scenario_s4_de_ch', + constraints: phoneConstraints, + pumpBeforeTest: (tester) async { + await tester.pumpAndSettle(); + await enterTinAt(tester, 0, 'DE111'); + await addTaxResidence(tester); + await selectFreeCountry(tester, 'Switzerland'); + }, + builder: () => buildSubject(residenceCountry: _germany), + ); + + // S5 — DE + FR + US all TINs filled + goldenTest( + 'S5 DE + FR + US — all three TINs filled', + fileName: 'kyc_tax_scenario_s5_de_fr_us', + constraints: tallPhoneConstraints, + pumpBeforeTest: (tester) async { + await tester.pumpAndSettle(); + await enterTinAt(tester, 0, 'DE111'); + await addTaxResidence(tester); + await selectFreeCountry(tester, 'France'); + await enterTinAt(tester, 1, 'FR999'); + await addTaxResidence(tester); + await selectFreeCountry(tester, 'United States'); + await enterTinAt(tester, 2, 'US123'); + }, + builder: () => buildSubject(residenceCountry: _germany), + ); + + // S5 — missing one TIN → error + goldenTest( + 'S5 DE + FR + US — partial TIN missing shows error', + fileName: 'kyc_tax_scenario_s5_de_fr_us_partial_tin_error', + constraints: tallPhoneConstraints, + pumpBeforeTest: (tester) async { + await tester.pumpAndSettle(); + await enterTinAt(tester, 0, 'DE111'); + await addTaxResidence(tester); + await selectFreeCountry(tester, 'France'); + await enterTinAt(tester, 1, 'FR999'); + await addTaxResidence(tester); + await selectFreeCountry(tester, 'United States'); + // US TIN deliberately left empty await tapComplete(tester); }, - builder: buildSubject, + builder: () => buildSubject(residenceCountry: _germany), ); }); } diff --git a/test/goldens/screens/legal/goldens/macos/legal_document_page_load_error.png b/test/goldens/screens/legal/goldens/macos/legal_document_page_load_error.png new file mode 100644 index 000000000..19828392f Binary files /dev/null and b/test/goldens/screens/legal/goldens/macos/legal_document_page_load_error.png differ diff --git a/test/goldens/screens/legal/goldens/macos/legal_document_page_loaded_with_pdf.png b/test/goldens/screens/legal/goldens/macos/legal_document_page_loaded_with_pdf.png new file mode 100644 index 000000000..423933dc9 Binary files /dev/null and b/test/goldens/screens/legal/goldens/macos/legal_document_page_loaded_with_pdf.png differ diff --git a/test/goldens/screens/legal/legal_document_states_golden_test.dart b/test/goldens/screens/legal/legal_document_states_golden_test.dart new file mode 100644 index 000000000..a3e84b35f --- /dev/null +++ b/test/goldens/screens/legal/legal_document_states_golden_test.dart @@ -0,0 +1,109 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/screens/legal/subpages/legal_document_page.dart'; +import 'package:realunit_wallet/screens/settings/bloc/settings_bloc.dart'; + +import '../../../helper/helper.dart'; + +void main() { + // Sibling `legal_document_golden_test.dart` covers the empty placeholder and a + // loaded document without a PDF footer. This file adds the loaded state WITH + // the "Original-PDF" footer button (`params.pdfUrls` set) and the asset + // load-error view. + late MockSettingsBloc settingsBloc; + + setUp(() { + // The page loads its document from rootBundle, which caches per asset path. + // Clear it so the load-error golden's missing-asset read fails fresh rather + // than replaying a cached future. + rootBundle.clear(); + settingsBloc = MockSettingsBloc(); + when(() => settingsBloc.state).thenReturn(const SettingsState()); + }); + + Widget buildSubject({ + String assetBaseName = 'terms', + String? initialMarkdownContent, + Map? pdfUrls, + }) => + BlocProvider.value( + value: settingsBloc, + child: LegalDocumentPage( + params: LegalDocumentParams( + title: 'Terms', + assetBaseName: assetBaseName, + pdfUrls: pdfUrls, + ), + initialMarkdownContent: initialMarkdownContent, + ), + ); + + group('$LegalDocumentPage', () { + // Loaded document WITH a PDF footer: a non-null `params.pdfUrls` makes + // `_pdfUrl` resolve, adding the secondary "Original-PDF" button under the + // Markdown body (page:121-140). The markdown is passed via the + // @visibleForTesting hook so it renders synchronously (no async asset load). + goldenTest( + 'document loaded with PDF footer button', + fileName: 'legal_document_page_loaded_with_pdf', + constraints: phoneConstraints, + builder: () => wrapForGolden( + buildSubject( + initialMarkdownContent: _termsMarkdownStub, + pdfUrls: const {'de': _termsPdfUrl, 'en': _termsPdfUrl}, + ), + ), + ); + + // Load-error view: a missing asset makes `rootBundle.loadString` throw, so + // `initState` flips `_loadFailed` and the page renders the error state + // (error_outline icon + title + description + retry — page:150-184). + // pumpAndSettle drives the async failure to completion. + goldenTest( + 'load error view', + fileName: 'legal_document_page_load_error', + constraints: phoneConstraints, + pumpBeforeTest: (tester) => tester.pumpAndSettle(), + builder: () => wrapForGolden( + buildSubject(assetBaseName: 'missing_legal_asset_test'), + ), + ); + }); +} + +/// Placeholder PDF URL — the value is only used on tap, never rendered, so a +/// stable stub keeps the golden independent of the live legal-documents config. +const _termsPdfUrl = 'https://realunit.ch/legal/terms.pdf'; + +/// Representative head of `assets/legal/terms_of_use_de.md` — kept inline so the +/// golden does not depend on the live asset file changing. +const _termsMarkdownStub = '''# Nutzungsbedingungen der RealUnit Wallet App + +*Stand: Februar 2026* + + +## 1. Geltungsbereich + +Diese Nutzungsbedingungen regeln die Verwendung der RealUnit Wallet App (nachfolgend «App»), herausgegeben von der RealUnit Schweiz AG, Schochenmühlestrasse 6, 6340 Baar, Schweiz (nachfolgend «RealUnit» oder «wir»). + +Mit der Nutzung der App erklären Sie sich mit diesen Nutzungsbedingungen einverstanden. Für den Kauf und Verkauf von RealUnit Aktien-Token gelten zusätzlich die Allgemeinen Geschäftsbedingungen (AGB). + + +## 2. Beschreibung der App + +Die App ist eine Self-Custody-Wallet für die Verwaltung von RealUnit Aktien-Token (REALU) und weiteren ERC-20-Token auf der Ethereum-Blockchain. Sie ermöglicht insbesondere: + +- Erstellen und Wiederherstellen von Wallets mittels Wiederherstellungs-Wörtern (Seed Phrase) +- Empfangen und Senden von Token +- Anzeige von Guthaben und Transaktionshistorie +- Erstellung von Steuerberichten +- Anbindung von Hardware-Wallets (BitBox02) + + +## 3. Eigenverantwortung und Verwahrung + +Die App ist eine Self-Custody-Lösung. Das bedeutet: +'''; diff --git a/test/goldens/screens/pin/forgot_pin_bottom_sheet_golden_test.dart b/test/goldens/screens/pin/forgot_pin_bottom_sheet_golden_test.dart new file mode 100644 index 000000000..beb03721f --- /dev/null +++ b/test/goldens/screens/pin/forgot_pin_bottom_sheet_golden_test.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:realunit_wallet/screens/pin/widgets/forgot_pin_bottom_sheet.dart'; + +import '../../../helper/helper.dart'; + +void main() { + const phoneConstraints = BoxConstraints.tightFor(width: 390, height: 844); + + // Presented via showModalBottomSheet from the app-lock 'Forgot PIN?' action. + // The sheet's buttons only call context.pop when tapped, so it renders fine + // in a Scaffold's bottomSheet slot with a dark scrim — the same pattern as + // biometric_prompt_sheet_golden_test.dart. The scrim stands in for the dimmed + // PIN screen behind the real sheet. + group('$ForgotPinBottomSheet', () { + goldenTest( + 'default state with close and reset buttons', + fileName: 'forgot_pin_bottom_sheet_default', + constraints: phoneConstraints, + builder: () => wrapForGolden( + const Scaffold( + backgroundColor: Colors.black54, + bottomSheet: ForgotPinBottomSheet(), + ), + ), + ); + }); +} diff --git a/test/goldens/screens/pin/goldens/macos/forgot_pin_bottom_sheet_default.png b/test/goldens/screens/pin/goldens/macos/forgot_pin_bottom_sheet_default.png new file mode 100644 index 000000000..c1b5bf420 Binary files /dev/null and b/test/goldens/screens/pin/goldens/macos/forgot_pin_bottom_sheet_default.png differ diff --git a/test/goldens/screens/pin/goldens/macos/setup_pin_page_confirm_mismatch.png b/test/goldens/screens/pin/goldens/macos/setup_pin_page_confirm_mismatch.png new file mode 100644 index 000000000..3fd003f65 Binary files /dev/null and b/test/goldens/screens/pin/goldens/macos/setup_pin_page_confirm_mismatch.png differ diff --git a/test/goldens/screens/pin/goldens/macos/setup_pin_page_confirming.png b/test/goldens/screens/pin/goldens/macos/setup_pin_page_confirming.png index ac4702cae..e432dc14b 100644 Binary files a/test/goldens/screens/pin/goldens/macos/setup_pin_page_confirming.png and b/test/goldens/screens/pin/goldens/macos/setup_pin_page_confirming.png differ diff --git a/test/goldens/screens/pin/goldens/macos/setup_pin_page_default.png b/test/goldens/screens/pin/goldens/macos/setup_pin_page_default.png index a82670e96..85a8da9ba 100644 Binary files a/test/goldens/screens/pin/goldens/macos/setup_pin_page_default.png and b/test/goldens/screens/pin/goldens/macos/setup_pin_page_default.png differ diff --git a/test/goldens/screens/pin/goldens/macos/setup_pin_page_store_failed.png b/test/goldens/screens/pin/goldens/macos/setup_pin_page_store_failed.png new file mode 100644 index 000000000..caf753924 Binary files /dev/null and b/test/goldens/screens/pin/goldens/macos/setup_pin_page_store_failed.png differ diff --git a/test/goldens/screens/pin/goldens/macos/setup_pin_page_submitting.png b/test/goldens/screens/pin/goldens/macos/setup_pin_page_submitting.png index 13da0ee43..7eabb6186 100644 Binary files a/test/goldens/screens/pin/goldens/macos/setup_pin_page_submitting.png and b/test/goldens/screens/pin/goldens/macos/setup_pin_page_submitting.png differ diff --git a/test/goldens/screens/pin/goldens/macos/verify_pin_page_app_lock.png b/test/goldens/screens/pin/goldens/macos/verify_pin_page_app_lock.png new file mode 100644 index 000000000..f84634d58 Binary files /dev/null and b/test/goldens/screens/pin/goldens/macos/verify_pin_page_app_lock.png differ diff --git a/test/goldens/screens/pin/goldens/macos/verify_pin_page_biometric_button.png b/test/goldens/screens/pin/goldens/macos/verify_pin_page_biometric_button.png index d4b51c192..9f9d42268 100644 Binary files a/test/goldens/screens/pin/goldens/macos/verify_pin_page_biometric_button.png and b/test/goldens/screens/pin/goldens/macos/verify_pin_page_biometric_button.png differ diff --git a/test/goldens/screens/pin/goldens/macos/verify_pin_page_biometric_hint.png b/test/goldens/screens/pin/goldens/macos/verify_pin_page_biometric_hint.png index 2e045ba68..57276b123 100644 Binary files a/test/goldens/screens/pin/goldens/macos/verify_pin_page_biometric_hint.png and b/test/goldens/screens/pin/goldens/macos/verify_pin_page_biometric_hint.png differ diff --git a/test/goldens/screens/pin/goldens/macos/verify_pin_page_biometric_hint_not_enrolled.png b/test/goldens/screens/pin/goldens/macos/verify_pin_page_biometric_hint_not_enrolled.png new file mode 100644 index 000000000..f70f129c6 Binary files /dev/null and b/test/goldens/screens/pin/goldens/macos/verify_pin_page_biometric_hint_not_enrolled.png differ diff --git a/test/goldens/screens/pin/goldens/macos/verify_pin_page_biometric_hint_unavailable.png b/test/goldens/screens/pin/goldens/macos/verify_pin_page_biometric_hint_unavailable.png new file mode 100644 index 000000000..fd348f4d0 Binary files /dev/null and b/test/goldens/screens/pin/goldens/macos/verify_pin_page_biometric_hint_unavailable.png differ diff --git a/test/goldens/screens/pin/goldens/macos/verify_pin_page_default.png b/test/goldens/screens/pin/goldens/macos/verify_pin_page_default.png index 638c1a122..299f0cad9 100644 Binary files a/test/goldens/screens/pin/goldens/macos/verify_pin_page_default.png and b/test/goldens/screens/pin/goldens/macos/verify_pin_page_default.png differ diff --git a/test/goldens/screens/pin/goldens/macos/verify_pin_page_failure.png b/test/goldens/screens/pin/goldens/macos/verify_pin_page_failure.png new file mode 100644 index 000000000..3a3345fec Binary files /dev/null and b/test/goldens/screens/pin/goldens/macos/verify_pin_page_failure.png differ diff --git a/test/goldens/screens/pin/goldens/macos/verify_pin_page_locked.png b/test/goldens/screens/pin/goldens/macos/verify_pin_page_locked.png new file mode 100644 index 000000000..f69027875 Binary files /dev/null and b/test/goldens/screens/pin/goldens/macos/verify_pin_page_locked.png differ diff --git a/test/goldens/screens/pin/goldens/macos/verify_pin_page_seed_backup.png b/test/goldens/screens/pin/goldens/macos/verify_pin_page_seed_backup.png index abc4c4d19..fba699167 100644 Binary files a/test/goldens/screens/pin/goldens/macos/verify_pin_page_seed_backup.png and b/test/goldens/screens/pin/goldens/macos/verify_pin_page_seed_backup.png differ diff --git a/test/goldens/screens/pin/goldens/macos/verify_pin_page_temporarily_locked.png b/test/goldens/screens/pin/goldens/macos/verify_pin_page_temporarily_locked.png new file mode 100644 index 000000000..2ab11be9d Binary files /dev/null and b/test/goldens/screens/pin/goldens/macos/verify_pin_page_temporarily_locked.png differ diff --git a/test/goldens/screens/pin/goldens/macos/verify_pin_page_unverifiable.png b/test/goldens/screens/pin/goldens/macos/verify_pin_page_unverifiable.png index 5d2e451da..725b69901 100644 Binary files a/test/goldens/screens/pin/goldens/macos/verify_pin_page_unverifiable.png and b/test/goldens/screens/pin/goldens/macos/verify_pin_page_unverifiable.png differ diff --git a/test/goldens/screens/pin/goldens/macos/verify_pin_page_unverifiable_app_lock.png b/test/goldens/screens/pin/goldens/macos/verify_pin_page_unverifiable_app_lock.png new file mode 100644 index 000000000..dcb1878ac Binary files /dev/null and b/test/goldens/screens/pin/goldens/macos/verify_pin_page_unverifiable_app_lock.png differ diff --git a/test/goldens/screens/pin/goldens/macos/verify_pin_page_verifying.png b/test/goldens/screens/pin/goldens/macos/verify_pin_page_verifying.png index 3d4bc6d2a..cd2b48d53 100644 Binary files a/test/goldens/screens/pin/goldens/macos/verify_pin_page_verifying.png and b/test/goldens/screens/pin/goldens/macos/verify_pin_page_verifying.png differ diff --git a/test/goldens/screens/pin/setup_pin_golden_test.dart b/test/goldens/screens/pin/setup_pin_golden_test.dart index f8a00d24a..7e7d922a5 100644 --- a/test/goldens/screens/pin/setup_pin_golden_test.dart +++ b/test/goldens/screens/pin/setup_pin_golden_test.dart @@ -60,5 +60,29 @@ void main() { return wrapForGolden(buildSubject()); }, ); + + goldenTest( + 'confirm mismatch marks the dots red and shows the mismatch message', + fileName: 'setup_pin_page_confirm_mismatch', + constraints: const BoxConstraints.tightFor(width: 390, height: 844), + builder: () { + when(() => setupPinCubit.state).thenReturn( + const SetupPinState(mode: SetupPinMode.confirm, mismatch: true), + ); + return wrapForGolden(buildSubject()); + }, + ); + + goldenTest( + 'store failure keeps the pad and shows the save-failed message', + fileName: 'setup_pin_page_store_failed', + constraints: const BoxConstraints.tightFor(width: 390, height: 844), + builder: () { + when(() => setupPinCubit.state).thenReturn( + const SetupPinState(mode: SetupPinMode.confirm, storeFailed: true), + ); + return wrapForGolden(buildSubject()); + }, + ); }); } diff --git a/test/goldens/screens/pin/verify_pin_golden_test.dart b/test/goldens/screens/pin/verify_pin_golden_test.dart index 5c25f7e28..adb2655c0 100644 --- a/test/goldens/screens/pin/verify_pin_golden_test.dart +++ b/test/goldens/screens/pin/verify_pin_golden_test.dart @@ -6,11 +6,35 @@ import 'package:mocktail/mocktail.dart'; import 'package:realunit_wallet/generated/i18n.dart'; import 'package:realunit_wallet/screens/pin/bloc/verify_pin/verify_pin_cubit.dart'; import 'package:realunit_wallet/screens/pin/verify_pin_page.dart'; +import 'package:realunit_wallet/widgets/buttons/app_text_button.dart'; import '../../../helper/helper.dart'; class _MockVerifyPinCubit extends MockCubit implements VerifyPinCubit {} +/// Mirrors the private `_ForgotPinButton` that `VerifyPinPage.appLock()` slots +/// into `VerifyPinView.bottom`. The production widget is file-private (and its +/// onPressed reads `PinAuthCubit`/`HomeBloc` only when tapped), so the golden +/// reproduces the wrapper here while still rendering the real `AppTextButton`. +/// Passing any non-null `bottom` also flips the `VerifyPinUnverifiable` copy +/// from the gate variant to the app-lock variant (`pinVerifyUnverifiable`). +class _ForgotPinButton extends StatelessWidget { + const _ForgotPinButton(); + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: SizedBox( + height: 52.0, + child: AppTextButton( + fullWidth: false, + onPressed: () {}, + label: S.of(context).pinForgotten, + ), + ), + ); +} + void main() { late _MockVerifyPinCubit verifyPinCubit; @@ -125,5 +149,141 @@ void main() { ); }, ); + + // App-lock entry point (VerifyPinPage.appLock): the only variant that + // renders the 'Forgot PIN?' action beneath the number pad. + goldenTest( + 'app lock variant shows the forgot-pin action below the pad', + fileName: 'verify_pin_page_app_lock', + constraints: const BoxConstraints.tightFor(width: 390, height: 844), + builder: () => wrapForGolden( + BlocProvider.value( + value: verifyPinCubit, + child: VerifyPinView( + onAuthenticated: () {}, + bottom: const _ForgotPinButton(), + ), + ), + ), + ); + + goldenTest( + 'failure state marks the dots red and shows the retry message', + fileName: 'verify_pin_page_failure', + constraints: const BoxConstraints.tightFor(width: 390, height: 844), + builder: () { + final cubit = _MockVerifyPinCubit(); + when(() => cubit.state).thenReturn(const VerifyPinFailure(failedAttempts: 3)); + when(() => cubit.checkBiometricAvailability()).thenAnswer((_) async {}); + return wrapForGolden( + BlocProvider.value( + value: cubit, + child: VerifyPinView(onAuthenticated: () {}), + ), + ); + }, + ); + + goldenTest( + 'temporarily locked disables the pad and shows the countdown', + fileName: 'verify_pin_page_temporarily_locked', + constraints: const BoxConstraints.tightFor(width: 390, height: 844), + builder: () { + final cubit = _MockVerifyPinCubit(); + // Lock more than an hour out so `_formatRemaining` renders the stable + // 'Xh Ym' form; the sub-hour form appends a live seconds counter that + // would differ between the two determinism runs. The mocked cubit emits + // no state change, so the view's countdown timer never starts. + when(() => cubit.state).thenReturn( + VerifyPinTemporarilyLocked( + failedAttempts: 5, + lockedUntil: + DateTime.now().add(const Duration(hours: 1, minutes: 5, seconds: 30)), + ), + ); + when(() => cubit.checkBiometricAvailability()).thenAnswer((_) async {}); + return wrapForGolden( + BlocProvider.value( + value: cubit, + child: VerifyPinView(onAuthenticated: () {}), + ), + ); + }, + ); + + goldenTest( + 'permanently locked disables the pad and shows the lockout message', + fileName: 'verify_pin_page_locked', + constraints: const BoxConstraints.tightFor(width: 390, height: 844), + builder: () { + final cubit = _MockVerifyPinCubit(); + when(() => cubit.state).thenReturn(const VerifyPinLocked(failedAttempts: 10)); + when(() => cubit.checkBiometricAvailability()).thenAnswer((_) async {}); + return wrapForGolden( + BlocProvider.value( + value: cubit, + child: VerifyPinView(onAuthenticated: () {}), + ), + ); + }, + ); + + goldenTest( + 'unverifiable app lock offers recovery via the forgot-pin action', + fileName: 'verify_pin_page_unverifiable_app_lock', + constraints: const BoxConstraints.tightFor(width: 390, height: 844), + builder: () { + final cubit = _MockVerifyPinCubit(); + when(() => cubit.state).thenReturn(const VerifyPinUnverifiable()); + when(() => cubit.checkBiometricAvailability()).thenAnswer((_) async {}); + return wrapForGolden( + BlocProvider.value( + value: cubit, + child: VerifyPinView( + onAuthenticated: () {}, + bottom: const _ForgotPinButton(), + ), + ), + ); + }, + ); + + goldenTest( + 'biometric hint shown when nothing is enrolled anymore', + fileName: 'verify_pin_page_biometric_hint_not_enrolled', + constraints: const BoxConstraints.tightFor(width: 390, height: 844), + builder: () { + final cubit = _MockVerifyPinCubit(); + when(() => cubit.state).thenReturn( + const VerifyPinState(biometricStatus: BiometricStatus.notEnrolled), + ); + when(() => cubit.checkBiometricAvailability()).thenAnswer((_) async {}); + return wrapForGolden( + BlocProvider.value( + value: cubit, + child: VerifyPinView(onAuthenticated: () {}), + ), + ); + }, + ); + + goldenTest( + 'biometric hint shown when biometrics are momentarily unavailable', + fileName: 'verify_pin_page_biometric_hint_unavailable', + constraints: const BoxConstraints.tightFor(width: 390, height: 844), + builder: () { + final cubit = _MockVerifyPinCubit(); + when(() => cubit.state).thenReturn( + const VerifyPinState(biometricStatus: BiometricStatus.unavailable), + ); + when(() => cubit.checkBiometricAvailability()).thenAnswer((_) async {}); + return wrapForGolden( + BlocProvider.value( + value: cubit, + child: VerifyPinView(onAuthenticated: () {}), + ), + ); + }, + ); }); } diff --git a/test/goldens/screens/receive/goldens/macos/receive_page_full_page.png b/test/goldens/screens/receive/goldens/macos/receive_page_full_page.png new file mode 100644 index 000000000..ab6ae0bd2 Binary files /dev/null and b/test/goldens/screens/receive/goldens/macos/receive_page_full_page.png differ diff --git a/test/goldens/screens/receive/receive_states_golden_test.dart b/test/goldens/screens/receive/receive_states_golden_test.dart new file mode 100644 index 000000000..efb31418e --- /dev/null +++ b/test/goldens/screens/receive/receive_states_golden_test.dart @@ -0,0 +1,32 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/packages/service/app_store.dart'; +import 'package:realunit_wallet/screens/receive/receive_page.dart'; + +import '../../../helper/helper.dart'; + +// `receive_page_default` (bottom-sheet variant: handlebar, no AppBar) lives in +// `receive_golden_test.dart`. The router actually pushes the full-page variant +// (`router_config.dart:210` → `ReceivePage(isBottomSheet: false)`): an AppBar +// with a back arrow and no handlebar. This file covers that routed surface. +void main() { + setUpAll(() { + final getIt = GetIt.instance; + final appStore = MockAppStore(); + when(() => appStore.primaryAddress) + .thenReturn('0xcabd3f4b10a7089986e708d19140bfc98e5880c0'); + getIt.registerSingleton(appStore); + }); + + tearDownAll(() async => GetIt.instance.reset()); + + group('$ReceivePage', () { + goldenTest( + 'full page — AppBar with back arrow, no handlebar', + fileName: 'receive_page_full_page', + constraints: phoneConstraints, + builder: () => wrapForGolden(const ReceivePage(isBottomSheet: false)), + ); + }); +} diff --git a/test/goldens/screens/restore_wallet/goldens/macos/restore_wallet_page_complete.png b/test/goldens/screens/restore_wallet/goldens/macos/restore_wallet_page_complete.png new file mode 100644 index 000000000..2b899198b Binary files /dev/null and b/test/goldens/screens/restore_wallet/goldens/macos/restore_wallet_page_complete.png differ diff --git a/test/goldens/screens/restore_wallet/goldens/macos/restore_wallet_page_failed.png b/test/goldens/screens/restore_wallet/goldens/macos/restore_wallet_page_failed.png new file mode 100644 index 000000000..46a7a5b06 Binary files /dev/null and b/test/goldens/screens/restore_wallet/goldens/macos/restore_wallet_page_failed.png differ diff --git a/test/goldens/screens/restore_wallet/goldens/macos/restore_wallet_page_restoring.png b/test/goldens/screens/restore_wallet/goldens/macos/restore_wallet_page_restoring.png new file mode 100644 index 000000000..82b3bf1ab Binary files /dev/null and b/test/goldens/screens/restore_wallet/goldens/macos/restore_wallet_page_restoring.png differ diff --git a/test/goldens/screens/restore_wallet/goldens/macos/restore_wallet_page_success.png b/test/goldens/screens/restore_wallet/goldens/macos/restore_wallet_page_success.png new file mode 100644 index 000000000..3f823170b Binary files /dev/null and b/test/goldens/screens/restore_wallet/goldens/macos/restore_wallet_page_success.png differ diff --git a/test/goldens/screens/restore_wallet/restore_wallet_golden_test.dart b/test/goldens/screens/restore_wallet/restore_wallet_golden_test.dart index da5abfa99..734d0cc14 100644 --- a/test/goldens/screens/restore_wallet/restore_wallet_golden_test.dart +++ b/test/goldens/screens/restore_wallet/restore_wallet_golden_test.dart @@ -10,6 +10,26 @@ import 'package:realunit_wallet/screens/restore_wallet/restore_wallet_view.dart' import '../../../helper/helper.dart'; +// A valid 12-word BIP39 phrase — every word is in the wordlist, so the mnemonic +// controller renders each in its matching (dark) style rather than the red +// non-match style. +const _seedPhrase = + 'cheese trigger cannon mention judge hire snack sustain annual predict illness celery'; + +// The 12 mnemonic controllers live inside RestoreWalletView's State and cannot +// be injected, so pasting the phrase into the first cell — which the field's +// paste handler distributes across all 12 controllers — is the only seam to +// populate the fields (it mirrors the real paste flow). Focus is dropped +// afterwards so no blinking cursor makes the capture non-deterministic; a +// single trailing pump is enough (the sibling `valid` baseline already renders +// this page, SVG included, under pumpOnce). +Future _fillSeed(WidgetTester tester) async { + await tester.enterText(find.byType(TextField).first, _seedPhrase); + await tester.pump(); + FocusManager.instance.primaryFocus?.unfocus(); + await tester.pump(); +} + class _MockRestoreWalletCubit extends MockCubit implements RestoreWalletCubit {} @@ -79,5 +99,56 @@ void main() { return wrapForGolden(buildSubject()); }, ); + + goldenTest( + 'complete seed enables the next button behind an okker border', + fileName: 'restore_wallet_page_complete', + pumpBeforeTest: _fillSeed, + constraints: const BoxConstraints.tightFor(width: 390, height: 844), + builder: () { + when(() => validateSeedCubit.state) + .thenReturn(ValidateSeedState.complete); + return wrapForGolden(buildSubject()); + }, + ); + + goldenTest( + 'restore in flight shows the loading button behind an okker border', + fileName: 'restore_wallet_page_restoring', + pumpBeforeTest: _fillSeed, + constraints: const BoxConstraints.tightFor(width: 390, height: 844), + builder: () { + when(() => validateSeedCubit.state).thenReturn(ValidateSeedState.valid); + when(() => restoreWalletCubit.state) + .thenReturn(const RestoreWalletState(isLoading: true)); + return wrapForGolden(buildSubject()); + }, + ); + + goldenTest( + 'successful restore shows the green success button and border', + fileName: 'restore_wallet_page_success', + pumpBeforeTest: _fillSeed, + constraints: const BoxConstraints.tightFor(width: 390, height: 844), + builder: () { + when(() => validateSeedCubit.state).thenReturn(ValidateSeedState.valid); + when(() => restoreWalletCubit.state) + .thenReturn(RestoreWalletState(wallet: MockSoftwareWallet())); + return wrapForGolden(buildSubject()); + }, + ); + + goldenTest( + 'failed restore shows an idle retry button behind a green border', + fileName: 'restore_wallet_page_failed', + pumpBeforeTest: _fillSeed, + constraints: const BoxConstraints.tightFor(width: 390, height: 844), + builder: () { + when(() => validateSeedCubit.state).thenReturn(ValidateSeedState.valid); + when(() => restoreWalletCubit.state) + .thenReturn(const RestoreWalletState(hasError: true)); + return wrapForGolden(buildSubject()); + }, + ); }); } diff --git a/test/goldens/screens/sell/goldens/macos/sell_confirm_sheet.png b/test/goldens/screens/sell/goldens/macos/sell_confirm_sheet.png index b59a55fc5..bdd7498d9 100644 Binary files a/test/goldens/screens/sell/goldens/macos/sell_confirm_sheet.png and b/test/goldens/screens/sell/goldens/macos/sell_confirm_sheet.png differ diff --git a/test/goldens/screens/sell/goldens/macos/sell_confirm_sheet_loading.png b/test/goldens/screens/sell/goldens/macos/sell_confirm_sheet_loading.png index f918f8889..64aed46f2 100644 Binary files a/test/goldens/screens/sell/goldens/macos/sell_confirm_sheet_loading.png and b/test/goldens/screens/sell/goldens/macos/sell_confirm_sheet_loading.png differ diff --git a/test/goldens/screens/sell/goldens/macos/sell_executed_sheet.png b/test/goldens/screens/sell/goldens/macos/sell_executed_sheet.png index dc1a85543..213037a28 100644 Binary files a/test/goldens/screens/sell/goldens/macos/sell_executed_sheet.png and b/test/goldens/screens/sell/goldens/macos/sell_executed_sheet.png differ diff --git a/test/goldens/screens/settings/goldens/macos/settings_confirm_logout_wallet_sheet_checked.png b/test/goldens/screens/settings/goldens/macos/settings_confirm_logout_wallet_sheet_checked.png new file mode 100644 index 000000000..e2fa71ccc Binary files /dev/null and b/test/goldens/screens/settings/goldens/macos/settings_confirm_logout_wallet_sheet_checked.png differ diff --git a/test/goldens/screens/settings/settings_states_golden_test.dart b/test/goldens/screens/settings/settings_states_golden_test.dart new file mode 100644 index 000000000..50c1d02e4 --- /dev/null +++ b/test/goldens/screens/settings/settings_states_golden_test.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:realunit_wallet/screens/settings/widgets/settings_confirm_logout_wallet_sheet.dart'; + +import '../../../helper/helper.dart'; + +void main() { + // `settings_page_{default,bitbox}` and the unchecked + // `settings_confirm_logout_wallet_sheet_default` live in + // `settings_golden_test.dart` / `settings_confirm_logout_wallet_sheet_golden_test.dart`. + // + // Documented skip — release-build Settings variant (Network tile hidden): + // the Network tile is gated by `if (kDebugMode)` (settings_page.dart:57), not + // a runtime flag. `flutter test` always runs in debug (JIT), so `kDebugMode` + // is a compile-time `true` that cannot be flipped from a test. The tile is + // therefore always present in goldens; the release variant is not + // deterministically reproducible without a separate release-mode build, so it + // is skipped rather than hacked. + // + // Covered here: the confirm-logout sheet with the checkbox ticked, which + // enables the Reset button (`isChecked ? … : null`, + // settings_confirm_logout_wallet_sheet.dart:122). + group('$SettingsConfirmLogoutWalletSheet', () { + goldenTest( + 'checkbox checked — Reset button enabled', + fileName: 'settings_confirm_logout_wallet_sheet_checked', + constraints: phoneConstraints, + // The whole row is one opaque GestureDetector (the inner Checkbox is + // wrapped in AbsorbPointer), so a tap at the checkbox toggles `isChecked` + // via the ancestor detector; pumpAndSettle finishes the check animation. + pumpBeforeTest: (tester) async { + await tester.tap(find.byType(Checkbox)); + await tester.pumpAndSettle(); + }, + builder: () => wrapForGolden( + const Scaffold( + backgroundColor: Colors.black54, + bottomSheet: SettingsConfirmLogoutWalletSheet(), + ), + ), + ); + }); +} diff --git a/test/goldens/screens/settings_currencies/goldens/macos/settings_currencies_page_error.png b/test/goldens/screens/settings_currencies/goldens/macos/settings_currencies_page_error.png new file mode 100644 index 000000000..4e45104d8 Binary files /dev/null and b/test/goldens/screens/settings_currencies/goldens/macos/settings_currencies_page_error.png differ diff --git a/test/goldens/screens/settings_currencies/goldens/macos/settings_currencies_page_loading.png b/test/goldens/screens/settings_currencies/goldens/macos/settings_currencies_page_loading.png new file mode 100644 index 000000000..34e0fd65d Binary files /dev/null and b/test/goldens/screens/settings_currencies/goldens/macos/settings_currencies_page_loading.png differ diff --git a/test/goldens/screens/settings_currencies/settings_currencies_states_golden_test.dart b/test/goldens/screens/settings_currencies/settings_currencies_states_golden_test.dart new file mode 100644 index 000000000..abb54f45b --- /dev/null +++ b/test/goldens/screens/settings_currencies/settings_currencies_states_golden_test.dart @@ -0,0 +1,65 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/packages/repository/supported_fiat_repository.dart'; +import 'package:realunit_wallet/screens/settings_currencies/settings_currencies_page.dart'; +import 'package:realunit_wallet/styles/currency.dart'; + +import '../../../helper/helper.dart'; + +class _MockSupportedFiatRepository extends Mock implements SupportedFiatRepository {} + +void main() { + // `settings_currencies_page_default` (currencies loaded) lives in + // `settings_currencies_golden_test.dart`. This file covers the two other + // `FutureBuilder` branches of `SettingsCurrenciesPage.build` + // (`settings_currencies_page.dart:43-63`): the pending spinner and the + // `_ErrorView`. Neither branch reaches the `getIt()` lookup, + // so only the repository the page reads in `initState` is registered. + late _MockSupportedFiatRepository fiatRepo; + + setUp(() { + fiatRepo = _MockSupportedFiatRepository(); + final getIt = GetIt.instance; + if (getIt.isRegistered()) { + getIt.unregister(); + } + getIt.registerSingleton(fiatRepo); + }); + + tearDown(() async { + await GetIt.instance.reset(); + }); + + group('$SettingsCurrenciesPage', () { + // No data, no error → `CupertinoActivityIndicator` (page:61-63). A future + // that never completes keeps the FutureBuilder in its pending branch; + // freeze the spinner with pumpOnce. + goldenTest( + 'loading', + fileName: 'settings_currencies_page_loading', + constraints: phoneConstraints, + pumpBeforeTest: pumpOnce, + builder: () { + when(() => fiatRepo.getAll()) + .thenAnswer((_) => Completer>().future); + return wrapForGolden(const SettingsCurrenciesPage()); + }, + ); + + // `snapshot.hasError` → `_ErrorView` (page:46-60, 96-142): icon, title, + // description and Retry button. + goldenTest( + 'error view with retry', + fileName: 'settings_currencies_page_error', + constraints: phoneConstraints, + builder: () { + when(() => fiatRepo.getAll()) + .thenAnswer((_) async => throw Exception('failed to load currencies')); + return wrapForGolden(const SettingsCurrenciesPage()); + }, + ); + }); +} diff --git a/test/goldens/screens/settings_languages/goldens/macos/settings_languages_page_error.png b/test/goldens/screens/settings_languages/goldens/macos/settings_languages_page_error.png new file mode 100644 index 000000000..2bbac280e Binary files /dev/null and b/test/goldens/screens/settings_languages/goldens/macos/settings_languages_page_error.png differ diff --git a/test/goldens/screens/settings_languages/goldens/macos/settings_languages_page_loading.png b/test/goldens/screens/settings_languages/goldens/macos/settings_languages_page_loading.png new file mode 100644 index 000000000..596296f05 Binary files /dev/null and b/test/goldens/screens/settings_languages/goldens/macos/settings_languages_page_loading.png differ diff --git a/test/goldens/screens/settings_languages/settings_languages_states_golden_test.dart b/test/goldens/screens/settings_languages/settings_languages_states_golden_test.dart new file mode 100644 index 000000000..fa77c8fb3 --- /dev/null +++ b/test/goldens/screens/settings_languages/settings_languages_states_golden_test.dart @@ -0,0 +1,66 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/packages/repository/supported_language_repository.dart'; +import 'package:realunit_wallet/screens/settings_languages/settings_languages_page.dart'; +import 'package:realunit_wallet/styles/language.dart'; + +import '../../../helper/helper.dart'; + +class _MockSupportedLanguageRepository extends Mock + implements SupportedLanguageRepository {} + +void main() { + // `settings_languages_page_default` (languages loaded) lives in + // `settings_languages_golden_test.dart`. This file covers the two other + // `FutureBuilder` branches of `SettingsLanguagePage.build` + // (`settings_languages_page.dart:39-59`): the pending spinner and the + // `_LanguageErrorView`. Neither branch reaches the `getIt()` + // lookup, so only the repository the page reads in `initState` is registered. + late _MockSupportedLanguageRepository langRepo; + + setUp(() { + langRepo = _MockSupportedLanguageRepository(); + final getIt = GetIt.instance; + if (getIt.isRegistered()) { + getIt.unregister(); + } + getIt.registerSingleton(langRepo); + }); + + tearDown(() async { + await GetIt.instance.reset(); + }); + + group('$SettingsLanguagePage', () { + // No data, no error → `CupertinoActivityIndicator` (page:57-59). A future + // that never completes keeps the FutureBuilder in its pending branch; + // freeze the spinner with pumpOnce. + goldenTest( + 'loading', + fileName: 'settings_languages_page_loading', + constraints: phoneConstraints, + pumpBeforeTest: pumpOnce, + builder: () { + when(() => langRepo.getEnabled()) + .thenAnswer((_) => Completer>().future); + return wrapForGolden(const SettingsLanguagePage()); + }, + ); + + // `snapshot.hasError` → `_LanguageErrorView` (page:42-56, 91-137): icon, + // title, description and Retry button. + goldenTest( + 'error view with retry', + fileName: 'settings_languages_page_error', + constraints: phoneConstraints, + builder: () { + when(() => langRepo.getEnabled()) + .thenAnswer((_) async => throw Exception('failed to load languages')); + return wrapForGolden(const SettingsLanguagePage()); + }, + ); + }); +} diff --git a/test/goldens/screens/settings_network/goldens/macos/settings_network_page_switching.png b/test/goldens/screens/settings_network/goldens/macos/settings_network_page_switching.png new file mode 100644 index 000000000..418e88259 Binary files /dev/null and b/test/goldens/screens/settings_network/goldens/macos/settings_network_page_switching.png differ diff --git a/test/goldens/screens/settings_network/settings_network_states_golden_test.dart b/test/goldens/screens/settings_network/settings_network_states_golden_test.dart new file mode 100644 index 000000000..8069347b1 --- /dev/null +++ b/test/goldens/screens/settings_network/settings_network_states_golden_test.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/screens/settings/bloc/settings_bloc.dart'; +import 'package:realunit_wallet/screens/settings_network/settings_network_page.dart'; + +import '../../../helper/helper.dart'; + +void main() { + // `settings_network_page_default` (mainnet selected, check icon) lives in + // `settings_network_golden_test.dart`. This file covers the "switch in + // flight" branch of `SettingsNetworkPage.build` + // (`settings_network_page.dart:32-44`): tapping a non-selected mode sets the + // page's `_LoadingNetworkModeModel` so its trailing shows a + // `CircularProgressIndicator` instead of the check. The mocked `SettingsBloc` + // never emits, so the `BlocConsumer.listener` that would clear the loading + // flag (`loadingFinished()`) never fires and the spinner stays put. + late MockSettingsBloc settingsBloc; + + setUp(() { + settingsBloc = MockSettingsBloc(); + when(() => settingsBloc.state).thenReturn(const SettingsState()); + + final getIt = GetIt.instance; + if (getIt.isRegistered()) { + getIt.unregister(); + } + getIt.registerSingleton(settingsBloc); + }); + + tearDown(() async { + await GetIt.instance.reset(); + }); + + group('$SettingsNetworkPage', () { + goldenTest( + 'switching to testnet — spinner instead of check', + fileName: 'settings_network_page_switching', + constraints: phoneConstraints, + // Tap the second (non-selected: Testnet) tile, then a single pump to + // freeze the CircularProgressIndicator on its first frame — pumpAndSettle + // would time out on the never-ending spinner. + pumpBeforeTest: (tester) async { + await tester.pumpAndSettle(); + await tester.tap(find.byType(InkWell).at(1)); + await tester.pump(); + }, + builder: () => wrapForGolden( + BlocProvider.value( + value: settingsBloc, + child: SettingsNetworkPage(), + ), + ), + ); + }); +} diff --git a/test/goldens/screens/settings_security/goldens/macos/settings_security_page_biometrics_disabled.png b/test/goldens/screens/settings_security/goldens/macos/settings_security_page_biometrics_disabled.png new file mode 100644 index 000000000..baec30a8e Binary files /dev/null and b/test/goldens/screens/settings_security/goldens/macos/settings_security_page_biometrics_disabled.png differ diff --git a/test/goldens/screens/settings_security/goldens/macos/settings_security_page_busy.png b/test/goldens/screens/settings_security/goldens/macos/settings_security_page_busy.png new file mode 100644 index 000000000..512ee4d77 Binary files /dev/null and b/test/goldens/screens/settings_security/goldens/macos/settings_security_page_busy.png differ diff --git a/test/goldens/screens/settings_security/goldens/macos/settings_security_page_error_snackbar.png b/test/goldens/screens/settings_security/goldens/macos/settings_security_page_error_snackbar.png new file mode 100644 index 000000000..d6f9a7847 Binary files /dev/null and b/test/goldens/screens/settings_security/goldens/macos/settings_security_page_error_snackbar.png differ diff --git a/test/goldens/screens/settings_security/goldens/macos/settings_security_page_no_biometrics.png b/test/goldens/screens/settings_security/goldens/macos/settings_security_page_no_biometrics.png new file mode 100644 index 000000000..ac5a38fd0 Binary files /dev/null and b/test/goldens/screens/settings_security/goldens/macos/settings_security_page_no_biometrics.png differ diff --git a/test/goldens/screens/settings_security/settings_security_states_golden_test.dart b/test/goldens/screens/settings_security/settings_security_states_golden_test.dart new file mode 100644 index 000000000..6fca1e34e --- /dev/null +++ b/test/goldens/screens/settings_security/settings_security_states_golden_test.dart @@ -0,0 +1,122 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/screens/settings_security/cubits/settings_security_cubit.dart'; +import 'package:realunit_wallet/screens/settings_security/settings_security_page.dart'; + +import '../../../helper/helper.dart'; + +class _MockSettingsSecurityCubit extends MockCubit + implements SettingsSecurityCubit {} + +void main() { + // `settings_security_page_default` (biometrics supported + enabled, Switch + // ON) lives in `settings_security_golden_test.dart`. This file covers the + // other rendered branches of `SettingsSecurityView` + // (`settings_security_page.dart`). + // + // The green "PIN geändert" success SnackBar (page:94-102) is intentionally + // NOT covered: it is fired from the `_onPinChanged` navigation callback that + // only runs after the full change-PIN flow completes, not from any + // `SettingsSecurityState`. There is no state-driven path to it, so a faithful + // golden would require driving the whole PIN navigation stack (out of scope) + // — reconstructing the SnackBar by hand would be a hack. Documented skip. + late _MockSettingsSecurityCubit cubit; + + setUp(() { + cubit = _MockSettingsSecurityCubit(); + }); + + Widget buildSubject() => wrapForGolden( + BlocProvider.value( + value: cubit, + child: const SettingsSecurityView(), + ), + ); + + group('$SettingsSecurityPage', () { + // biometricSupported + !biometricEnabled → toggle row present, Switch OFF + // (page:64-74, 123-129). + goldenTest( + 'biometrics supported but disabled (switch off)', + fileName: 'settings_security_page_biometrics_disabled', + constraints: phoneConstraints, + builder: () { + when(() => cubit.state).thenReturn( + const SettingsSecurityState( + biometricSupported: true, + biometricEnabled: false, + ), + ); + return buildSubject(); + }, + ); + + // !biometricSupported → the whole biometric toggle row is dropped, only the + // "Change PIN" row remains (page:64 guard). + goldenTest( + 'biometrics not supported (toggle row hidden)', + fileName: 'settings_security_page_no_biometrics', + constraints: phoneConstraints, + builder: () { + when(() => cubit.state).thenReturn( + const SettingsSecurityState(biometricSupported: false), + ); + return buildSubject(); + }, + ); + + // isBusy → the Switch is replaced by a CupertinoActivityIndicator + // (page:116-122); freeze the spinner on its first frame. + goldenTest( + 'biometric round-trip in flight (spinner instead of switch)', + fileName: 'settings_security_page_busy', + constraints: phoneConstraints, + pumpBeforeTest: pumpOnce, + builder: () { + when(() => cubit.state).thenReturn( + const SettingsSecurityState( + biometricSupported: true, + biometricEnabled: false, + isBusy: true, + ), + ); + return buildSubject(); + }, + ); + + // A state with `error != null` drives the BlocConsumer.listener + // (page:50-56) to show the red failure SnackBar. Emitting the error via + // `whenListen` (initial state has no error) fires `listenWhen`; pumpAndSettle + // runs the entrance animation to completion (the 4s auto-dismiss is a Timer, + // not a frame, so the SnackBar stays visible). + goldenTest( + 'biometric-enable failure SnackBar (red)', + fileName: 'settings_security_page_error_snackbar', + constraints: phoneConstraints, + pumpBeforeTest: (tester) async { + await tester.pump(); // deliver the whenListen emission to the listener + await tester.pumpAndSettle(); // run the SnackBar entrance to completion + }, + builder: () { + whenListen( + cubit, + Stream.value( + const SettingsSecurityState( + biometricSupported: true, + biometricEnabled: false, + error: SettingsSecurityError.biometricEnableFailed, + ), + ), + initialState: const SettingsSecurityState( + biometricSupported: true, + biometricEnabled: false, + ), + ); + return buildSubject(); + }, + ); + }); +} diff --git a/test/goldens/screens/settings_seed/goldens/macos/settings_seed_page_loading.png b/test/goldens/screens/settings_seed/goldens/macos/settings_seed_page_loading.png new file mode 100644 index 000000000..4235c9a1a Binary files /dev/null and b/test/goldens/screens/settings_seed/goldens/macos/settings_seed_page_loading.png differ diff --git a/test/goldens/screens/settings_seed/settings_seed_states_golden_test.dart b/test/goldens/screens/settings_seed/settings_seed_states_golden_test.dart new file mode 100644 index 000000000..308aa4456 --- /dev/null +++ b/test/goldens/screens/settings_seed/settings_seed_states_golden_test.dart @@ -0,0 +1,53 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/screens/settings_seed/bloc/settings_seed_cubit.dart'; +import 'package:realunit_wallet/screens/settings_seed/settings_seed_view.dart'; + +import '../../../helper/helper.dart'; + +class _MockSettingsSeedCubit extends MockCubit implements SettingsSeedCubit {} + +void main() { + // `settings_seed_page_default` (blurred seed card) and + // `settings_seed_page_revealed` live in `settings_seed_golden_test.dart`. + // This file covers the pre-unlock branch of `SettingsSeedView` + // (`settings_seed_view.dart:89-100`): while fewer than 12 mnemonic words are + // in memory the seed card is replaced by a CupertinoActivityIndicator. + late _MockSettingsSeedCubit settingsSeedCubit; + + setUp(() { + settingsSeedCubit = _MockSettingsSeedCubit(); + // Empty seed → wordCount != 12 → spinner branch. + when(() => settingsSeedCubit.state).thenReturn(const SettingsSeedState('')); + }); + + setUpAll(() { + // SettingsSeedView.initState calls ScreenshotGuard.acquire(), which hits + // the no_screenshot method channel. + stubNoScreenshotChannel(); + }); + + group('$SettingsSeedView', () { + goldenTest( + 'seed still loading — spinner instead of the seed card', + fileName: 'settings_seed_page_loading', + constraints: phoneConstraints, + // The backup illustration SVG is not covered by precacheImages; give it a + // couple of (microtask-driven, deterministic) frames to decode, then stop + // before the endless CupertinoActivityIndicator would time out a settle. + pumpBeforeTest: (tester) async { + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 100)); + }, + builder: () => wrapForGolden( + BlocProvider.value( + value: settingsSeedCubit, + child: const SettingsSeedView(), + ), + ), + ); + }); +} diff --git a/test/goldens/screens/settings_tax_report/goldens/macos/settings_tax_report_page_date_picker.png b/test/goldens/screens/settings_tax_report/goldens/macos/settings_tax_report_page_date_picker.png new file mode 100644 index 000000000..7f697388a Binary files /dev/null and b/test/goldens/screens/settings_tax_report/goldens/macos/settings_tax_report_page_date_picker.png differ diff --git a/test/goldens/screens/settings_tax_report/goldens/macos/settings_tax_report_page_failure_snackbar.png b/test/goldens/screens/settings_tax_report/goldens/macos/settings_tax_report_page_failure_snackbar.png new file mode 100644 index 000000000..17af7506b Binary files /dev/null and b/test/goldens/screens/settings_tax_report/goldens/macos/settings_tax_report_page_failure_snackbar.png differ diff --git a/test/goldens/screens/settings_tax_report/settings_tax_report_states_golden_test.dart b/test/goldens/screens/settings_tax_report/settings_tax_report_states_golden_test.dart new file mode 100644 index 000000000..f9c63da84 --- /dev/null +++ b/test/goldens/screens/settings_tax_report/settings_tax_report_states_golden_test.dart @@ -0,0 +1,103 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:clock/clock.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_pdf_service.dart'; +import 'package:realunit_wallet/screens/settings/bloc/settings_bloc.dart'; +import 'package:realunit_wallet/screens/settings_tax_report/cubit/settings_tax_report_cubit.dart'; +import 'package:realunit_wallet/screens/settings_tax_report/settings_tax_report_page.dart'; +import 'package:realunit_wallet/widgets/date_picker_field.dart'; + +import '../../../helper/helper.dart'; + +class _MockSettingsTaxReportCubit extends MockCubit + implements SettingsTaxReportCubit {} + +class _MockRealUnitPdfService extends Mock implements RealUnitPdfService {} + +void main() { + // `settings_tax_report_page_{default,loading,failure}` live in + // `settings_tax_report_golden_test.dart`. Note the existing `_failure` + // golden stubs `cubit.state` without an emission, so its BlocListener never + // fires — it shows the idle page, NOT the SnackBar. This file adds the two + // missing surfaces: the actually-visible failure SnackBar and the date-picker + // overlay. + late MockSettingsBloc settingsBloc; + late _MockSettingsTaxReportCubit taxReportCubit; + + setUp(() { + settingsBloc = MockSettingsBloc(); + taxReportCubit = _MockSettingsTaxReportCubit(); + when(() => settingsBloc.state).thenReturn(const SettingsState()); + when(() => taxReportCubit.state).thenReturn(const SettingsTaxReportInitial()); + }); + + setUpAll(() { + GetIt.instance.registerSingleton(_MockRealUnitPdfService()); + }); + + tearDownAll(() async { + await GetIt.instance.reset(); + }); + + // `SettingsTaxReportView` field-initializes `_DatePickerModel`, which reads + // `clock.now()`; the constructor runs inside the alchemist `builder` (and, for + // the date picker, `ValueListenableBuilder` re-reads `clock.now()` for + // `lastDate` on each rebuild). Both are pinned via `withClock`. + final pinnedClock = Clock.fixed(DateTime.utc(2026, 5, 23)); + + Widget buildSubject() => wrapForGolden( + MultiBlocProvider( + providers: [ + BlocProvider.value(value: settingsBloc), + BlocProvider.value(value: taxReportCubit), + ], + child: SettingsTaxReportView(), + ), + ); + + group('$SettingsTaxReportPage', () { + // Emitting `SettingsTaxReportFailure` drives the BlocListener + // (`settings_tax_report_page.dart:38-45`) to show the red failure SnackBar. + // pumpAndSettle runs the entrance animation to completion (it does not + // dismiss the SnackBar — the 4s auto-dismiss is a Timer, not a frame). + goldenTest( + 'failure SnackBar (red)', + fileName: 'settings_tax_report_page_failure_snackbar', + constraints: phoneConstraints, + pumpBeforeTest: (tester) => withClock(pinnedClock, () async { + await tester.pump(); // deliver the whenListen emission to the listener + await tester.pumpAndSettle(); // run the SnackBar entrance to completion + }), + builder: () { + whenListen( + taxReportCubit, + Stream.value( + const SettingsTaxReportFailure('Konnte Steuerausweis nicht generieren.'), + ), + initialState: const SettingsTaxReportInitial(), + ); + return withClock(pinnedClock, buildSubject); + }, + ); + + // Tapping the DatePickerField opens the platform date picker. Headless the + // `DeviceInfo.instance.isIOS` guard (`date_picker.dart:13`) is false, so the + // deterministic Material `showDatePicker` dialog is what renders — pinned to + // the initial date (31 Dec 2025) via the fixed clock. + goldenTest( + 'date picker overlay (Material dialog)', + fileName: 'settings_tax_report_page_date_picker', + constraints: phoneConstraints, + pumpBeforeTest: (tester) => withClock(pinnedClock, () async { + await tester.pumpAndSettle(); + await tester.tap(find.byType(DatePickerField)); + await tester.pumpAndSettle(); + }), + builder: () => withClock(pinnedClock, buildSubject), + ); + }); +} diff --git a/test/goldens/screens/settings_user_data/goldens/macos/settings_edit_address_page_default.png b/test/goldens/screens/settings_user_data/goldens/macos/settings_edit_address_page_default.png index 4a8e66504..84dd87ac4 100644 Binary files a/test/goldens/screens/settings_user_data/goldens/macos/settings_edit_address_page_default.png and b/test/goldens/screens/settings_user_data/goldens/macos/settings_edit_address_page_default.png differ diff --git a/test/goldens/screens/settings_user_data/goldens/macos/settings_edit_failure_page_default.png b/test/goldens/screens/settings_user_data/goldens/macos/settings_edit_failure_page_default.png index 065eaf78f..7e91fc417 100644 Binary files a/test/goldens/screens/settings_user_data/goldens/macos/settings_edit_failure_page_default.png and b/test/goldens/screens/settings_user_data/goldens/macos/settings_edit_failure_page_default.png differ diff --git a/test/goldens/screens/settings_user_data/goldens/macos/settings_edit_pending_page_default.png b/test/goldens/screens/settings_user_data/goldens/macos/settings_edit_pending_page_default.png index 058437e58..8ece13c29 100644 Binary files a/test/goldens/screens/settings_user_data/goldens/macos/settings_edit_pending_page_default.png and b/test/goldens/screens/settings_user_data/goldens/macos/settings_edit_pending_page_default.png differ diff --git a/test/goldens/screens/settings_user_data/goldens/macos/settings_user_data_page_bitbox_disconnected.png b/test/goldens/screens/settings_user_data/goldens/macos/settings_user_data_page_bitbox_disconnected.png new file mode 100644 index 000000000..f5eac39db Binary files /dev/null and b/test/goldens/screens/settings_user_data/goldens/macos/settings_user_data_page_bitbox_disconnected.png differ diff --git a/test/goldens/screens/settings_user_data/goldens/macos/settings_user_data_page_editable.png b/test/goldens/screens/settings_user_data/goldens/macos/settings_user_data_page_editable.png new file mode 100644 index 000000000..c542e28ce Binary files /dev/null and b/test/goldens/screens/settings_user_data/goldens/macos/settings_user_data_page_editable.png differ diff --git a/test/goldens/screens/settings_user_data/goldens/macos/settings_user_data_page_email_only.png b/test/goldens/screens/settings_user_data/goldens/macos/settings_user_data_page_email_only.png new file mode 100644 index 000000000..795540583 Binary files /dev/null and b/test/goldens/screens/settings_user_data/goldens/macos/settings_user_data_page_email_only.png differ diff --git a/test/goldens/screens/settings_user_data/goldens/macos/settings_user_data_page_empty.png b/test/goldens/screens/settings_user_data/goldens/macos/settings_user_data_page_empty.png new file mode 100644 index 000000000..29e57b471 Binary files /dev/null and b/test/goldens/screens/settings_user_data/goldens/macos/settings_user_data_page_empty.png differ diff --git a/test/goldens/screens/settings_user_data/goldens/macos/settings_user_data_page_failure.png b/test/goldens/screens/settings_user_data/goldens/macos/settings_user_data_page_failure.png new file mode 100644 index 000000000..10b480431 Binary files /dev/null and b/test/goldens/screens/settings_user_data/goldens/macos/settings_user_data_page_failure.png differ diff --git a/test/goldens/screens/settings_user_data/goldens/macos/settings_user_data_page_loading.png b/test/goldens/screens/settings_user_data/goldens/macos/settings_user_data_page_loading.png new file mode 100644 index 000000000..b69186fb1 Binary files /dev/null and b/test/goldens/screens/settings_user_data/goldens/macos/settings_user_data_page_loading.png differ diff --git a/test/goldens/screens/settings_user_data/goldens/macos/settings_user_data_page_no_birthday.png b/test/goldens/screens/settings_user_data/goldens/macos/settings_user_data_page_no_birthday.png new file mode 100644 index 000000000..030e8cb77 Binary files /dev/null and b/test/goldens/screens/settings_user_data/goldens/macos/settings_user_data_page_no_birthday.png differ diff --git a/test/goldens/screens/settings_user_data/goldens/macos/settings_user_data_page_pending.png b/test/goldens/screens/settings_user_data/goldens/macos/settings_user_data_page_pending.png new file mode 100644 index 000000000..380711603 Binary files /dev/null and b/test/goldens/screens/settings_user_data/goldens/macos/settings_user_data_page_pending.png differ diff --git a/test/goldens/screens/settings_user_data/settings_user_data_states_golden_test.dart b/test/goldens/screens/settings_user_data/settings_user_data_states_golden_test.dart new file mode 100644 index 000000000..beb8e1116 --- /dev/null +++ b/test/goldens/screens/settings_user_data/settings_user_data_states_golden_test.dart @@ -0,0 +1,174 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_country_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_kyc_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/country/country.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/kyc/kyc_level.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/registration/registration_user_type.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/user/dto/user_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/user/user_data.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_registration_service.dart'; +import 'package:realunit_wallet/screens/settings_user_data/cubit/settings_user_data_cubit.dart'; +import 'package:realunit_wallet/screens/settings_user_data/settings_user_data_page.dart'; + +import '../../../helper/helper.dart'; + +class _MockSettingsUserDataCubit extends MockCubit + implements SettingsUserDataCubit {} + +class _MockRealUnitRegistrationService extends Mock implements RealUnitRegistrationService {} + +class _MockDfxKycService extends Mock implements DfxKycService {} + +void main() { + // The base `settings_user_data_page_default` golden lives in + // `settings_user_data_golden_test.dart` and renders a fully-populated + // `SettingsUserDataSuccess` with the conservative default capabilities + // (all `canEdit*` false → no Edit buttons). This file covers the remaining + // build branches of `SettingsUserDataView.build` (see + // `settings_user_data_page.dart:46-153`). + const country = Country( + id: 41, + symbol: 'CH', + name: 'Switzerland', + kycAllowed: true, + ); + + UserData buildUserData({DateTime? birthday}) => UserData( + email: 'test-direct@dfx.swiss', + name: 'Test Direct', + type: RegistrationUserType.human, + phoneNumber: '+41791234567', + birthday: birthday, + nationality: country, + addressStreet: 'Teststrasse 1', + addressPostalCode: '8000', + addressCity: 'Zurich', + addressCountry: country, + swissTaxResidence: true, + lang: 'DE', + ); + + late _MockSettingsUserDataCubit cubit; + + setUp(() { + cubit = _MockSettingsUserDataCubit(); + when(() => cubit.getUserData()).thenAnswer((_) => Future.value()); + }); + + setUpAll(() { + final getIt = GetIt.instance; + getIt.registerSingleton(_MockRealUnitRegistrationService()); + getIt.registerSingleton(fixtureCountryService()); + getIt.registerSingleton(_MockDfxKycService()); + }); + + tearDownAll(() async { + await GetIt.instance.reset(); + }); + + Widget buildSubject(SettingsUserDataState state) { + when(() => cubit.state).thenReturn(state); + return wrapForGolden( + BlocProvider.value( + value: cubit, + child: const SettingsUserDataView(), + ), + ); + } + + group('$SettingsUserDataPage', () { + // `canEdit*` true → all three Edit buttons render (page:72-123). + goldenTest( + 'success with edit buttons (all capabilities enabled)', + fileName: 'settings_user_data_page_editable', + constraints: phoneConstraints, + builder: () => buildSubject( + SettingsUserDataSuccess( + userData: buildUserData(birthday: DateTime.utc(1990, 1, 1)), + capabilities: const UserCapabilitiesDto( + canEditName: true, + canEditPhone: true, + canEditAddress: true, + ), + ), + ), + ); + + // `pendingSteps` contains name+address change → "Change in review" badge + // renders next to the Name and Residence rows (page:69-71, 111-113). + goldenTest( + 'success with pending change badges', + fileName: 'settings_user_data_page_pending', + constraints: phoneConstraints, + builder: () => buildSubject( + SettingsUserDataSuccess( + userData: buildUserData(birthday: DateTime.utc(1990, 1, 1)), + pendingSteps: const {KycStepName.nameChange, KycStepName.addressChange}, + capabilities: const UserCapabilitiesDto( + canEditName: true, + canEditPhone: true, + canEditAddress: true, + ), + ), + ), + ); + + // `userData.birthday == null` → the Birthday row is dropped (page:83). + goldenTest( + 'success without a birthday row', + fileName: 'settings_user_data_page_no_birthday', + constraints: phoneConstraints, + builder: () => buildSubject( + SettingsUserDataSuccess(userData: buildUserData()), + ), + ); + + // `userData == null` but `email != null` → only the E-Mail row (page:130-139). + goldenTest( + 'success with only an email (no user data)', + fileName: 'settings_user_data_page_email_only', + constraints: phoneConstraints, + builder: () => buildSubject( + const SettingsUserDataSuccess(email: 'test-direct@dfx.swiss'), + ), + ); + + // `userData == null` and `email == null` → "No user data" copy (page:140-142). + goldenTest( + 'success empty (no user data, no email)', + fileName: 'settings_user_data_page_empty', + constraints: phoneConstraints, + builder: () => buildSubject(const SettingsUserDataSuccess()), + ); + + // Loading → CupertinoActivityIndicator (page:143-145); freeze the spinner. + goldenTest( + 'loading', + fileName: 'settings_user_data_page_loading', + constraints: phoneConstraints, + pumpBeforeTest: pumpOnce, + builder: () => buildSubject(const SettingsUserDataLoading()), + ); + + // BitboxDisconnected → _BitboxDisconnectedView (page:146-148, 161-199). + goldenTest( + 'bitbox disconnected', + fileName: 'settings_user_data_page_bitbox_disconnected', + constraints: phoneConstraints, + builder: () => buildSubject(const SettingsUserDataBitboxDisconnected()), + ); + + // Failure → userDataLoadFailed copy (page:149-151). + goldenTest( + 'failure', + fileName: 'settings_user_data_page_failure', + constraints: phoneConstraints, + builder: () => buildSubject(const SettingsUserDataFailure()), + ); + }); +} diff --git a/test/goldens/screens/support/goldens/macos/support_chat_page_closed.png b/test/goldens/screens/support/goldens/macos/support_chat_page_closed.png new file mode 100644 index 000000000..dcb576998 Binary files /dev/null and b/test/goldens/screens/support/goldens/macos/support_chat_page_closed.png differ diff --git a/test/goldens/screens/support/goldens/macos/support_chat_page_error.png b/test/goldens/screens/support/goldens/macos/support_chat_page_error.png new file mode 100644 index 000000000..b9d1c37ab Binary files /dev/null and b/test/goldens/screens/support/goldens/macos/support_chat_page_error.png differ diff --git a/test/goldens/screens/support/goldens/macos/support_chat_page_loaded.png b/test/goldens/screens/support/goldens/macos/support_chat_page_loaded.png new file mode 100644 index 000000000..dc1f1b2a7 Binary files /dev/null and b/test/goldens/screens/support/goldens/macos/support_chat_page_loaded.png differ diff --git a/test/goldens/screens/support/goldens/macos/support_chat_page_sending.png b/test/goldens/screens/support/goldens/macos/support_chat_page_sending.png new file mode 100644 index 000000000..c3aa21fdc Binary files /dev/null and b/test/goldens/screens/support/goldens/macos/support_chat_page_sending.png differ diff --git a/test/goldens/screens/support/goldens/macos/support_create_ticket_page_filled.png b/test/goldens/screens/support/goldens/macos/support_create_ticket_page_filled.png new file mode 100644 index 000000000..ce1118dd9 Binary files /dev/null and b/test/goldens/screens/support/goldens/macos/support_create_ticket_page_filled.png differ diff --git a/test/goldens/screens/support/goldens/macos/support_create_ticket_page_submitting.png b/test/goldens/screens/support/goldens/macos/support_create_ticket_page_submitting.png new file mode 100644 index 000000000..e95004ec2 Binary files /dev/null and b/test/goldens/screens/support/goldens/macos/support_create_ticket_page_submitting.png differ diff --git a/test/goldens/screens/support/goldens/macos/support_email_capture_page_buy_flow.png b/test/goldens/screens/support/goldens/macos/support_email_capture_page_buy_flow.png new file mode 100644 index 000000000..10c5d65c0 Binary files /dev/null and b/test/goldens/screens/support/goldens/macos/support_email_capture_page_buy_flow.png differ diff --git a/test/goldens/screens/support/goldens/macos/support_tickets_page_error.png b/test/goldens/screens/support/goldens/macos/support_tickets_page_error.png new file mode 100644 index 000000000..bc8604615 Binary files /dev/null and b/test/goldens/screens/support/goldens/macos/support_tickets_page_error.png differ diff --git a/test/goldens/screens/support/goldens/macos/support_tickets_page_loaded.png b/test/goldens/screens/support/goldens/macos/support_tickets_page_loaded.png new file mode 100644 index 000000000..0e0c43a82 Binary files /dev/null and b/test/goldens/screens/support/goldens/macos/support_tickets_page_loaded.png differ diff --git a/test/goldens/screens/support/goldens/macos/support_tickets_page_loading.png b/test/goldens/screens/support/goldens/macos/support_tickets_page_loading.png new file mode 100644 index 000000000..3be23c616 Binary files /dev/null and b/test/goldens/screens/support/goldens/macos/support_tickets_page_loading.png differ diff --git a/test/goldens/screens/support/support_chat_golden_test.dart b/test/goldens/screens/support/support_chat_golden_test.dart index 7597a4bf9..28893fd9a 100644 --- a/test/goldens/screens/support/support_chat_golden_test.dart +++ b/test/goldens/screens/support/support_chat_golden_test.dart @@ -3,6 +3,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/support/support_issue.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/support/support_issue_reason.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/support/support_issue_state.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/support/support_issue_type.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/support/support_message.dart'; import 'package:realunit_wallet/screens/support/cubits/support_chat/support_chat_cubit.dart'; import 'package:realunit_wallet/screens/support/cubits/support_chat/support_chat_state.dart'; import 'package:realunit_wallet/screens/support/subpages/support_chat_page.dart'; @@ -12,6 +17,39 @@ import '../../../helper/helper.dart'; class _MockSupportChatCubit extends MockCubit implements SupportChatCubit {} +// Deterministic two-message conversation. The customer bubble (author == +// `customerAuthor`) renders blue and right-aligned; the support bubble renders +// grey, left-aligned, with the "Support" label above it. `created` is a fixed +// UTC instant and the bubble formats it via `toLocal()`, so the rendered HH:MM +// is stable across render dates: these March 2024 instants (before that year's +// DST switch) always show CET, i.e. 08:15/08:42 UTC render as 09:15 and 09:42 +// on the Europe/Zurich runner regardless of when the golden is captured. +List _conversation() => [ + SupportMessage( + id: 1, + author: customerAuthor, + created: DateTime.utc(2024, 3, 4, 8, 15), + message: 'Meine Auszahlung ist noch nicht angekommen.', + ), + SupportMessage( + id: 2, + author: 'RealUnit Support', + created: DateTime.utc(2024, 3, 4, 8, 42), + message: + 'Guten Tag, wir prüfen Ihre Transaktion und melden uns in Kürze bei Ihnen.', + ), + ]; + +SupportIssue _ticket(SupportIssueState state) => SupportIssue( + uid: 'ticket-1', + state: state, + type: SupportIssueType.transactionIssue, + reason: SupportIssueReason.fundsNotReceived, + name: 'Transaction Issue', + created: DateTime.utc(2024, 3, 4, 8, 15), + messages: _conversation(), + ); + void main() { late _MockSupportChatCubit cubit; @@ -25,6 +63,16 @@ void main() { child: const SupportChatView(), ); + // The chat bubbles cap their width at `MediaQuery.size.width * 0.75`. + // Alchemist renders inside the 390-wide `constraints` but leaves MediaQuery at + // the default test-window width (800), which would size the bubble wider than + // the visible area and overflow the row. Pin MediaQuery to the phone size so + // the bubbles wrap exactly as they do on a real 390-wide device. + Widget buildPhoneSubject() => MediaQuery( + data: const MediaQueryData(size: Size(390, 844)), + child: buildSubject(), + ); + group('$SupportChatView', () { goldenTest( 'loading state', @@ -35,5 +83,59 @@ void main() { constraints: const BoxConstraints.tightFor(width: 390, height: 844), builder: () => wrapForGolden(buildSubject()), ); + + goldenTest( + 'error state — centered failure text', + fileName: 'support_chat_page_error', + constraints: phoneConstraints, + builder: () { + when(() => cubit.state).thenReturn( + const SupportChatError('Chat konnte nicht geladen werden.'), + ); + return wrapForGolden(buildSubject()); + }, + ); + + goldenTest( + 'loaded open ticket — customer/support bubbles + input field', + fileName: 'support_chat_page_loaded', + constraints: phoneConstraints, + builder: () { + when(() => cubit.state).thenReturn( + SupportChatLoaded(ticket: _ticket(SupportIssueState.created)), + ); + return wrapForGolden(buildPhoneSubject()); + }, + ); + + goldenTest( + 'loaded open ticket sending — disabled field + spinner', + fileName: 'support_chat_page_sending', + // The send button hosts a CupertinoActivityIndicator while sending; + // freeze it on the first frame instead of letting pumpAndSettle hang. + pumpBeforeTest: pumpOnce, + constraints: phoneConstraints, + builder: () { + when(() => cubit.state).thenReturn( + SupportChatLoaded( + ticket: _ticket(SupportIssueState.created), + isSending: true, + ), + ); + return wrapForGolden(buildPhoneSubject()); + }, + ); + + goldenTest( + 'loaded closed ticket — closed banner instead of input field', + fileName: 'support_chat_page_closed', + constraints: phoneConstraints, + builder: () { + when(() => cubit.state).thenReturn( + SupportChatLoaded(ticket: _ticket(SupportIssueState.completed)), + ); + return wrapForGolden(buildPhoneSubject()); + }, + ); }); } diff --git a/test/goldens/screens/support/support_create_ticket_golden_test.dart b/test/goldens/screens/support/support_create_ticket_golden_test.dart index 7a1e8b325..3e8f8ef7b 100644 --- a/test/goldens/screens/support/support_create_ticket_golden_test.dart +++ b/test/goldens/screens/support/support_create_ticket_golden_test.dart @@ -3,6 +3,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/support/support_issue_reason.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/support/support_issue_type.dart'; import 'package:realunit_wallet/screens/support/cubits/support_create_ticket/support_create_ticket_cubit.dart'; import 'package:realunit_wallet/screens/support/cubits/support_create_ticket/support_create_ticket_state.dart'; import 'package:realunit_wallet/screens/support/subpages/support_create_ticket_page.dart'; @@ -32,5 +34,46 @@ void main() { constraints: const BoxConstraints.tightFor(width: 390, height: 844), builder: () => wrapForGolden(buildSubject()), ); + + // `selectType` sets `selectedReason` to `other` alongside `selectedType`, + // and a non-empty `message` flips `canSubmit` to true, so the Send button + // renders enabled. The message TextField renders empty because the field is + // not bound to state (it drives the cubit via `onChanged` only); the + // enabled button is what proves the filled state. + goldenTest( + 'filled form — type tag selected, send button enabled', + fileName: 'support_create_ticket_page_filled', + constraints: phoneConstraints, + builder: () { + when(() => cubit.state).thenReturn( + const SupportCreateTicketState( + selectedType: SupportIssueType.genericIssue, + selectedReason: SupportIssueReason.other, + message: 'Ich habe eine Frage zu meinem Konto.', + ), + ); + return wrapForGolden(buildSubject()); + }, + ); + + goldenTest( + 'submitting — send button shows the loading spinner', + fileName: 'support_create_ticket_page_submitting', + // The loading button hosts a CupertinoActivityIndicator; freeze it on the + // first frame instead of letting pumpAndSettle hang. + pumpBeforeTest: pumpOnce, + constraints: phoneConstraints, + builder: () { + when(() => cubit.state).thenReturn( + const SupportCreateTicketState( + selectedType: SupportIssueType.genericIssue, + selectedReason: SupportIssueReason.other, + message: 'Ich habe eine Frage zu meinem Konto.', + isSubmitting: true, + ), + ); + return wrapForGolden(buildSubject()); + }, + ); }); } diff --git a/test/goldens/screens/support/support_email_capture_golden_test.dart b/test/goldens/screens/support/support_email_capture_golden_test.dart index e607f9184..42081e3b6 100644 --- a/test/goldens/screens/support/support_email_capture_golden_test.dart +++ b/test/goldens/screens/support/support_email_capture_golden_test.dart @@ -3,6 +3,7 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; import 'package:realunit_wallet/screens/support/cubits/support_email_capture/support_email_capture_cubit.dart'; import 'package:realunit_wallet/screens/support/subpages/support_email_capture_page.dart'; @@ -42,5 +43,26 @@ void main() { return wrapForGolden(buildSubject()); }, ); + + // Buy-flow entry point: the caller passes the buy-confirm gate copy as the + // `description`, replacing the default Support wording. Same form, different + // explanatory text. The string is resolved via the localization delegate so + // the golden stays in sync with the production copy. + goldenTest( + 'buy-flow entry — alternate description copy', + fileName: 'support_email_capture_page_buy_flow', + constraints: phoneConstraints, + builder: () => wrapForGolden( + Builder( + builder: (context) => BlocProvider.value( + value: cubit, + child: SupportEmailCaptureView( + description: + S.of(context).buyPrimaryEmailRequiredCaptureDescription, + ), + ), + ), + ), + ); }); } diff --git a/test/goldens/screens/support/support_tickets_golden_test.dart b/test/goldens/screens/support/support_tickets_golden_test.dart index 7a0ee8245..20e52ddf2 100644 --- a/test/goldens/screens/support/support_tickets_golden_test.dart +++ b/test/goldens/screens/support/support_tickets_golden_test.dart @@ -3,6 +3,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/support/support_issue.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/support/support_issue_reason.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/support/support_issue_state.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/support/support_issue_type.dart'; import 'package:realunit_wallet/screens/support/cubits/support_tickets/support_tickets_cubit.dart'; import 'package:realunit_wallet/screens/support/cubits/support_tickets/support_tickets_state.dart'; import 'package:realunit_wallet/screens/support/subpages/support_tickets_page.dart'; @@ -12,6 +16,25 @@ import '../../../helper/helper.dart'; class _MockSupportTicketsCubit extends MockCubit implements SupportTicketsCubit {} +// A ticket for the list tile. Only `name`, `created` (rendered as +// `day.month.year` — timezone-independent) and `isOpen` (derived from `state`, +// drives the green/grey status dot) surface in the OutlinedTile. `messages` is +// empty because the list view never reads it. +SupportIssue _ticket({ + required String name, + required SupportIssueState state, + required DateTime created, +}) => + SupportIssue( + uid: name, + state: state, + type: SupportIssueType.transactionIssue, + reason: SupportIssueReason.fundsNotReceived, + name: name, + created: created, + messages: const [], + ); + void main() { late _MockSupportTicketsCubit cubit; @@ -32,5 +55,53 @@ void main() { constraints: const BoxConstraints.tightFor(width: 390, height: 844), builder: () => wrapForGolden(buildSubject()), ); + + goldenTest( + 'loading — centered spinner', + fileName: 'support_tickets_page_loading', + // The loading indicator is a CupertinoActivityIndicator; freeze it on the + // first frame instead of letting pumpAndSettle hang. + pumpBeforeTest: pumpOnce, + constraints: phoneConstraints, + builder: () { + when(() => cubit.state).thenReturn(const SupportTicketsLoading()); + return wrapForGolden(buildSubject()); + }, + ); + + goldenTest( + 'error — centered failure text', + fileName: 'support_tickets_page_error', + constraints: phoneConstraints, + builder: () { + when(() => cubit.state).thenReturn( + const SupportTicketsError('Tickets konnten nicht geladen werden.'), + ); + return wrapForGolden(buildSubject()); + }, + ); + + goldenTest( + 'loaded — open (green dot) and closed (grey dot) tickets', + fileName: 'support_tickets_page_loaded', + constraints: phoneConstraints, + builder: () { + when(() => cubit.state).thenReturn( + SupportTicketsLoaded([ + _ticket( + name: 'Transaction Issue', + state: SupportIssueState.created, + created: DateTime.utc(2024, 3, 4), + ), + _ticket( + name: 'KYC Issue', + state: SupportIssueState.completed, + created: DateTime.utc(2024, 2, 18), + ), + ]), + ); + return wrapForGolden(buildSubject()); + }, + ); }); } diff --git a/test/goldens/screens/transaction_history/goldens/macos/transaction_history_multi_receipt_loading.png b/test/goldens/screens/transaction_history/goldens/macos/transaction_history_multi_receipt_loading.png new file mode 100644 index 000000000..ce1c38fd3 Binary files /dev/null and b/test/goldens/screens/transaction_history/goldens/macos/transaction_history_multi_receipt_loading.png differ diff --git a/test/goldens/screens/transaction_history/goldens/macos/transaction_history_page_date_picker.png b/test/goldens/screens/transaction_history/goldens/macos/transaction_history_page_date_picker.png new file mode 100644 index 000000000..be819af3e Binary files /dev/null and b/test/goldens/screens/transaction_history/goldens/macos/transaction_history_page_date_picker.png differ diff --git a/test/goldens/screens/transaction_history/goldens/macos/transaction_history_page_list.png b/test/goldens/screens/transaction_history/goldens/macos/transaction_history_page_list.png new file mode 100644 index 000000000..17aa4140c Binary files /dev/null and b/test/goldens/screens/transaction_history/goldens/macos/transaction_history_page_list.png differ diff --git a/test/goldens/screens/transaction_history/goldens/macos/transaction_history_row_receipt_failure.png b/test/goldens/screens/transaction_history/goldens/macos/transaction_history_row_receipt_failure.png new file mode 100644 index 000000000..99398c48a Binary files /dev/null and b/test/goldens/screens/transaction_history/goldens/macos/transaction_history_row_receipt_failure.png differ diff --git a/test/goldens/screens/transaction_history/goldens/macos/transaction_history_row_receipt_loading.png b/test/goldens/screens/transaction_history/goldens/macos/transaction_history_row_receipt_loading.png new file mode 100644 index 000000000..4ca646463 Binary files /dev/null and b/test/goldens/screens/transaction_history/goldens/macos/transaction_history_row_receipt_loading.png differ diff --git a/test/goldens/screens/transaction_history/transaction_history_states_golden_test.dart b/test/goldens/screens/transaction_history/transaction_history_states_golden_test.dart new file mode 100644 index 000000000..faccdc1ee --- /dev/null +++ b/test/goldens/screens/transaction_history/transaction_history_states_golden_test.dart @@ -0,0 +1,269 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:clock/clock.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/models/transaction.dart'; +import 'package:realunit_wallet/packages/config/api_config.dart'; +import 'package:realunit_wallet/packages/repository/transaction_repository.dart'; +import 'package:realunit_wallet/packages/service/app_store.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_pdf_service.dart'; +import 'package:realunit_wallet/packages/utils/default_assets.dart'; +import 'package:realunit_wallet/screens/settings/bloc/settings_bloc.dart'; +import 'package:realunit_wallet/screens/transaction_history/cubits/filter/transaction_history_filter_cubit.dart'; +import 'package:realunit_wallet/screens/transaction_history/cubits/multi_receipt/transaction_history_multi_receipt_cubit.dart'; +import 'package:realunit_wallet/screens/transaction_history/cubits/receipt/transaction_history_receipt_cubit.dart'; +import 'package:realunit_wallet/screens/transaction_history/transaction_history_page.dart'; +import 'package:realunit_wallet/screens/transaction_history/widgets/transaction_history_download_button.dart'; +import 'package:realunit_wallet/screens/transaction_history/widgets/transaction_history_row.dart'; +import 'package:realunit_wallet/widgets/date_picker_field.dart'; + +import '../../../helper/helper.dart'; + +// `transaction_history_page_default` and `..._with_transactions` live in +// `transaction_history_golden_test.dart` — both mock the filter cubit with an +// empty `filtered` list, so they render the same empty page (the `_with_...` +// baseline never actually shows a row). This file adds the surfaces that base +// file leaves uncovered: a populated list, the per-row receipt spinner, the +// multi-receipt spinner, the receipt-failure SnackBar and the date picker. +// +// Determinism note — dates: `TransactionHistoryRow` formats via +// `DateFormat('MMM dd, yyyy | H:mm').format(transaction.timestamp)` +// (`transaction_history_row.dart:112`) — no `toLocal()`, no `now()`, no +// relative time. With fixed UTC instants the rendered text is host- and +// timezone-independent. `TransactionHistoryView` itself reads `clock.now()` +// for the date-picker bounds (`transaction_history_page.dart:36`), so its +// builders are pinned with `withClock`, matching the base file. + +class _MockTransactionHistoryFilterCubit + extends MockCubit + implements TransactionHistoryFilterCubit {} + +class _MockTransactionHistoryReceiptCubit + extends MockCubit + implements TransactionHistoryReceiptCubit {} + +class _MockTransactionHistoryMultiReceiptCubit + extends MockCubit + implements TransactionHistoryMultiReceiptCubit {} + +class _MockTransactionRepository extends Mock implements TransactionRepository {} + +class _MockRealUnitPdfService extends Mock implements RealUnitPdfService {} + +class _MockApiConfig extends Mock implements ApiConfig {} + +void main() { + const walletAddress = '0xcabd3f4b10a7089986e708d19140bfc98e5880c0'; + const counterparty = '0x1234567890abcdef1234567890abcdef12345678'; + + late MockSettingsBloc settingsBloc; + final transactionRepository = _MockTransactionRepository(); + + // decimals of realUnitAsset is 0 → amounts are plain share counts. + Transaction buy(String txId, int shares, DateTime timestamp) => Transaction( + height: 200, + txId: txId, + chainId: 1, + senderAddress: counterparty, + receiverAddress: walletAddress, + amount: BigInt.from(shares), + asset: realUnitAsset, + type: TransactionTypes.tokenTransfer, + note: null, + data: null, + timestamp: timestamp, + ); + + Transaction sell(String txId, int shares, DateTime timestamp) => Transaction( + height: 199, + txId: txId, + chainId: 1, + senderAddress: walletAddress, + receiverAddress: counterparty, + amount: BigInt.from(shares), + asset: realUnitAsset, + type: TransactionTypes.tokenTransfer, + note: null, + data: null, + timestamp: timestamp, + ); + + final transactions = [ + buy('0xtx1', 50, DateTime.utc(2026, 5, 20, 10, 30)), + sell('0xtx2', 20, DateTime.utc(2026, 5, 18, 14)), + buy('0xtx3', 100, DateTime.utc(2026, 5, 15, 9, 15)), + ]; + + // `TransactionHistoryView` field-initializes its date models from + // `clock.now()` when constructed inside the alchemist builder; pin it so the + // date-picker fields (and, for the picker overlay, `showDatePicker`) render + // deterministically. Same fixed instant as the base file. + final pinnedClock = Clock.fixed(DateTime.utc(2026, 5, 23)); + + setUpAll(() { + final getIt = GetIt.instance; + final apiConfig = _MockApiConfig(); + final appStore = MockAppStore(); + when(() => apiConfig.asset).thenReturn(realUnitAsset); + when(() => appStore.apiConfig).thenReturn(apiConfig); + when(() => appStore.primaryAddress).thenReturn(walletAddress); + when(() => transactionRepository.watchTransactionsOfAssets(any(), any())) + .thenAnswer((_) => const Stream>.empty()); + getIt.registerSingleton(appStore); + getIt.registerSingleton(_MockRealUnitPdfService()); + getIt.registerSingleton(transactionRepository); + }); + + tearDownAll(() async => GetIt.instance.reset()); + + setUp(() { + settingsBloc = MockSettingsBloc(); + when(() => settingsBloc.state).thenReturn(const SettingsState()); + }); + + // ---- A) TransactionHistoryView: populated list + date picker ---- + group('$TransactionHistoryView', () { + late _MockTransactionHistoryFilterCubit filterCubit; + + setUp(() { + filterCubit = _MockTransactionHistoryFilterCubit(); + when(() => filterCubit.state).thenReturn(TransactionHistoryFilterState()); + }); + + Widget buildSubject() => MultiBlocProvider( + providers: [ + BlocProvider.value(value: settingsBloc), + BlocProvider.value(value: filterCubit), + ], + child: TransactionHistoryView(walletAddress: walletAddress), + ); + + goldenTest( + 'populated transaction list', + fileName: 'transaction_history_page_list', + constraints: phoneConstraints, + builder: () { + when(() => filterCubit.state).thenReturn( + TransactionHistoryFilterState(all: transactions, filtered: transactions), + ); + return withClock(pinnedClock, () => wrapForGolden(buildSubject())); + }, + ); + + // Tapping a DatePickerField opens the platform date picker. Headless the + // `DeviceInfo.instance.isIOS` guard (`date_picker.dart:13`) is false, so the + // deterministic Material `showDatePicker` dialog renders — pinned to the + // start field's initial date (23 May 2025) via the fixed clock. + goldenTest( + 'date picker overlay (Material dialog)', + fileName: 'transaction_history_page_date_picker', + constraints: phoneConstraints, + pumpBeforeTest: (tester) => withClock(pinnedClock, () async { + await tester.pumpAndSettle(); + await tester.tap(find.byType(DatePickerField).first); + await tester.pumpAndSettle(); + }), + builder: () => withClock(pinnedClock, () => wrapForGolden(buildSubject())), + ); + }); + + // ---- B) TransactionHistoryRowView: per-row receipt spinner + failure ---- + group('$TransactionHistoryRowView', () { + late _MockTransactionHistoryReceiptCubit receiptCubit; + + setUp(() { + receiptCubit = _MockTransactionHistoryReceiptCubit(); + when(() => receiptCubit.state) + .thenReturn(const TransactionHistoryReceiptInitial()); + }); + + Widget rowSubject() => wrapForGolden( + Scaffold( + body: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20.0, vertical: 24.0), + child: MultiBlocProvider( + providers: [ + BlocProvider.value(value: settingsBloc), + BlocProvider.value(value: receiptCubit), + ], + child: TransactionHistoryRowView( + transaction: transactions.first, + isOutbound: false, + ), + ), + ), + ), + ); + + goldenTest( + 'receipt generating — 12px spinner replaces the download icon', + fileName: 'transaction_history_row_receipt_loading', + // The 12px CircularProgressIndicator never settles; freeze the first frame. + pumpBeforeTest: pumpOnce, + constraints: phoneConstraints, + builder: () { + when(() => receiptCubit.state) + .thenReturn(const TransactionHistoryReceiptLoading()); + return rowSubject(); + }, + ); + + // Emitting a failure drives the BlocConsumer listener + // (`transaction_history_row.dart:52-59`) to show the red error SnackBar. + goldenTest( + 'receipt failure SnackBar (red)', + fileName: 'transaction_history_row_receipt_failure', + constraints: phoneConstraints, + pumpBeforeTest: (tester) async { + await tester.pump(); // deliver the whenListen emission to the listener + await tester.pumpAndSettle(); // run the SnackBar entrance to completion + }, + builder: () { + whenListen( + receiptCubit, + Stream.value( + const TransactionHistoryReceiptFailure('Beleg konnte nicht erstellt werden.'), + ), + initialState: const TransactionHistoryReceiptInitial(), + ); + return rowSubject(); + }, + ); + }); + + // ---- C) TransactionHistoryDownloadButtonView: multi-receipt spinner ---- + group('$TransactionHistoryDownloadButtonView', () { + late _MockTransactionHistoryMultiReceiptCubit multiReceiptCubit; + + setUp(() { + multiReceiptCubit = _MockTransactionHistoryMultiReceiptCubit(); + when(() => multiReceiptCubit.state) + .thenReturn(const TransactionHistoryMultiReceiptInitial()); + }); + + goldenTest( + 'multi-receipt generating — 44x44 CircularProgressIndicator', + fileName: 'transaction_history_multi_receipt_loading', + // The CircularProgressIndicator never settles; freeze the first frame. + pumpBeforeTest: pumpOnce, + constraints: phoneConstraints, + builder: () { + when(() => multiReceiptCubit.state) + .thenReturn(const TransactionHistoryMultiReceiptLoading()); + return wrapForGolden( + Scaffold( + body: Center( + child: BlocProvider.value( + value: multiReceiptCubit, + child: TransactionHistoryDownloadButtonView(transactions: transactions), + ), + ), + ), + ); + }, + ); + }); +} diff --git a/test/goldens/screens/verify_seed/goldens/macos/verify_seed_page_commit_failed.png b/test/goldens/screens/verify_seed/goldens/macos/verify_seed_page_commit_failed.png new file mode 100644 index 000000000..0060a5457 Binary files /dev/null and b/test/goldens/screens/verify_seed/goldens/macos/verify_seed_page_commit_failed.png differ diff --git a/test/goldens/screens/verify_seed/goldens/macos/verify_seed_page_empty.png b/test/goldens/screens/verify_seed/goldens/macos/verify_seed_page_empty.png new file mode 100644 index 000000000..f69183db8 Binary files /dev/null and b/test/goldens/screens/verify_seed/goldens/macos/verify_seed_page_empty.png differ diff --git a/test/goldens/screens/verify_seed/goldens/macos/verify_seed_page_error.png b/test/goldens/screens/verify_seed/goldens/macos/verify_seed_page_error.png new file mode 100644 index 000000000..61e713f0c Binary files /dev/null and b/test/goldens/screens/verify_seed/goldens/macos/verify_seed_page_error.png differ diff --git a/test/goldens/screens/verify_seed/goldens/macos/verify_seed_page_verified.png b/test/goldens/screens/verify_seed/goldens/macos/verify_seed_page_verified.png new file mode 100644 index 000000000..c5b0d62f1 Binary files /dev/null and b/test/goldens/screens/verify_seed/goldens/macos/verify_seed_page_verified.png differ diff --git a/test/goldens/screens/verify_seed/goldens/macos/verify_seed_page_verifying.png b/test/goldens/screens/verify_seed/goldens/macos/verify_seed_page_verifying.png new file mode 100644 index 000000000..7bb41bdab Binary files /dev/null and b/test/goldens/screens/verify_seed/goldens/macos/verify_seed_page_verifying.png differ diff --git a/test/goldens/screens/verify_seed/verify_seed_golden_test.dart b/test/goldens/screens/verify_seed/verify_seed_golden_test.dart index c98d3f446..6cb8110e8 100644 --- a/test/goldens/screens/verify_seed/verify_seed_golden_test.dart +++ b/test/goldens/screens/verify_seed/verify_seed_golden_test.dart @@ -61,5 +61,86 @@ void main() { return wrapForGolden(buildSubject()); }, ); + + goldenTest( + 'wrong words paint the fields red and show the invalid button', + fileName: 'verify_seed_page_error', + constraints: const BoxConstraints.tightFor(width: 390, height: 844), + builder: () { + when(() => verifySeedCubit.state).thenReturn( + const VerifySeedState( + wordIndices: [1, 3, 5, 7], + enteredWords: ['abandon', 'ability', 'about', 'above'], + hasError: true, + ), + ); + return wrapForGolden(buildSubject()); + }, + ); + + goldenTest( + 'verifying state shows the loading button', + fileName: 'verify_seed_page_verifying', + // The loading button hosts a CupertinoActivityIndicator that never + // settles; pump once to capture the initial frame. + pumpBeforeTest: pumpOnce, + constraints: const BoxConstraints.tightFor(width: 390, height: 844), + builder: () { + when(() => verifySeedCubit.state).thenReturn( + const VerifySeedState( + wordIndices: [1, 3, 5, 7], + enteredWords: ['abandon', 'ability', 'about', 'above'], + isVerifying: true, + ), + ); + return wrapForGolden(buildSubject()); + }, + ); + + // The success button is visible for 2s before navigation. The mocked cubit + // emits no state change, so the isVerified listener (which would await that + // 2s delay, then dispatch LoadWalletEvent) never fires and the green + // success button renders stably. + goldenTest( + 'verified state shows the green success button', + fileName: 'verify_seed_page_verified', + constraints: const BoxConstraints.tightFor(width: 390, height: 844), + builder: () { + when(() => verifySeedCubit.state).thenReturn( + const VerifySeedState( + wordIndices: [1, 3, 5, 7], + enteredWords: ['abandon', 'ability', 'about', 'above'], + isVerified: true, + ), + ); + return wrapForGolden(buildSubject()); + }, + ); + + goldenTest( + 'commit failure shows an idle retry button with a refresh icon', + fileName: 'verify_seed_page_commit_failed', + constraints: const BoxConstraints.tightFor(width: 390, height: 844), + builder: () { + when(() => verifySeedCubit.state).thenReturn( + const VerifySeedState( + wordIndices: [1, 3, 5, 7], + enteredWords: ['abandon', 'ability', 'about', 'above'], + commitFailed: true, + ), + ); + return wrapForGolden(buildSubject()); + }, + ); + + goldenTest( + 'empty word indices render nothing but the scaffold', + fileName: 'verify_seed_page_empty', + constraints: const BoxConstraints.tightFor(width: 390, height: 844), + builder: () { + when(() => verifySeedCubit.state).thenReturn(const VerifySeedState()); + return wrapForGolden(buildSubject()); + }, + ); }); } diff --git a/test/helper/helper.dart b/test/helper/helper.dart index 2725500bd..e94830116 100644 --- a/test/helper/helper.dart +++ b/test/helper/helper.dart @@ -3,5 +3,8 @@ export 'golden_constants.dart'; export 'golden_mocks.dart'; export 'golden_plugin_stubs.dart'; export 'golden_test_with_assets.dart'; +export 'layout_assertions.dart'; export 'pump_app.dart'; export 'pump_golden_app.dart'; +export 'responsive_matrix.dart'; +export 'responsive_surface_catalog.dart'; diff --git a/test/helper/layout_assertions.dart b/test/helper/layout_assertions.dart new file mode 100644 index 000000000..4bd93030e --- /dev/null +++ b/test/helper/layout_assertions.dart @@ -0,0 +1,188 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/styles/themes.dart'; + +/// Asserts that no [RenderFlex] overflow was reported during [body]. +/// +/// Catches the class of bugs where content is painted below a clipped sheet +/// and buttons stop receiving taps. +Future expectNoLayoutOverflow( + WidgetTester tester, + Future Function() body, { + String? reason, +}) async { + final overflows = []; + final previous = FlutterError.onError; + FlutterError.onError = (details) { + final message = details.exceptionAsString(); + if (message.contains('overflowed') || message.contains('OVERFLOWING')) { + overflows.add(message.split('\n').first); + } + previous?.call(details); + }; + try { + await body(); + // Allow a frame for flex overflow reporting. + await tester.pump(); + } finally { + FlutterError.onError = previous; + } + + expect( + overflows, + isEmpty, + reason: reason ?? + 'Expected no RenderFlex overflow, got:\n${overflows.join('\n')}', + ); +} + +/// Asserts [finder] resolves to a box with real size, fully inside [within], +/// and that a real pointer tap at its visual center actually reaches a render +/// object belonging to [finder]'s widget subtree (not just that *some* hit +/// path exists — [RenderView.hitTest] always returns a non-empty path, so +/// checking for non-emptiness alone proves nothing). [within] is required +/// because it is the only load-bearing containment check; callers must name +/// the sheet/page that bounds the tappable area. +/// +/// Prefer this over `tester.widget(...).onPressed?.call()`. +Future expectFullyTappable( + WidgetTester tester, + Finder finder, { + required Finder within, + String? reason, +}) async { + expect( + finder, + findsOneWidget, + reason: reason ?? 'tappable target not found: $finder', + ); + + final box = tester.renderObject(finder); + final rect = box.localToGlobal(Offset.zero) & box.size; + + expect( + rect.width, + greaterThan(0), + reason: reason ?? 'tappable target has zero width', + ); + expect( + rect.height, + greaterThan(0), + reason: reason ?? 'tappable target has zero height', + ); + + expect(within, findsOneWidget, reason: 'within parent not found'); + final parentBox = tester.renderObject(within); + final parentRect = parentBox.localToGlobal(Offset.zero) & parentBox.size; + // Allow 1px float tolerance. + final inflated = parentRect.inflate(1); + expect( + inflated.contains(rect.topLeft) && inflated.contains(rect.bottomRight), + isTrue, + reason: + reason ?? + 'target $rect is not fully inside parent $parentRect ' + '(clipped / overflowed — taps will miss)', + ); + + // Hit-test at the visual center and assert the hit path actually reaches a + // render object belonging to [finder]'s own element subtree — not merely + // that some (any) hit path exists, which is always true for RenderView. + final center = rect.center; + final result = HitTestResult(); + WidgetsBinding.instance.hitTestInView(result, center, tester.view.viewId); + + final targets = {}; + void collectRenderObjects(Element element) { + final renderObject = element.renderObject; + if (renderObject != null) { + targets.add(renderObject); + } + element.visitChildren(collectRenderObjects); + } + + collectRenderObjects(finder.evaluate().single); + + expect( + result.path.any((entry) => targets.contains(entry.target)), + isTrue, + reason: + reason ?? + 'hit test at $center did not reach $finder — the control is ' + 'covered, clipped, or outside the hit-test region', + ); + + await tester.tap(finder, warnIfMissed: true); + await tester.pump(); +} + +/// Runs [body] with [platform] active as the target-platform override. +/// +/// The override must span the whole test body: widgets that read +/// `defaultTargetPlatform` directly (e.g. `DeviceInfo.isIOS`, which selects a +/// different, longer iOS copy) re-read it on every rebuild, and +/// [expectFullyTappable] rebuilds while tapping. `addTearDown` cannot be used — +/// the framework's `_verifyInvariants()` runs before package:test tear-downs +/// and would trip `debugAssertAllFoundationVarsUnset`. +Future withTargetPlatform( + TargetPlatform platform, + Future Function() body, +) async { + final previous = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = platform; + try { + await body(); + } finally { + debugDefaultTargetPlatformOverride = previous; + } +} + +/// Pumps [widget] as a clipped bottom-sheet-like host (mirrors production +/// `showModalBottomSheet` clipping) under [mediaQuery], with real app +/// localizations wired up so widgets calling `S.of(context)` do not throw. +Future pumpClippedSheet( + WidgetTester tester, { + required Widget widget, + required MediaQueryData mediaQuery, + ThemeData? theme, + Locale locale = const Locale('de'), +}) async { + await tester.binding.setSurfaceSize(mediaQuery.size); + addTearDown(() async { + await tester.binding.setSurfaceSize(null); + }); + + await tester.pumpWidget( + MediaQuery( + data: mediaQuery, + child: MaterialApp( + theme: theme ?? realUnitTheme, + locale: locale, + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + supportedLocales: S.delegate.supportedLocales, + home: Scaffold( + body: Align( + alignment: Alignment.bottomCenter, + child: Material( + clipBehavior: Clip.antiAlias, + borderRadius: const BorderRadius.vertical(top: Radius.circular(32)), + child: widget, + ), + ), + ), + ), + ), + ); + // SVGs / first frame settle, matching the reference matrix test. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); +} diff --git a/test/helper/responsive_matrix.dart b/test/helper/responsive_matrix.dart new file mode 100644 index 000000000..b60da2d5d --- /dev/null +++ b/test/helper/responsive_matrix.dart @@ -0,0 +1,132 @@ +import 'package:flutter/material.dart'; + +/// Standard device profiles for layout / hit-test matrix tests. +/// +/// Covers the supported phone range (smallest → largest) on iOS and Android +/// with realistic view padding (notch / Dynamic Island / home indicator / +/// status + nav bars). Logical sizes match common Flutter device metrics. +@immutable +class DeviceProfile { + const DeviceProfile({ + required this.id, + required this.label, + required this.size, + required this.viewPadding, + required this.platform, + }); + + final String id; + final String label; + final Size size; + + /// System insets (status bar / island / home indicator / nav bar). + final EdgeInsets viewPadding; + final TargetPlatform platform; + + MediaQueryData get mediaQuery => MediaQueryData( + size: size, + padding: viewPadding, + viewPadding: viewPadding, + devicePixelRatio: 3, + ); +} + +/// Accessibility text scales from system "small" to extreme large accessibility. +/// +/// 1.0 = default; 2.0 = common "larger accessibility"; 3.0 = stress / max +/// accessibility on many OEMs. 0.85 covers "smaller" system text. +const kTextScaleFactors = [0.85, 1.0, 1.3, 2.0, 3.0]; + +/// Default scales used in CI matrix runs (full set). Prefer this over ad-hoc lists. +const kMatrixTextScales = kTextScaleFactors; + +/// Smallest → largest phones we claim to support. +const kIosDeviceProfiles = [ + DeviceProfile( + id: 'iphone_se_3', + label: 'iPhone SE (3rd gen)', + size: Size(375, 667), + viewPadding: EdgeInsets.only(top: 20, bottom: 0), + platform: TargetPlatform.iOS, + ), + DeviceProfile( + id: 'iphone_13_mini', + label: 'iPhone 13 mini', + size: Size(375, 812), + viewPadding: EdgeInsets.only(top: 50, bottom: 34), + platform: TargetPlatform.iOS, + ), + DeviceProfile( + id: 'iphone_15', + label: 'iPhone 15', + size: Size(393, 852), + viewPadding: EdgeInsets.only(top: 59, bottom: 34), + platform: TargetPlatform.iOS, + ), + DeviceProfile( + id: 'iphone_16_pro_max', + label: 'iPhone 16 Pro Max', + size: Size(440, 956), + viewPadding: EdgeInsets.only(top: 59, bottom: 34), + platform: TargetPlatform.iOS, + ), +]; + +const kAndroidDeviceProfiles = [ + DeviceProfile( + id: 'android_small', + label: 'Android compact (≈ Pixel 4a)', + size: Size(360, 760), + viewPadding: EdgeInsets.only(top: 24, bottom: 48), + platform: TargetPlatform.android, + ), + DeviceProfile( + id: 'android_medium', + label: 'Android medium (≈ Pixel 7)', + size: Size(412, 915), + viewPadding: EdgeInsets.only(top: 24, bottom: 48), + platform: TargetPlatform.android, + ), + DeviceProfile( + id: 'android_large', + label: 'Android large (≈ Pixel 8 Pro)', + size: Size(448, 998), + viewPadding: EdgeInsets.only(top: 24, bottom: 48), + platform: TargetPlatform.android, + ), +]; + +/// Full matrix: every standard phone profile we support. +const kAllDeviceProfiles = [ + ...kIosDeviceProfiles, + ...kAndroidDeviceProfiles, +]; + +/// One cell of the responsive matrix (device × text scale). +@immutable +class MatrixCell { + const MatrixCell(this.device, this.textScale); + + final DeviceProfile device; + final double textScale; + + String get id => '${device.id}@${textScale}x'; + + String get label => '${device.label}, textScale=$textScale'; + + MediaQueryData get mediaQuery => device.mediaQuery.copyWith( + textScaler: TextScaler.linear(textScale), + ); +} + +/// Cartesian product of [devices] × [textScales]. +List buildResponsiveMatrix({ + List devices = kAllDeviceProfiles, + List textScales = kMatrixTextScales, +}) => [ + for (final device in devices) + for (final scale in textScales) MatrixCell(device, scale), +]; + +/// CI-default full matrix (7 devices × 5 scales = 35 cells). +final kFullResponsiveMatrix = buildResponsiveMatrix(); diff --git a/test/helper/responsive_surface_catalog.dart b/test/helper/responsive_surface_catalog.dart new file mode 100644 index 000000000..a81eb8439 --- /dev/null +++ b/test/helper/responsive_surface_catalog.dart @@ -0,0 +1,236 @@ +/// Catalog of interactive surfaces that **must** pass the responsive matrix +/// (device × text scale × real tap). Adding a new bottom sheet / sticky-CTA +/// flow without a matrix gate is a review-blocking gap. +/// +/// This is a living list, not a proof of completeness: the self-test in +/// `responsive_surface_catalog_test.dart` verifies that every listed surface's +/// matrix test file exists, its production file exists, and that production +/// file actually references [ScrollableActionsLayout]. It cannot verify that +/// every sticky-CTA surface in the app has been listed here — that is a +/// review responsibility for every new sticky-CTA surface. +/// +/// Line coverage % of the repo is a different metric — see docs/testing.md. +library; + +/// One surface under the responsive layout contract. +class ResponsiveSurface { + const ResponsiveSurface({ + required this.id, + required this.description, + required this.matrixTestPath, + required this.productionPath, + }); + + final String id; + final String description; + + /// Path under `test/` that runs [kFullResponsiveMatrix] (or a documented + /// subset) with [expectFullyTappable] on primary CTAs. + final String matrixTestPath; + + /// Path under `lib/` of the production widget that must use + /// [ScrollableActionsLayout] (or equivalent Expanded + scroll + sticky + /// actions). + final String productionPath; +} + +/// Living catalog — extend when adding sticky-CTA sheets/pages. +const kResponsiveSurfaceCatalog = [ + ResponsiveSurface( + id: 'bitbox_connect_sheet', + description: 'BitBox pairing bottom sheet (all button-bearing states)', + matrixTestPath: + 'test/screens/hardware_connect_bitbox/connect_bitbox_responsive_matrix_test.dart', + productionPath: 'lib/screens/hardware_connect_bitbox/widgets/connect_content.dart', + ), + ResponsiveSurface( + id: 'dashboard_page', + description: 'Dashboard main page', + matrixTestPath: 'test/screens/dashboard/dashboard_responsive_matrix_test.dart', + productionPath: 'lib/screens/dashboard/dashboard_page.dart', + ), + ResponsiveSurface( + id: 'create_wallet_view', + description: 'Create wallet view', + matrixTestPath: 'test/screens/create_wallet/create_wallet_responsive_matrix_test.dart', + productionPath: 'lib/screens/create_wallet/create_wallet_view.dart', + ), + ResponsiveSurface( + id: 'verify_pin_page', + description: 'Verify PIN page', + matrixTestPath: 'test/screens/pin/verify_pin_responsive_matrix_test.dart', + productionPath: 'lib/screens/pin/verify_pin_page.dart', + ), + ResponsiveSurface( + id: 'kyc_completed_page', + description: 'KYC completed status page', + matrixTestPath: + 'test/screens/kyc/subpages/kyc_status_pages_responsive_matrix_test.dart', + productionPath: 'lib/screens/kyc/subpages/kyc_completed_page.dart', + ), + ResponsiveSurface( + id: 'kyc_manual_review_page', + description: 'KYC manual review status page', + matrixTestPath: + 'test/screens/kyc/subpages/kyc_status_pages_responsive_matrix_test.dart', + productionPath: 'lib/screens/kyc/subpages/kyc_manual_review_page.dart', + ), + ResponsiveSurface( + id: 'kyc_pending_page', + description: 'KYC pending status page', + matrixTestPath: + 'test/screens/kyc/subpages/kyc_status_pages_responsive_matrix_test.dart', + productionPath: 'lib/screens/kyc/subpages/kyc_pending_page.dart', + ), + ResponsiveSurface( + id: 'kyc_account_merge_page', + description: 'KYC account merge page', + matrixTestPath: 'test/screens/kyc/kyc_merge_link_responsive_matrix_test.dart', + productionPath: 'lib/screens/kyc/subpages/kyc_account_merge_page.dart', + ), + ResponsiveSurface( + id: 'kyc_merge_processing_page', + description: 'KYC merge processing page', + matrixTestPath: 'test/screens/kyc/kyc_merge_link_responsive_matrix_test.dart', + productionPath: 'lib/screens/kyc/subpages/kyc_merge_processing_page.dart', + ), + ResponsiveSurface( + id: 'kyc_link_wallet_page', + description: 'KYC link wallet page', + matrixTestPath: 'test/screens/kyc/kyc_merge_link_responsive_matrix_test.dart', + productionPath: 'lib/screens/kyc/steps/link_wallet/kyc_link_wallet_page.dart', + ), + ResponsiveSurface( + id: 'kyc_financial_data_questions_page', + description: 'KYC financial data questions page', + matrixTestPath: + 'test/screens/kyc/steps/financial_data/kyc_financial_data_responsive_matrix_test.dart', + productionPath: + 'lib/screens/kyc/steps/financial_data/subpages/kyc_financial_data_questions_page.dart', + ), + ResponsiveSurface( + id: 'onboarding_completed_page', + description: 'Onboarding completed page', + matrixTestPath: + 'test/screens/onboarding/onboarding_completed_responsive_matrix_test.dart', + productionPath: 'lib/screens/onboarding/onboarding_completed_page.dart', + ), + ResponsiveSurface( + id: 'support_create_ticket_page', + description: 'Support create ticket page', + matrixTestPath: + 'test/screens/support/support_create_ticket_responsive_matrix_test.dart', + productionPath: 'lib/screens/support/subpages/support_create_ticket_page.dart', + ), + ResponsiveSurface( + id: 'settings_edit_address_page', + description: 'Settings edit address form', + matrixTestPath: + 'test/screens/settings_user_data/settings_user_data_responsive_matrix_test.dart', + productionPath: + 'lib/screens/settings_user_data/subpages/edit_address/settings_edit_address_page.dart', + ), + ResponsiveSurface( + id: 'settings_edit_name_page', + description: 'Settings edit name form', + matrixTestPath: + 'test/screens/settings_user_data/settings_user_data_responsive_matrix_test.dart', + productionPath: + 'lib/screens/settings_user_data/subpages/edit_name/settings_edit_name_page.dart', + ), + ResponsiveSurface( + id: 'settings_edit_phone_number_page', + description: 'Settings edit phone number form', + matrixTestPath: + 'test/screens/settings_user_data/settings_user_data_responsive_matrix_test.dart', + productionPath: + 'lib/screens/settings_user_data/subpages/edit_phone_number/settings_edit_phone_number_page.dart', + ), + ResponsiveSurface( + id: 'settings_edit_pending_page', + description: 'Settings edit pending status page', + matrixTestPath: + 'test/screens/settings_user_data/settings_user_data_responsive_matrix_test.dart', + productionPath: + 'lib/screens/settings_user_data/subpages/others/settings_edit_pending_page.dart', + ), + ResponsiveSurface( + id: 'settings_edit_failure_page', + description: 'Settings edit failure status page', + matrixTestPath: + 'test/screens/settings_user_data/settings_user_data_responsive_matrix_test.dart', + productionPath: + 'lib/screens/settings_user_data/subpages/others/settings_edit_failure_page.dart', + ), + ResponsiveSurface( + id: 'verify_seed_page', + description: 'Verify seed phrase page (confirm CTA)', + matrixTestPath: 'test/screens/verify_seed/verify_seed_responsive_matrix_test.dart', + productionPath: 'lib/screens/verify_seed/verify_seed_page.dart', + ), + ResponsiveSurface( + id: 'restore_wallet_view', + description: 'Restore wallet from seed phrase view (next CTA)', + matrixTestPath: 'test/screens/restore_wallet/restore_wallet_responsive_matrix_test.dart', + productionPath: 'lib/screens/restore_wallet/restore_wallet_view.dart', + ), + ResponsiveSurface( + id: 'buy_page', + description: 'Buy page (primary CTA)', + matrixTestPath: 'test/screens/buy/buy_responsive_matrix_test.dart', + productionPath: 'lib/screens/buy/buy_page.dart', + ), + ResponsiveSurface( + id: 'sell_page', + description: 'Sell page (primary CTA)', + matrixTestPath: 'test/screens/sell/sell_responsive_matrix_test.dart', + productionPath: 'lib/screens/sell/sell_page.dart', + ), + ResponsiveSurface( + id: 'setup_pin_page', + description: 'Setup PIN page (sticky numeric keypad as actions)', + matrixTestPath: 'test/screens/pin/setup_pin_responsive_matrix_test.dart', + productionPath: 'lib/screens/pin/setup_pin_page.dart', + ), + ResponsiveSurface( + id: 'kyc_failure_page', + description: 'KYC failure status page (no CTA — gates overflow + message reachability only)', + matrixTestPath: 'test/screens/kyc/kyc_static_pages_responsive_matrix_test.dart', + productionPath: 'lib/screens/kyc/subpages/kyc_failure_page.dart', + ), + ResponsiveSurface( + id: 'kyc_signature_unsupported_page', + description: 'KYC signature-unsupported status page (no CTA — gates overflow + message reachability only)', + matrixTestPath: 'test/screens/kyc/kyc_static_pages_responsive_matrix_test.dart', + productionPath: 'lib/screens/kyc/steps/signature_unsupported/kyc_signature_unsupported_page.dart', + ), + ResponsiveSurface( + id: 'sell_confirm_sheet', + description: 'Sell confirmation bottom sheet (shrinkWrap mode)', + matrixTestPath: 'test/screens/sell/sell_sheets_responsive_matrix_test.dart', + productionPath: 'lib/screens/sell/widgets/sell_confirm_sheet.dart', + ), + ResponsiveSurface( + id: 'sell_executed_sheet', + description: 'Sell executed/receipt bottom sheet (shrinkWrap mode)', + matrixTestPath: 'test/screens/sell/sell_sheets_responsive_matrix_test.dart', + productionPath: 'lib/screens/sell/widgets/sell_executed_sheet.dart', + ), + ResponsiveSurface( + id: 'forgot_pin_bottom_sheet', + description: 'Forgot-PIN bottom sheet (shrinkWrap mode)', + matrixTestPath: 'test/screens/pin/pin_sheets_responsive_matrix_test.dart', + productionPath: 'lib/screens/pin/widgets/forgot_pin_bottom_sheet.dart', + ), + ResponsiveSurface( + id: 'enable_biometric_bottom_sheet', + description: 'Enable-biometric bottom sheet (shrinkWrap mode)', + matrixTestPath: 'test/screens/pin/pin_sheets_responsive_matrix_test.dart', + productionPath: 'lib/screens/pin/widgets/enable_biometric_bottom_sheet.dart', + ), + // Migration covers 29 surfaces total (bitbox_connect_sheet + 28 above). No + // further known candidates remain from the prior sweep. welcome_page was + // reviewed and found safe (scrolls end-to-end, no separate sticky CTA) — not + // a migration candidate. Not exhaustive — review responsibility for every + // new sticky-CTA surface. +]; diff --git a/test/helper/responsive_surface_catalog_test.dart b/test/helper/responsive_surface_catalog_test.dart new file mode 100644 index 000000000..acf683a6e --- /dev/null +++ b/test/helper/responsive_surface_catalog_test.dart @@ -0,0 +1,140 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'responsive_surface_catalog.dart'; + +/// Every public (`class X extends StatelessWidget`/`StatefulWidget`) name declared in +/// [source]. Private (`_`-prefixed) classes are excluded: a matrix test in a different +/// file could never import/reference them, so requiring a match against them would prove +/// nothing. +List _publicWidgetClassNames(String source) => [ + for (final match in RegExp( + r'class\s+([A-Za-z][A-Za-z0-9]*)\s+extends\s+State(?:less|ful)Widget\b', + ).allMatches(source)) + match.group(1)!, +]; + +/// `lib/`-relative paths imported from [source] via `package:realunit_wallet/...`. +Set _packageLibImportPaths(String source) => { + for (final match in RegExp( + r"import\s+'package:realunit_wallet/([^']+)'", + ).allMatches(source)) + 'lib/${match.group(1)!}', +}; + +/// Whether [productionPath] is reachable from the matrix test's package imports, +/// allowing at most one composition hop (direct import, or import of a lib file that +/// itself imports the production file). Unbounded transitive closure is intentionally +/// not used — it would make the check meaningless. +bool _isProductionReachableViaOneHopImport({ + required String productionPath, + required String matrixContents, +}) { + final directImports = _packageLibImportPaths(matrixContents); + if (directImports.contains(productionPath)) { + return true; + } + for (final importPath in directImports) { + final file = File(importPath); + if (!file.existsSync()) { + continue; + } + final hopImports = _packageLibImportPaths(file.readAsStringSync()); + if (hopImports.contains(productionPath)) { + return true; + } + } + return false; +} + +void main() { + test('every catalogued responsive surface has a matrix test file on disk', () { + for (final surface in kResponsiveSurfaceCatalog) { + final file = File(surface.matrixTestPath); + expect( + file.existsSync(), + isTrue, + reason: + 'Surface "${surface.id}" lists matrix test ' + '${surface.matrixTestPath} but the file is missing', + ); + } + }); + + test('every catalogued surface has a production file on disk', () { + for (final surface in kResponsiveSurfaceCatalog) { + final file = File(surface.productionPath); + expect( + file.existsSync(), + isTrue, + reason: + 'Surface "${surface.id}" lists production file ' + '${surface.productionPath} but the file is missing', + ); + } + }); + + test('every catalogued surface actually uses ScrollableActionsLayout', () { + for (final surface in kResponsiveSurfaceCatalog) { + final contents = File(surface.productionPath).readAsStringSync(); + expect( + RegExp(r'ScrollableActionsLayout\s*\(').hasMatch(contents), + isTrue, + reason: + 'Surface "${surface.id}" (${surface.productionPath}) no longer ' + 'references ScrollableActionsLayout — the responsive-layout ' + 'contract regressed', + ); + } + }); + + test('catalog is non-empty (responsive gate is active)', () { + expect(kResponsiveSurfaceCatalog, isNotEmpty); + }); + + test( + "every catalogued surface's matrix test actually exercises its production widget", + () { + final gaps = []; + for (final surface in kResponsiveSurfaceCatalog) { + final productionContents = File(surface.productionPath).readAsStringSync(); + final classNames = _publicWidgetClassNames(productionContents); + expect( + classNames, + isNotEmpty, + reason: + 'Surface "${surface.id}" (${surface.productionPath}) declares no public ' + 'StatelessWidget/StatefulWidget class — cannot verify its matrix test ' + 'actually exercises it', + ); + + final matrixContents = File(surface.matrixTestPath).readAsStringSync(); + final referenced = classNames.where(matrixContents.contains).toList(); + final reachable = _isProductionReachableViaOneHopImport( + productionPath: surface.productionPath, + matrixContents: matrixContents, + ); + if (referenced.isEmpty && !reachable) { + gaps.add( + 'Surface "${surface.id}": none of $classNames (declared in ' + '${surface.productionPath}) are referenced by name in ' + '${surface.matrixTestPath}, and ${surface.productionPath} is not ' + "reachable from ${surface.matrixTestPath}'s direct imports " + '(or one hop beyond them) — the matrix test may be exercising a ' + 'different widget than the one this entry claims to gate', + ); + } + } + + expect( + gaps, + isEmpty, + reason: + 'Matrix tests that do not reference their production widget by class name ' + 'and do not reach it via one-hop package import:\n' + '${gaps.join('\n')}', + ); + }, + ); +} diff --git a/test/integration/link_wallet_connect_flow_test.dart b/test/integration/link_wallet_connect_flow_test.dart index df16208d6..dc0e0cf0b 100644 --- a/test/integration/link_wallet_connect_flow_test.dart +++ b/test/integration/link_wallet_connect_flow_test.dart @@ -109,7 +109,10 @@ void main() { () async { credentials.behavior = FakeBitboxBehavior.disconnect; var posts = 0; - final client = MockClient((_) async { + final client = MockClient((request) async { + if (request.url.path == '/v1/realunit/register/date') { + return http.Response(jsonEncode({'date': '2026-07-13'}), 200); + } posts++; return http.Response(jsonEncode({'status': 'completed'}), 201); }); @@ -122,7 +125,7 @@ void main() { expect( posts, 0, - reason: 'the EIP-712 sign throws BitboxNotConnectedException before the POST', + reason: 'the EIP-712 sign throws BitboxNotConnectedException before the register POST', ); // The wallet is unlocked for the ceremony and re-locked in the finally // even though signing throws. @@ -138,6 +141,9 @@ void main() { Uri? sentUri; Map? body; final client = MockClient((request) async { + if (request.url.path == '/v1/realunit/register/date') { + return http.Response(jsonEncode({'date': '2026-07-13'}), 200); + } sentUri = request.url; body = jsonDecode(request.body) as Map; return http.Response(jsonEncode({'status': 'completed'}), 201); @@ -152,7 +158,8 @@ void main() { expect(body!['walletAddress'], credentials.address.hexEip55); // 65-byte ECDSA signature → 0x + 130 hex chars. expect((body!['signature'] as String).length, 132); - expect((body!['registrationDate'] as String).length, 10); + // The signed registrationDate is the server-provided date. + expect(body!['registrationDate'], '2026-07-13'); }, ); @@ -161,6 +168,9 @@ void main() { () async { Uri? sentUri; final client = MockClient((request) async { + if (request.url.path == '/v1/realunit/register/date') { + return http.Response(jsonEncode({'date': '2026-07-13'}), 200); + } sentUri = request.url; return http.Response(jsonEncode({'status': 'completed'}), 201); }); diff --git a/test/packages/hardware_wallet/bitbox_credentials_test.dart b/test/packages/hardware_wallet/bitbox_credentials_test.dart index 150707005..7654aadcb 100644 --- a/test/packages/hardware_wallet/bitbox_credentials_test.dart +++ b/test/packages/hardware_wallet/bitbox_credentials_test.dart @@ -51,6 +51,27 @@ void main() { expect(c.isConnected, isFalse); }); + // The two synchronous web3dart entry points are never used on a BitBox + // (every sign path is awaitable). They exist only to satisfy the interface + // and must fail loud if a future refactor wires a sync caller onto BitBox + // credentials by accident — the same guard `_DebugCredentials` carries in + // wallet.dart. + test('signToEcSignature throws UnimplementedError (sync path is never used)', () { + final c = BitboxCredentials('0x000000000000000000000000000000000000dead'); + expect( + () => c.signToEcSignature(Uint8List.fromList([1, 2, 3])), + throwsA(isA()), + ); + }); + + test('signPersonalMessageToUint8List throws UnimplementedError (sync path is never used)', () { + final c = BitboxCredentials('0x000000000000000000000000000000000000dead'); + expect( + () => c.signPersonalMessageToUint8List(Uint8List.fromList([1, 2, 3])), + throwsA(isA()), + ); + }); + test('signToSignature throws BitboxNotConnectedException when bitboxManager is null', () { final c = BitboxCredentials('0x000000000000000000000000000000000000dead'); expect( @@ -120,6 +141,30 @@ void main() { expect(sig.v, 38); }); + // The EIP-155 parity check truncates chainIds wider than 32 bits before + // matching the low byte of v. chainId 2^32 + 1 is 33 bits, so the + // truncation loop runs once (`>> 8` → 2^24, which is ≤ 32 bits and stops + // it). The device returns v = 35, which matches (2^24 * 2 + 35) & 0xff == + // 35, so parity resolves to 0 and the final v is computed from the FULL, + // untruncated chainId. + test('signToSignature truncates a >2^32 chainId for the EIP-155 parity check', () async { + const chainId = 0x100000001; // 2^32 + 1, 33 bits — forces one loop iteration + final fakeSig = Uint8List.fromList( + List.filled(32, 0x11) + List.filled(32, 0x22) + [35], + ); + when( + () => manager.signETHRLPTransaction(any(), any(), any(), any()), + ).thenAnswer((_) async => fakeSig); + + final sig = await connected().signToSignature( + Uint8List.fromList([0xDE, 0xAD]), + chainId: chainId, + ); + + // parity 0 → v = chainId * 2 + 35, derived from the untruncated chainId. + expect(sig.v, chainId * 2 + 35); + }); + test('signPersonalMessage throws BitboxNotConnectedException when not connected', () { final c = BitboxCredentials('0x000000000000000000000000000000000000dead'); expect( diff --git a/test/packages/service/dfx/exceptions/exception_surface_test.dart b/test/packages/service/dfx/exceptions/exception_surface_test.dart index cddcc596d..80c34544f 100644 --- a/test/packages/service/dfx/exceptions/exception_surface_test.dart +++ b/test/packages/service/dfx/exceptions/exception_surface_test.dart @@ -4,6 +4,7 @@ import 'package:realunit_wallet/packages/service/dfx/exceptions/bitbox_address_u import 'package:realunit_wallet/packages/service/dfx/exceptions/bitbox_exception.dart'; import 'package:realunit_wallet/packages/service/dfx/exceptions/payment/buy_exceptions.dart'; import 'package:realunit_wallet/packages/service/dfx/exceptions/payment/sell_exceptions.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/registration_rejected_exception.dart'; import 'package:realunit_wallet/packages/storage/secure_storage.dart'; import 'package:realunit_wallet/packages/wallet/exceptions/signing_cancelled_exception.dart'; @@ -21,6 +22,7 @@ void main() { const BitboxAddressUnavailableException(), const SigningCancelledException(), const ApiException(code: 'TEST', message: 'test'), + const RegistrationRejectedException(code: 'TEST', message: 'test'), const RegistrationRequiredException(code: 'TEST', message: 'test'), const KycLevelRequiredException( code: 'TEST', diff --git a/test/packages/service/dfx/models/aggregate_dtos_test.dart b/test/packages/service/dfx/models/aggregate_dtos_test.dart index c7c7cadd5..51a05f3ae 100644 --- a/test/packages/service/dfx/models/aggregate_dtos_test.dart +++ b/test/packages/service/dfx/models/aggregate_dtos_test.dart @@ -249,16 +249,45 @@ void main() { expect(dto.confirmedDate, isNull); }); - test('guards an unparseable confirmedDate → null', () { + test('parses manualReview=true', () { final dto = RealUnitRegistrationInfoDto.fromJson({ 'state': 'AlreadyRegistered', 'userData': null, - 'emailConfirmed': true, - 'confirmedDate': 'not-a-date', + 'manualReview': true, }); - expect(dto.emailConfirmed, isTrue); - expect(dto.confirmedDate, isNull); + expect(dto.manualReview, isTrue); + }); + + test('parses manualReview=false', () { + final dto = RealUnitRegistrationInfoDto.fromJson({ + 'state': 'AlreadyRegistered', + 'userData': null, + 'manualReview': false, + }); + + expect(dto.manualReview, isFalse); + }); + + test('legacy fallback: manualReview absent → null', () { + final dto = RealUnitRegistrationInfoDto.fromJson({ + 'state': 'AlreadyRegistered', + 'userData': null, + }); + + expect(dto.manualReview, isNull); + }); + + test('throws on a present but unparseable confirmedDate (fail loud)', () { + expect( + () => RealUnitRegistrationInfoDto.fromJson({ + 'state': 'AlreadyRegistered', + 'userData': null, + 'emailConfirmed': true, + 'confirmedDate': 'not-a-date', + }), + throwsA(isA()), + ); }); }); diff --git a/test/packages/service/dfx/models/legal/real_unit_legal_agreement_test.dart b/test/packages/service/dfx/models/legal/real_unit_legal_agreement_test.dart new file mode 100644 index 000000000..2fa6db097 --- /dev/null +++ b/test/packages/service/dfx/models/legal/real_unit_legal_agreement_test.dart @@ -0,0 +1,19 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/legal/real_unit_legal_agreement.dart'; + +void main() { + group('RealUnitLegalAgreement', () { + test('value <-> fromValue round-trips every agreement', () { + for (final agreement in RealUnitLegalAgreement.values) { + expect(RealUnitLegalAgreementExtension.fromValue(agreement.value), agreement); + } + }); + + test('fromValue throws ArgumentError on an unknown value', () { + expect( + () => RealUnitLegalAgreementExtension.fromValue('NopeNotAnAgreement'), + throwsArgumentError, + ); + }); + }); +} diff --git a/test/packages/service/dfx/models/payment/buy_sell_dtos_test.dart b/test/packages/service/dfx/models/payment/buy_sell_dtos_test.dart index 498a8a4eb..7d8889c71 100644 --- a/test/packages/service/dfx/models/payment/buy_sell_dtos_test.dart +++ b/test/packages/service/dfx/models/payment/buy_sell_dtos_test.dart @@ -182,15 +182,16 @@ void main() { }); group('$PaymentInfoError', () { - test('has the seven documented variants', () { + test('has the eight documented variants', () { // Pin the wire contract — any new variant has to be added intentionally. - expect(PaymentInfoError.values, hasLength(7)); + expect(PaymentInfoError.values, hasLength(8)); expect( PaymentInfoError.values.toSet(), { PaymentInfoError.registrationRequired, PaymentInfoError.kycRequired, PaymentInfoError.primaryEmailRequired, + PaymentInfoError.primaryEmailNotConfirmed, PaymentInfoError.minAmountNotMet, PaymentInfoError.bitboxDisconnected, PaymentInfoError.priceSourceUnavailable, diff --git a/test/packages/service/dfx/real_unit_legal_service_test.dart b/test/packages/service/dfx/real_unit_legal_service_test.dart new file mode 100644 index 000000000..f9048ea23 --- /dev/null +++ b/test/packages/service/dfx/real_unit_legal_service_test.dart @@ -0,0 +1,171 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/packages/config/api_config.dart'; +import 'package:realunit_wallet/packages/config/network_mode.dart'; +import 'package:realunit_wallet/packages/repository/cache_repository.dart'; +import 'package:realunit_wallet/packages/service/app_store.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/legal/real_unit_legal_agreement.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_legal_service.dart'; +import 'package:realunit_wallet/packages/service/session_cache.dart'; +import 'package:realunit_wallet/packages/service/wallet_service.dart'; +import 'package:realunit_wallet/packages/wallet/wallet.dart'; +import 'package:realunit_wallet/packages/wallet/wallet_account.dart'; + +class _MockAppStore extends Mock implements AppStore {} + +class _MockWallet extends Mock implements AWallet {} + +class _MockAccount extends Mock implements AWalletAccount {} + +class _MockCacheRepository extends Mock implements CacheRepository {} + +class _MockWalletService extends Mock implements WalletService {} + +void main() { + late _MockAppStore appStore; + late _MockWallet wallet; + late _MockAccount account; + late _MockWalletService walletService; + late SessionCache session; + + setUp(() { + appStore = _MockAppStore(); + wallet = _MockWallet(); + account = _MockAccount(); + walletService = _MockWalletService(); + session = SessionCache(_MockCacheRepository()); + session.setAuthToken('jwt-1'); + + when(() => appStore.apiConfig).thenReturn(const ApiConfig(networkMode: NetworkMode.mainnet)); + when(() => appStore.sessionCache).thenReturn(session); + when(() => appStore.wallet).thenReturn(wallet); + when(() => wallet.primaryAccount).thenReturn(account); + when(() => walletService.ensureCurrentWalletUnlocked()).thenAnswer((_) async {}); + when(() => walletService.lockCurrentWallet()).thenAnswer((_) async {}); + }); + + RealUnitLegalService build(http.Client client) { + when(() => appStore.httpClient).thenReturn(client); + return RealUnitLegalService(appStore, walletService); + } + + // The per-service wiring (401-retry etc.) is covered in dfx_auth_service_test; + // the "Bearer JWT" assertion below proves the call routes through the + // authenticated client. + group('$RealUnitLegalService.getLegalInfo', () { + test('GETs /v1/realunit/legal with the Bearer JWT and parses the info', () async { + String? path; + String? method; + String? auth; + final client = MockClient((request) async { + path = request.url.path; + method = request.method; + auth = request.headers['Authorization']; + return http.Response( + jsonEncode({ + 'agreements': [ + { + 'agreement': 'DfxTermsAndConditions', + 'currentVersion': '20260712', + 'acceptedVersion': '20260712', + 'accepted': true, + }, + { + 'agreement': 'ResidenceConfirmation', + 'currentVersion': '20260712', + 'acceptedVersion': null, + 'accepted': false, + }, + ], + 'allAccepted': false, + }), + 200, + ); + }); + + final info = await build(client).getLegalInfo(); + + expect(method, 'GET'); + expect(path, '/v1/realunit/legal'); + expect(auth, 'Bearer jwt-1'); + expect(info.allAccepted, isFalse); + expect(info.agreements, hasLength(2)); + // Only the not-yet-accepted agreement is outstanding. + expect(info.outstandingAgreements, [RealUnitLegalAgreement.residenceConfirmation]); + }); + + test('throws ApiException on a non-200 response', () async { + final client = MockClient( + (_) async => http.Response( + jsonEncode({'statusCode': 500, 'code': 'SERVER_ERROR', 'message': 'boom'}), + 500, + ), + ); + + expect(() => build(client).getLegalInfo(), throwsA(isA())); + }); + }); + + group('$RealUnitLegalService.acceptLegal', () { + test('PUTs /v1/realunit/legal with the agreement values and parses the info', () async { + String? path; + String? method; + String? auth; + Map? body; + final client = MockClient((request) async { + path = request.url.path; + method = request.method; + auth = request.headers['Authorization']; + body = jsonDecode(request.body) as Map; + return http.Response( + jsonEncode({'agreements': [], 'allAccepted': true}), + 200, + ); + }); + + final info = await build(client).acceptLegal([ + RealUnitLegalAgreement.dfxTermsAndConditions, + RealUnitLegalAgreement.residenceConfirmation, + ]); + + expect(method, 'PUT'); + expect(path, '/v1/realunit/legal'); + expect(auth, 'Bearer jwt-1'); + // The PascalCase server values go on the wire, in order. + expect(body!['agreements'], ['DfxTermsAndConditions', 'ResidenceConfirmation']); + expect(info.allAccepted, isTrue); + }); + + test('accepts a 201 Created response as success', () async { + final client = MockClient( + (_) async => http.Response( + jsonEncode({'agreements': [], 'allAccepted': true}), + 201, + ), + ); + + final info = await build(client).acceptLegal([RealUnitLegalAgreement.dfxTermsAndConditions]); + + expect(info.allAccepted, isTrue); + }); + + test('throws ApiException on a non-2xx response', () async { + final client = MockClient( + (_) async => http.Response( + jsonEncode({'statusCode': 400, 'code': 'BAD', 'message': 'nope'}), + 400, + ), + ); + + expect( + () => build(client).acceptLegal([RealUnitLegalAgreement.dfxTermsAndConditions]), + throwsA(isA()), + ); + }); + }); +} diff --git a/test/packages/service/dfx/real_unit_registration_service_happy_test.dart b/test/packages/service/dfx/real_unit_registration_service_happy_test.dart index 6d6636afb..101a31fce 100644 --- a/test/packages/service/dfx/real_unit_registration_service_happy_test.dart +++ b/test/packages/service/dfx/real_unit_registration_service_happy_test.dart @@ -135,6 +135,9 @@ void main() { Map? body; Map? headers; final client = MockClient((request) async { + if (request.url.path == '/v1/realunit/register/date') { + return http.Response(jsonEncode({'date': '2026-07-13'}), 200); + } sentUri = request.url; body = jsonDecode(request.body) as Map; headers = request.headers; @@ -147,6 +150,10 @@ void main() { expect(sentUri!.path, '/v1/realunit/register/complete'); expect(headers!['authorization'], 'Bearer jwt-1'); + // The signed registrationDate must be the server-provided date, not a + // locally-derived one — the whole point of the /register/date fetch. + expect(body!['registrationDate'], '2026-07-13'); + // Signed envelope copy — must be ASCII-transliterated to match what // BitBox firmware would have signed. expect(body!['email'], 'ada@example.com'); // lowercased + ASCII @@ -183,6 +190,9 @@ void main() { Uri? sentUri; Map? body; final client = MockClient((request) async { + if (request.url.path == '/v1/realunit/register/date') { + return http.Response(jsonEncode({'date': '2026-07-13'}), 200); + } sentUri = request.url; body = jsonDecode(request.body) as Map; return http.Response(jsonEncode({'status': 'completed'}), 201); @@ -224,7 +234,10 @@ void main() { // sibling file short-circuits before the HTTP call, so the wire // error path stays uncovered without this. var calls = 0; - final client = MockClient((_) async { + final client = MockClient((request) async { + if (request.url.path == '/v1/realunit/register/date') { + return http.Response(jsonEncode({'date': '2026-07-13'}), 200); + } calls++; return http.Response( jsonEncode({ @@ -256,7 +269,10 @@ void main() { () async { // Coverage pin for the matching branch in `_registerWallet`. var calls = 0; - final client = MockClient((_) async { + final client = MockClient((request) async { + if (request.url.path == '/v1/realunit/register/date') { + return http.Response(jsonEncode({'date': '2026-07-13'}), 200); + } calls++; return http.Response( jsonEncode({ @@ -286,6 +302,9 @@ void main() { Uri? sentUri; Map? body; final client = MockClient((request) async { + if (request.url.path == '/v1/realunit/register/date') { + return http.Response(jsonEncode({'date': '2026-07-13'}), 200); + } sentUri = request.url; body = jsonDecode(request.body) as Map; return http.Response(jsonEncode({'status': 'completed'}), 201); @@ -297,9 +316,52 @@ void main() { expect(sentUri!.path, '/v1/realunit/register/wallet'); expect(body!['walletAddress'], _privKey.address.hexEip55); expect((body!['signature'] as String).length, 132); - // YYYY-MM-DD shape, length 10. - expect((body!['registrationDate'] as String).length, 10); + // The signed registrationDate must be the server-provided date. + expect(body!['registrationDate'], '2026-07-13'); }, ); }); + + group('getRegistrationDate', () { + test('GETs /v1/realunit/register/date and returns the server date', () async { + Uri? sentUri; + String? method; + final client = MockClient((request) async { + sentUri = request.url; + method = request.method; + return http.Response(jsonEncode({'date': '2026-07-13'}), 200); + }); + + final date = await build(client).getRegistrationDate(); + + expect(date, '2026-07-13'); + expect(method, 'GET'); + expect(sentUri!.path, '/v1/realunit/register/date'); + }); + + test('rewraps a non-200 response as ApiException carrying the backend status', () async { + final client = MockClient((_) async { + return http.Response( + jsonEncode({'statusCode': 503, 'code': 'UNAVAILABLE', 'message': 'down'}), + 503, + ); + }); + + await expectLater( + () => build(client).getRegistrationDate(), + throwsA(isA()), + ); + }); + + test('throws when the response omits the date field (no silent fallback)', () async { + final client = MockClient((_) async { + return http.Response(jsonEncode({'notDate': true}), 200); + }); + + await expectLater( + () => build(client).getRegistrationDate(), + throwsA(isA()), + ); + }); + }); } diff --git a/test/packages/service/dfx/real_unit_registration_service_test.dart b/test/packages/service/dfx/real_unit_registration_service_test.dart index 2242763ee..dc31f7ca6 100644 --- a/test/packages/service/dfx/real_unit_registration_service_test.dart +++ b/test/packages/service/dfx/real_unit_registration_service_test.dart @@ -10,6 +10,8 @@ import 'package:realunit_wallet/packages/repository/cache_repository.dart'; import 'package:realunit_wallet/packages/service/app_store.dart'; import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart'; import 'package:realunit_wallet/packages/service/dfx/exceptions/bitbox_exception.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/payment/buy_exceptions.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/registration_rejected_exception.dart'; import 'package:realunit_wallet/packages/service/dfx/models/registration/registration_email_status.dart'; import 'package:realunit_wallet/packages/service/dfx/models/registration/registration_user_type.dart'; import 'package:realunit_wallet/packages/service/dfx/models/wallet/real_unit_registration_state.dart'; @@ -204,13 +206,128 @@ void main() { when(() => account.primaryAddress).thenReturn( FakeBitboxCredentials(behavior: FakeBitboxBehavior.disconnect)..bitboxManager = null, ); - final client = MockClient((_) async => http.Response('{}', 201)); + // The date fetch happens before signing, so it must succeed for the flow + // to reach the signing step where the disconnected BitBox surfaces. + final client = MockClient((request) async { + if (request.url.path == '/v1/realunit/register/date') { + return http.Response('{"date":"2026-07-13"}', 200); + } + return http.Response('{}', 201); + }); expect( () => build(client).completeRegistration(buildRegistration()), throwsA(isA()), ); }); + + // Error mapping of the register/complete response itself: only a non-auth + // 4xx becomes the typed RegistrationRejectedException the UI attributes + // to the user's entries; everything else stays a plain (or code-specific) + // ApiException. A working signer so the flow reaches the POST. + MockClient completionErrorClient(int status, Map body) => + MockClient((request) async { + if (request.url.path == '/v1/realunit/register/date') { + return http.Response('{"date":"2026-07-13"}', 200); + } + return http.Response(jsonEncode(body), status); + }); + + test('types a non-auth 4xx as RegistrationRejectedException', () async { + when(() => account.primaryAddress).thenReturn(FakeBitboxCredentials()); + final client = completionErrorClient(400, { + 'statusCode': 400, + 'message': 'Registration date must be today', + }); + + expect( + () => build(client).completeRegistration(buildRegistration()), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Registration date must be today', + ), + ), + ); + }); + + test('keeps a 401 a plain ApiException (auth, not a content rejection)', () async { + when(() => account.primaryAddress).thenReturn(FakeBitboxCredentials()); + // A 401 triggers _authenticated's one-shot refresh (re-sign + /v1/auth) + // before the retried response is surfaced — stub the signature cache, + // the auth wallet accessor, and serve the auth endpoint so the SECOND + // 401 reaches the mapping. + when(() => wallet.currentAccount).thenReturn(account); + when(() => account.signMessage(any())).thenAnswer((_) async => '0xsig'); + final repo = _MockCacheRepository(); + when(() => repo.read(any())).thenAnswer((_) async => null); + when(() => repo.write(any(), any())).thenAnswer((_) async => 1); + session = SessionCache(repo)..setAuthToken('jwt-1'); + when(() => appStore.sessionCache).thenReturn(session); + + final client = MockClient((request) async { + if (request.url.path == '/v1/realunit/register/date') { + return http.Response('{"date":"2026-07-13"}', 200); + } + if (request.url.path == '/v1/auth') { + return http.Response(jsonEncode({'accessToken': 'jwt-2'}), 201); + } + return http.Response( + jsonEncode({'statusCode': 401, 'message': 'Unauthorized'}), + 401, + ); + }); + + expect( + () => build(client).completeRegistration(buildRegistration()), + throwsA( + allOf( + isA(), + isNot(isA()), + ), + ), + ); + }); + + test('keeps a 5xx a plain ApiException', () async { + when(() => account.primaryAddress).thenReturn(FakeBitboxCredentials()); + final client = completionErrorClient(500, { + 'statusCode': 500, + 'message': 'Internal server error', + }); + + expect( + () => build(client).completeRegistration(buildRegistration()), + throwsA( + allOf( + isA(), + isNot(isA()), + ), + ), + ); + }); + + test('preserves the code-specific subclass for KYC_LEVEL_REQUIRED', () async { + when(() => account.primaryAddress).thenReturn(FakeBitboxCredentials()); + final client = completionErrorClient(400, { + 'statusCode': 400, + 'code': 'KYC_LEVEL_REQUIRED', + 'message': 'KYC level 10 required', + 'requiredLevel': 10, + 'currentLevel': 0, + }); + + expect( + () => build(client).completeRegistration(buildRegistration()), + throwsA( + allOf( + isA(), + isNot(isA()), + ), + ), + ); + }); }); group('$RealUnitRegistrationService.registerWallet', () { @@ -245,7 +362,14 @@ void main() { when(() => account.primaryAddress).thenReturn( FakeBitboxCredentials(behavior: FakeBitboxBehavior.disconnect)..bitboxManager = null, ); - final client = MockClient((_) async => http.Response('{}', 201)); + // The date fetch happens before signing, so it must succeed for the flow + // to reach the signing step where the disconnected BitBox surfaces. + final client = MockClient((request) async { + if (request.url.path == '/v1/realunit/register/date') { + return http.Response('{"date":"2026-07-13"}', 200); + } + return http.Response('{}', 201); + }); expect( () => build(client).registerWallet(buildUserData()), diff --git a/test/packages/utils/fuck_firebase_test.dart b/test/packages/utils/fuck_firebase_test.dart new file mode 100644 index 000000000..9869c388c --- /dev/null +++ b/test/packages/utils/fuck_firebase_test.dart @@ -0,0 +1,55 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:realunit_wallet/packages/utils/fuck_firebase.dart'; + +void main() { + group('fuckFirebase', () { + test('is a no-op off Android and never touches the filesystem', () async { + // The host test runner reports Platform.isAndroid == false, so the whole + // Android side effect is skipped. This pins the early-return contract: + // fuckFirebase must do nothing (and must not throw) off Android. + await expectLater(fuckFirebase(), completes); + }); + }); + + group('writeFirebaseTelemetryStub', () { + late Directory root; + + setUp(() { + root = Directory.systemTemp.createTempSync('fuck_firebase_test_'); + }); + + tearDown(() { + if (root.existsSync()) root.deleteSync(recursive: true); + }); + + test('writes the SQLITE_NOTADB sentinel into /databases/', () async { + await writeFirebaseTelemetryStub(root.path); + + final file = File('${root.path}/databases/com.google.android.datatransport.events'); + expect(file.existsSync(), isTrue); + expect(file.readAsStringSync(), 'Fake'); + }); + + test('creates the databases/ directory when it does not exist yet', () async { + // A fresh install has no databases/ dir — recursive: true must materialise + // the parent chain rather than throwing. + expect(Directory('${root.path}/databases').existsSync(), isFalse); + + await writeFirebaseTelemetryStub(root.path); + + expect(Directory('${root.path}/databases').existsSync(), isTrue); + }); + + test('overwrites an existing sentinel file idempotently', () async { + // fuckFirebase runs on every boot; a second write must not throw on an + // already-present file and must leave the same sentinel content. + await writeFirebaseTelemetryStub(root.path); + await writeFirebaseTelemetryStub(root.path); + + final file = File('${root.path}/databases/com.google.android.datatransport.events'); + expect(file.readAsStringSync(), 'Fake'); + }); + }); +} diff --git a/test/screens/buy/buy_responsive_matrix_test.dart b/test/screens/buy/buy_responsive_matrix_test.dart new file mode 100644 index 000000000..e89f7aaaf --- /dev/null +++ b/test/screens/buy/buy_responsive_matrix_test.dart @@ -0,0 +1,210 @@ +// Responsive matrix gate for BuyView primary CTA. +// +// Proves the buy payment-action button stays fully tappable across the full +// device × text-scale matrix (see test/helper/responsive_matrix.dart). +// Regression lock for the IntrinsicHeight + Spacer collapse that pushed the +// CTA below the viewport on first frame without a RenderFlex overflow. +// +// Also proves the sticky actions stay above the soft keyboard via Scaffold's +// default resizeToAvoidBottomInset (viewInsets shrink the bounded height). +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/packages/config/api_config.dart'; +import 'package:realunit_wallet/packages/repository/cache_repository.dart'; +import 'package:realunit_wallet/packages/repository/supported_fiat_repository.dart'; +import 'package:realunit_wallet/packages/service/app_store.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_brokerbot_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_price_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/payment_info_error.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_buy_payment_info_service.dart'; +import 'package:realunit_wallet/packages/service/session_cache.dart'; +import 'package:realunit_wallet/screens/buy/buy_page.dart'; +import 'package:realunit_wallet/screens/buy/cubits/buy_converter/buy_converter_cubit.dart'; +import 'package:realunit_wallet/screens/buy/cubits/buy_payment_info/buy_payment_info_cubit.dart'; +import 'package:realunit_wallet/styles/currency.dart'; +import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart'; + +import '../../helper/helper.dart'; + +class MockBuyConverterCubit extends MockCubit implements BuyConverterCubit {} + +class MockBuyPaymentInfoCubit extends MockCubit + implements BuyPaymentInfoCubit {} + +class MockDfxBrokerbotService extends Mock implements DfxBrokerbotService {} + +class MockRealUnitBuyPaymentInfoService extends Mock implements RealUnitBuyPaymentInfoService {} + +class MockDfxPriceService extends Mock implements DFXPriceService {} + +class MockApiConfig extends Mock implements ApiConfig {} + +class MockCacheRepository extends Mock implements CacheRepository {} + +class MockSupportedFiatRepository extends Mock implements SupportedFiatRepository {} + +void main() { + late BuyConverterCubit converterCubit; + late BuyPaymentInfoCubit buyPaymentInfoCubit; + + void setupDependencyInjection() { + final getIt = GetIt.instance; + getIt.registerSingleton( + AppStore(() => MockApiConfig(), SessionCache(MockCacheRepository())), + ); + getIt.registerSingleton(MockDfxBrokerbotService()); + getIt.registerSingleton(MockRealUnitBuyPaymentInfoService()); + getIt.registerSingleton(MockDfxPriceService()); + final fiatRepo = MockSupportedFiatRepository(); + when(() => fiatRepo.getBuyable()).thenAnswer((_) async => const [Currency.chf, Currency.eur]); + when(() => fiatRepo.getSellable()).thenAnswer((_) async => const [Currency.chf]); + when(() => fiatRepo.getAll()).thenAnswer((_) async => const [Currency.chf, Currency.eur]); + getIt.registerSingleton(fiatRepo); + } + + setUpAll(() { + registerFallbackValue(Currency.chf); + setupDependencyInjection(); + }); + + tearDownAll(() async => await GetIt.instance.reset()); + + setUp(() { + converterCubit = MockBuyConverterCubit(); + buyPaymentInfoCubit = MockBuyPaymentInfoCubit(); + + when(() => converterCubit.state).thenReturn(const BuyConverterState()); + when(() => buyPaymentInfoCubit.state).thenReturn(const BuyPaymentInfoInitial()); + when( + () => buyPaymentInfoCubit.getPaymentInfo( + amount: any(named: 'amount'), + currency: any(named: 'currency'), + ), + ).thenAnswer((_) => Future.value()); + }); + + /// Primary CTA state: unknown error shows a Retry button whose tap only + /// calls the already-stubbed getPaymentInfo (no router/service needed). + void stubPrimaryCtaState() { + when(() => buyPaymentInfoCubit.state).thenReturn( + const BuyPaymentInfoFailure(PaymentInfoError.unknown), + ); + when(() => converterCubit.state).thenReturn( + const BuyConverterState( + currency: Currency.eur, + fiatText: '999999999', + sharesText: '1234567.89', + ), + ); + } + + Widget buildSubject() { + return MultiBlocProvider( + providers: [ + BlocProvider.value(value: converterCubit), + BlocProvider.value(value: buyPaymentInfoCubit), + ], + child: const BuyView(), + ); + } + + Future pumpScreen( + WidgetTester tester, + MatrixCell cell, + Widget child, { + MediaQueryData? mediaQueryOverride, + }) async { + final mediaQuery = mediaQueryOverride ?? cell.mediaQuery; + await tester.binding.setSurfaceSize(mediaQuery.size); + addTearDown(() async => await tester.binding.setSurfaceSize(null)); + await tester.pumpWidget( + MediaQuery( + data: mediaQuery, + child: MaterialApp( + locale: const Locale('de'), + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + supportedLocales: S.delegate.supportedLocales, + home: child, + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + } + + group('BuyView responsive matrix (full device × textScale)', () { + for (final cell in kFullResponsiveMatrix) { + testWidgets(cell.id, (tester) async { + await withTargetPlatform(cell.device.platform, () async { + stubPrimaryCtaState(); + + await expectNoLayoutOverflow( + tester, + () async { + await pumpScreen(tester, cell, buildSubject()); + }, + reason: 'overflow on ${cell.label}', + ); + + await expectFullyTappable( + tester, + find.byType(AppFilledButton), + within: find.byType(BuyView), + reason: '${cell.label}: buy CTA not tappable', + ); + }); + }); + } + }); + + // Keyboard: Scaffold.resizeToAvoidBottomInset (default true) shrinks the + // body; ScrollableActionsLayout receives the reduced bounded height. Prove + // the CTA stays fully tappable with a simulated open keyboard. + testWidgets( + 'KEYBOARD: iPhone SE textScale 1.0 — CTA tappable with viewInsets keyboard', + (tester) async { + final cell = MatrixCell( + kIosDeviceProfiles.firstWhere((d) => d.id == 'iphone_se_3'), + 1.0, + ); + final keyboardMediaQuery = cell.mediaQuery.copyWith( + viewInsets: const EdgeInsets.only(bottom: 336), + ); + + await withTargetPlatform(cell.device.platform, () async { + stubPrimaryCtaState(); + + await expectNoLayoutOverflow( + tester, + () async { + await pumpScreen( + tester, + cell, + buildSubject(), + mediaQueryOverride: keyboardMediaQuery, + ); + }, + reason: 'overflow with keyboard on ${cell.label}', + ); + + await expectFullyTappable( + tester, + find.byType(AppFilledButton), + within: find.byType(BuyView), + reason: 'buy CTA not tappable above the keyboard (viewInsets.bottom=336)', + ); + }); + }, + ); +} diff --git a/test/screens/buy/cubits/buy_confirm/buy_confirm_state_test.dart b/test/screens/buy/cubits/buy_confirm/buy_confirm_state_test.dart index c09fb5a04..24738872a 100644 --- a/test/screens/buy/cubits/buy_confirm/buy_confirm_state_test.dart +++ b/test/screens/buy/cubits/buy_confirm/buy_confirm_state_test.dart @@ -83,6 +83,17 @@ void main() { final b = BuyConfirmFailure(BuyConfirmError.unknown); expect(a, isNot(equals(b))); }); + + test('primaryEmailRequired props match', () { + final a = BuyConfirmFailure(BuyConfirmError.primaryEmailRequired); + expect(a.props, [BuyConfirmError.primaryEmailRequired]); + }); + + test('primaryEmailRequired is unequal to aktionariat', () { + final a = BuyConfirmFailure(BuyConfirmError.primaryEmailRequired); + final b = BuyConfirmFailure(BuyConfirmError.aktionariat); + expect(a, isNot(equals(b))); + }); }); group('BuyConfirmState (cross-subclass identity)', () { diff --git a/test/screens/buy/cubits/buy_confirm_cubit_test.dart b/test/screens/buy/cubits/buy_confirm_cubit_test.dart index e99a0d82f..b5b51654f 100644 --- a/test/screens/buy/cubits/buy_confirm_cubit_test.dart +++ b/test/screens/buy/cubits/buy_confirm_cubit_test.dart @@ -116,6 +116,48 @@ void main() { expect((cubit.state as BuyConfirmFailure).error, BuyConfirmError.aktionariat); }); + test('confirmPayment emits Failure(primaryEmailRequired) on ApiException 400 ' + 'with code PrimaryEmailRequired', () async { + when(() => service.confirmPayment(any())).thenAnswer( + (_) async => throw const ApiException( + statusCode: 400, + code: 'PrimaryEmailRequired', + message: 'Primary email is required', + ), + ); + + final cubit = BuyConfirmCubit(service); + final done = cubit.stream.firstWhere((s) => s is BuyConfirmFailure); + await cubit.confirmPayment(7); + await done; + + expect( + (cubit.state as BuyConfirmFailure).error, + BuyConfirmError.primaryEmailRequired, + ); + }); + + test('confirmPayment prefers aktionariat over primaryEmailRequired when a ' + '503 also carries code PrimaryEmailRequired', () async { + // 503 keeps precedence over the PrimaryEmailRequired code — pins the + // branch order so a future refactor can't surface an email-required + // message for a genuine service outage. + when(() => service.confirmPayment(any())).thenAnswer( + (_) async => throw const ApiException( + statusCode: 503, + code: 'PrimaryEmailRequired', + message: 'Aktionariat down', + ), + ); + + final cubit = BuyConfirmCubit(service); + final done = cubit.stream.firstWhere((s) => s is BuyConfirmFailure); + await cubit.confirmPayment(7); + await done; + + expect((cubit.state as BuyConfirmFailure).error, BuyConfirmError.aktionariat); + }); + test('confirmPayment emits Failure(unknown) on other ApiException', () async { when(() => service.confirmPayment(any())).thenAnswer( (_) async => throw const ApiException( diff --git a/test/screens/buy/cubits/buy_payment_info_cubit_test.dart b/test/screens/buy/cubits/buy_payment_info_cubit_test.dart index 5abf60d56..9b9b88450 100644 --- a/test/screens/buy/cubits/buy_payment_info_cubit_test.dart +++ b/test/screens/buy/cubits/buy_payment_info_cubit_test.dart @@ -113,6 +113,25 @@ void main() { expect((cubit.state as BuyPaymentInfoFailure).error, PaymentInfoError.primaryEmailRequired); }); + test('API isValid=false with error=PrimaryEmailNotConfirmed → ' + 'Failure(primaryEmailNotConfirmed)', () async { + // A registered-but-unconfirmed email routes to the confirmation flow + // (via KYC) instead of a post-submit failure or the email-capture path. + when(() => service.getPaymentInfo(any(), currency: any(named: 'currency'))) + .thenAnswer( + (_) async => _info(isValid: false, error: 'PrimaryEmailNotConfirmed'), + ); + + final cubit = build(); + await cubit.getPaymentInfo(amount: '300'); + + expect(cubit.state, isA()); + expect( + (cubit.state as BuyPaymentInfoFailure).error, + PaymentInfoError.primaryEmailNotConfirmed, + ); + }); + test('API isValid=false with unknown error → generic Failure', () async { when(() => service.getPaymentInfo(any(), currency: any(named: 'currency'))) .thenAnswer((_) async => _info(isValid: false, error: 'AmountTooHigh', minVolume: 100)); diff --git a/test/screens/buy/widgets/buy_confirm_button_test.dart b/test/screens/buy/widgets/buy_confirm_button_test.dart index 6127600d9..f97ff8b23 100644 --- a/test/screens/buy/widgets/buy_confirm_button_test.dart +++ b/test/screens/buy/widgets/buy_confirm_button_test.dart @@ -160,6 +160,23 @@ void main() { expect(find.text(S.current.buyPaymentConfirmFailedAmountTooLow), findsOneWidget); }); + testWidgets( + 'shows the aktionariat-specific error on a primary-email-required failure', + (tester) async { + whenListen( + cubit, + Stream.fromIterable([ + const BuyConfirmFailure(BuyConfirmError.primaryEmailRequired), + ]), + initialState: const BuyConfirmInitial(), + ); + + await tester.pumpWidget(host()); + await tester.pump(); + + expect(find.text(S.current.buyPaymentConfirmFailedAktionariat), findsOneWidget); + }); + GoRouter detailsRouter({BuyPaymentInfo info = _info}) => GoRouter( initialLocation: '/buy', routes: [ diff --git a/test/screens/buy/widgets/payment_action_button_test.dart b/test/screens/buy/widgets/payment_action_button_test.dart index 4d25af7e6..5bb775a30 100644 --- a/test/screens/buy/widgets/payment_action_button_test.dart +++ b/test/screens/buy/widgets/payment_action_button_test.dart @@ -11,6 +11,7 @@ import 'package:realunit_wallet/screens/buy/cubits/buy_converter/buy_converter_c import 'package:realunit_wallet/screens/buy/cubits/buy_payment_info/buy_payment_info_cubit.dart'; import 'package:realunit_wallet/screens/buy/widgets/buy_confirm_button.dart'; import 'package:realunit_wallet/screens/buy/widgets/payment_action_button.dart'; +import 'package:realunit_wallet/setup/routing/routes/app_routes.dart'; import 'package:realunit_wallet/setup/routing/routes/support_routes.dart'; import 'package:realunit_wallet/styles/currency.dart'; @@ -84,6 +85,20 @@ void main() { ); }, ), + GoRoute( + name: AppRoutes.kyc, + path: '/kyc', + builder: (_, _) { + pushedRoutes.add(AppRoutes.kyc); + return _EmailCaptureStub( + onReady: (popContext) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (popContext.mounted) popContext.pop(); + }); + }, + ); + }, + ), ], ); } @@ -146,6 +161,49 @@ void main() { }, ); }); + + group('$PaymentActionButton primaryEmailNotConfirmed gate', () { + testWidgets( + 'renders the Weiter CTA, not the binding-buy button', + (tester) async { + when(() => paymentInfoCubit.state).thenReturn( + const BuyPaymentInfoFailure(PaymentInfoError.primaryEmailNotConfirmed), + ); + + await pumpButton(tester); + + expect(find.text(S.current.next), findsOne); + // Pre-tap gate must NOT surface the confirm affordance. + expect(find.byType(BuyConfirmButton), findsNothing); + expect(find.text(S.current.buyPaymentConfirm), findsNothing); + }, + ); + + testWidgets( + 'tap pushes kyc (never email capture) and re-fetches the quote', + (tester) async { + when(() => paymentInfoCubit.state).thenReturn( + const BuyPaymentInfoFailure(PaymentInfoError.primaryEmailNotConfirmed), + ); + + await pumpButton(tester); + + await tester.tap(find.text(S.current.next)); + await tester.pumpAndSettle(); + + // Routed to KYC confirm-email, not to email capture. + expect(pushedRoutes, [AppRoutes.kyc]); + // After the KYC flow returns, the quote is re-fetched with the + // current amount + currency so a now-confirmed email surfaces the CTA. + verify( + () => paymentInfoCubit.getPaymentInfo( + amount: '250', + currency: Currency.eur, + ), + ).called(1); + }, + ); + }); } /// Minimal capture-page stub that pops with a caller-controlled value on diff --git a/test/screens/buy_sell_converter_confirm_states_test.dart b/test/screens/buy_sell_converter_confirm_states_test.dart index 476082b72..74414480f 100644 --- a/test/screens/buy_sell_converter_confirm_states_test.dart +++ b/test/screens/buy_sell_converter_confirm_states_test.dart @@ -73,11 +73,12 @@ void main() { expect(a, isNot(c)); }); - test('BuyConfirmError enum has exactly aktionariat + amountTooLow + unknown', () { + test('BuyConfirmError enum has exactly aktionariat + amountTooLow + primaryEmailRequired + unknown', () { // Pin the variants — the listener in BuyButton switches on these. expect(BuyConfirmError.values.toSet(), { BuyConfirmError.aktionariat, BuyConfirmError.amountTooLow, + BuyConfirmError.primaryEmailRequired, BuyConfirmError.unknown, }); }); diff --git a/test/screens/create_wallet/create_wallet_responsive_matrix_test.dart b/test/screens/create_wallet/create_wallet_responsive_matrix_test.dart new file mode 100644 index 000000000..b6461eaaf --- /dev/null +++ b/test/screens/create_wallet/create_wallet_responsive_matrix_test.dart @@ -0,0 +1,160 @@ +// Responsive matrix gate for CreateWalletView confirm CTA. +// +// Proves the seed-backup confirm button stays fully tappable across the full +// device × text-scale matrix (see test/helper/responsive_matrix.dart). +// Regression lock for the IntrinsicHeight + Spacer collapse that pushed the +// confirm button below the viewport on first frame without a RenderFlex overflow. +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_kyc_service.dart'; +import 'package:realunit_wallet/packages/service/wallet_service.dart'; +import 'package:realunit_wallet/packages/wallet/wallet.dart'; +import 'package:realunit_wallet/packages/wallet/wallet_account.dart'; +import 'package:realunit_wallet/screens/create_wallet/bloc/create_wallet_cubit.dart'; +import 'package:realunit_wallet/screens/create_wallet/create_wallet_view.dart'; +import 'package:realunit_wallet/setup/routing/routes/onboarding_routes.dart'; +import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart'; + +import '../../helper/helper.dart'; + +class MockCreateWalletCubit extends MockCubit + implements CreateWalletCubit {} + +class MockWalletService extends Mock implements WalletService {} + +class MockDfxKycService extends Mock implements DfxKycService {} + +class MockWallet extends Mock implements SoftwareWallet {} + +class MockWalletAccount extends Mock implements WalletAccount {} + +const _seed = + 'cheese trigger cannon mention judge hire snack sustain annual predict illness celery'; + +void main() { + late CreateWalletCubit createWalletCubit; + late MockWallet wallet; + + setUpAll(() { + registerFallbackValue(MockWalletAccount()); + }); + + void setupDependencyInjection() { + final getIt = GetIt.instance; + final walletService = MockWalletService(); + final stubbedWallet = MockWallet(); + when(() => stubbedWallet.currentAccount).thenReturn(MockWalletAccount()); + when( + () => walletService.generateUncommittedSeedWallet(any()), + ).thenAnswer((_) async => stubbedWallet); + getIt.registerSingleton(walletService); + final kyc = MockDfxKycService(); + when(() => kyc.ensureSignatureFor(any())).thenAnswer((_) async {}); + getIt.registerSingleton(kyc); + } + + setUpAll(setupDependencyInjection); + + tearDownAll(() async => await GetIt.instance.reset()); + + setUp(() { + createWalletCubit = MockCreateWalletCubit(); + wallet = MockWallet(); + when(() => wallet.seed).thenReturn(_seed); + when( + () => createWalletCubit.state, + ).thenReturn(CreateWalletState(wallet: wallet)); + whenListen( + createWalletCubit, + const Stream.empty(), + initialState: CreateWalletState(wallet: wallet), + ); + }); + + GoRouter buildRouter() => GoRouter( + initialLocation: '/createWallet', + routes: [ + GoRoute( + name: OnboardingRoutes.createWallet, + path: '/createWallet', + builder: (_, _) => BlocProvider.value( + value: createWalletCubit, + child: const CreateWalletView(), + ), + ), + GoRoute( + name: OnboardingRoutes.verifySeed, + path: '/verifySeed', + builder: (_, _) => const Scaffold( + body: Text('verify-seed-destination'), + ), + ), + ], + ); + + Future pumpScreen(WidgetTester tester, MatrixCell cell) async { + await tester.binding.setSurfaceSize(cell.mediaQuery.size); + addTearDown(() async { + await tester.binding.setSurfaceSize(null); + }); + + await tester.pumpWidget( + MediaQuery( + data: cell.mediaQuery, + child: MaterialApp.router( + routerConfig: buildRouter(), + locale: const Locale('de'), + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + supportedLocales: S.delegate.supportedLocales, + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + } + + group('CreateWalletView responsive matrix (full device × textScale)', () { + for (final cell in kFullResponsiveMatrix) { + testWidgets(cell.id, (tester) async { + await withTargetPlatform(cell.device.platform, () async { + await expectNoLayoutOverflow( + tester, + () async { + await pumpScreen(tester, cell); + }, + reason: 'overflow on ${cell.label}', + ); + + await expectFullyTappable( + tester, + find.byType(AppFilledButton), + within: find.byType(CreateWalletView), + reason: '${cell.label}: confirm CTA not tappable', + ); + + // expectFullyTappable ends with a single pump(); GoRouter page + // transitions need settle before the destination is in the tree. + await tester.pumpAndSettle(); + + expect( + find.text('verify-seed-destination'), + findsOneWidget, + reason: '${cell.label}: confirm tap did not navigate to verifySeed', + ); + }); + }); + } + }); +} diff --git a/test/screens/dashboard/dashboard_responsive_matrix_test.dart b/test/screens/dashboard/dashboard_responsive_matrix_test.dart new file mode 100644 index 000000000..6fd7a62e9 --- /dev/null +++ b/test/screens/dashboard/dashboard_responsive_matrix_test.dart @@ -0,0 +1,208 @@ +// Responsive matrix gate for the empty-dashboard "RealUnit kaufen" CTA. +// +// Proves the buy CTA stays reachable and fully tappable across the full +// device x text-scale matrix (see test/helper/responsive_matrix.dart). +// Regression lock for the dead-button bug: `Column(..., Spacer(), )` +// inside the height-bounded `Expanded > Stack` host (dashboard_page.dart) +// overflowed already at default text scale with one pending transaction, +// painting the CTA outside the parent's hit-testable region. +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/models/balance.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/transactions/dto/transactions_dto.dart'; +import 'package:realunit_wallet/packages/utils/default_assets.dart'; +import 'package:realunit_wallet/screens/dashboard/bloc/balance_cubit.dart'; +import 'package:realunit_wallet/screens/dashboard/bloc/dashboard_bloc.dart'; +import 'package:realunit_wallet/screens/dashboard/bloc/pending_transactions_cubit.dart'; +import 'package:realunit_wallet/screens/dashboard/dashboard_page.dart'; +import 'package:realunit_wallet/screens/settings/bloc/settings_bloc.dart'; +import 'package:realunit_wallet/setup/routing/routes/app_routes.dart'; +import 'package:realunit_wallet/styles/currency.dart'; +import 'package:realunit_wallet/styles/themes.dart'; + +import '../../helper/helper.dart'; + +class _MockDashboardBloc extends MockBloc + implements DashboardBloc {} + +class _MockBalanceCubit extends MockCubit implements BalanceCubit {} + +class _MockPendingTransactionsCubit extends MockCubit> + implements PendingTransactionsCubit {} + +void main() { + late _MockDashboardBloc dashboardBloc; + late _MockBalanceCubit balanceCubit; + late _MockPendingTransactionsCubit pendingTxCubit; + late MockSettingsBloc settingsBloc; + + Balance zeroBalance() => Balance( + chainId: realUnitAsset.chainId, + contractAddress: realUnitAsset.address, + walletAddress: '0x0', + balance: BigInt.zero, + asset: realUnitAsset, + ); + + DashboardState emptyDashboardState() => DashboardState( + price: BigInt.zero, + priceChart: const [], + portfolioHistory: const [], + currency: Currency.chf, + ); + + // Long amount + asset so the trailing amount/date column is under real + // width pressure at high text scales (gates the Flexible wrap on + // PendingTransactionRow). Realistic sell-size, not an absurd string. + final waitingForPaymentTx = TransactionDto( + id: 1, + type: TransactionType.sell, + state: TransactionState.waitingForPayment, + inputAmount: 123456.78, + inputAsset: 'REALU', + date: DateTime.utc(2026, 5, 20, 12), + ); + + setUp(() { + dashboardBloc = _MockDashboardBloc(); + balanceCubit = _MockBalanceCubit(); + pendingTxCubit = _MockPendingTransactionsCubit(); + settingsBloc = MockSettingsBloc(); + + when(() => dashboardBloc.state).thenReturn(emptyDashboardState()); + when(() => balanceCubit.state).thenReturn(zeroBalance()); + when(() => balanceCubit.asset).thenReturn(realUnitAsset); + when(() => pendingTxCubit.state).thenReturn(const []); + when(() => settingsBloc.state).thenReturn(const SettingsState()); + }); + + Widget buildDashboard() => MultiBlocProvider( + providers: [ + BlocProvider.value(value: settingsBloc), + BlocProvider.value(value: dashboardBloc), + BlocProvider.value(value: balanceCubit), + BlocProvider.value(value: pendingTxCubit), + ], + child: const DashboardView(), + ); + + // A minimal two-route stack: '/' hosts the dashboard, '/buy' is a marker + // page so a real `context.pushNamed(AppRoutes.buy)` resolves instead of + // throwing (the CTA's actual, unmocked navigation call). + GoRouter buildRouter() => GoRouter( + initialLocation: '/', + routes: [ + GoRoute(path: '/', builder: (_, _) => buildDashboard()), + GoRoute( + name: AppRoutes.buy, + path: '/buy', + builder: (_, _) => const Scaffold( + body: Center(child: Text('buy-page-marker')), + ), + ), + ], + ); + + Future pumpDashboard(WidgetTester tester, MatrixCell cell) async { + final router = buildRouter(); + addTearDown(router.dispose); + + await tester.binding.setSurfaceSize(cell.device.size); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + MediaQuery( + data: cell.mediaQuery, + child: MaterialApp.router( + routerConfig: router, + theme: realUnitTheme, + locale: const Locale('de'), + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + supportedLocales: S.delegate.supportedLocales, + ), + ), + ); + // The pending-tx row hosts a CupertinoActivityIndicator that never + // settles, and the empty-state SVG needs a couple of frames to decode — + // fixed pumps only, never pumpAndSettle here (mirrors + // dashboard_states_golden_test.dart's pendingTransactions pumpBeforeTest). + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + } + + group('DashboardView responsive matrix - empty balance, buy CTA reachable ' + '(full device x textScale)', () { + for (final cell in kFullResponsiveMatrix) { + for (final hasPendingTx in [false, true]) { + final label = hasPendingTx ? 'with pending tx' : 'no pending tx'; + testWidgets('$label - ${cell.id}', (tester) async { + await withTargetPlatform(cell.device.platform, () async { + when(() => pendingTxCubit.state).thenReturn( + hasPendingTx ? [waitingForPaymentTx] : const [], + ); + + await expectNoLayoutOverflow( + tester, + () async { + await pumpDashboard(tester, cell); + }, + reason: 'overflow on $label / ${cell.label}', + ); + + await expectFullyTappable( + tester, + find.text(S.current.buyRealUnit), + within: find.byType(DashboardView), + reason: '$label / ${cell.label}: buy CTA not tappable', + ); + }); + }); + } + } + }); + + // Focused regression: the exact reported failure mode (empty balance, one + // waitingForPayment pending tx, default text scale) must invoke the real + // navigation via a tap - not just have a non-null onPressed. The old code + // silently swallowed the tap because the button was painted outside the + // hit-testable region. + testWidgets( + 'REGRESSION: zero balance + one pending tx @1.0x - tap on the buy CTA ' + 'actually navigates to AppRoutes.buy', + (tester) async { + final cell = MatrixCell( + kIosDeviceProfiles.firstWhere((d) => d.id == 'iphone_se_3'), + 1.0, + ); + await withTargetPlatform(cell.device.platform, () async { + when(() => pendingTxCubit.state).thenReturn([waitingForPaymentTx]); + + await expectNoLayoutOverflow(tester, () async { + await pumpDashboard(tester, cell); + }); + + expect(find.text('buy-page-marker'), findsNothing); + + await expectFullyTappable( + tester, + find.text(S.current.buyRealUnit), + within: find.byType(DashboardView), + ); + await tester.pumpAndSettle(); + + expect(find.text('buy-page-marker'), findsOneWidget); + }); + }, + ); +} diff --git a/test/screens/hardware_connect_bitbox/connect_bitbox_responsive_matrix_test.dart b/test/screens/hardware_connect_bitbox/connect_bitbox_responsive_matrix_test.dart new file mode 100644 index 000000000..cdc827b0d --- /dev/null +++ b/test/screens/hardware_connect_bitbox/connect_bitbox_responsive_matrix_test.dart @@ -0,0 +1,175 @@ +// Responsive matrix gate for BitBox connect sheet CTAs. +// +// Proves every button-bearing state stays fully tappable across the full +// device × text-scale matrix (see test/helper/responsive_matrix.dart). +// This is the regression lock for the iOS pairing "Bestätigen not tappable" +// bug: long DE copy + real channel hash + home-indicator padding + sheet clip. +import 'package:bitbox_flutter/bitbox_flutter.dart' as sdk; +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/packages/wallet/wallet.dart'; +import 'package:realunit_wallet/screens/hardware_connect_bitbox/bloc/connect_bitbox_cubit.dart'; +import 'package:realunit_wallet/screens/hardware_connect_bitbox/connect_bitbox_view.dart'; +import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart'; + +import '../../helper/helper.dart'; + +class _MockCubit extends MockCubit implements ConnectBitboxCubit {} + +class _FakeDevice extends Fake implements sdk.BitboxDevice {} + +class _MockWallet extends Mock implements BitboxWallet {} + +/// Worst-case pairing code from production reports (two visual groups). +const _realChannelHash = 'SDP53 Z7GIS FUDJ3 OIEIV'; + +void main() { + late _MockCubit cubit; + late _FakeDevice device; + late _MockWallet wallet; + + setUp(() { + cubit = _MockCubit(); + device = _FakeDevice(); + wallet = _MockWallet(); + }); + + void stubState(BitboxConnectionState state) { + when(() => cubit.state).thenReturn(state); + whenListen( + cubit, + const Stream.empty(), + initialState: state, + ); + } + + Future pumpSheet(WidgetTester tester, MatrixCell cell) async { + await pumpClippedSheet( + tester, + widget: BlocProvider.value( + value: cubit, + child: ConnectBitboxView( + onFinish: (_) {}, + onCancel: () {}, + ), + ), + mediaQuery: cell.mediaQuery, + ); + } + + /// Every state that shows at least one CTA must stay tappable on every cell. + Map buttonStates() => { + 'checkHash': BitboxCheckHash(device, _realChannelHash), + 'notInitialized': BitboxNotInitialized(device), + 'signatureFailed': BitboxSignatureFailed(wallet), + 'connected': BitboxConnected(wallet), + 'notConnected': BitboxNotConnected(), + }; + + group('ConnectBitboxView responsive matrix (full device × textScale)', () { + for (final cell in kFullResponsiveMatrix) { + // State keys are fixed; instances are built in setUp-time via buttonStates(). + for (final stateKey in const [ + 'checkHash', + 'notInitialized', + 'signatureFailed', + 'connected', + 'notConnected', + ]) { + testWidgets('$stateKey · ${cell.id}', (tester) async { + await withTargetPlatform(cell.device.platform, () async { + stubState(buttonStates()[stateKey]!); + when(() => cubit.confirmPairing()).thenAnswer((_) async {}); + when(() => cubit.recheckDeviceStatus()).thenAnswer((_) async {}); + when(() => cubit.retrySignatureCapture()).thenAnswer((_) async {}); + when(() => cubit.continueWithoutSignature()).thenReturn(null); + when(() => cubit.finishSetup()).thenReturn(null); + + await expectNoLayoutOverflow( + tester, + () async { + await pumpSheet(tester, cell); + }, + reason: 'overflow on $stateKey / ${cell.label}', + ); + + final buttons = find.byType(AppFilledButton); + expect( + buttons, + findsWidgets, + reason: '$stateKey / ${cell.label}: expected CTA(s)', + ); + + // Primary CTA (first button) must be fully inside the sheet and + // receive a real pointer event. + final primary = buttons.first; + await expectFullyTappable( + tester, + primary, + within: find.byType(ConnectBitboxView), + reason: '$stateKey / ${cell.label}: primary CTA not tappable', + ); + }); + }); + } + } + }); + + // Focused regression: the exact reported failure mode (iPhone 15 DE + real + // hash) must invoke confirmPairing via tap — not via onPressed.call(). + testWidgets( + 'REGRESSION: iPhone 15 DE checkHash real hash — tap calls confirmPairing', + (tester) async { + final cell = MatrixCell( + kIosDeviceProfiles.firstWhere((d) => d.id == 'iphone_15'), + 1.0, + ); + await withTargetPlatform(cell.device.platform, () async { + stubState(BitboxCheckHash(device, _realChannelHash)); + when(() => cubit.confirmPairing()).thenAnswer((_) async {}); + + await pumpSheet(tester, cell); + + await expectFullyTappable( + tester, + find.text('Bestätigen'), + within: find.byType(ConnectBitboxView), + ); + verify(() => cubit.confirmPairing()).called(1); + + // Cancel must also be on-screen (was fully clipped in the bug report). + await expectFullyTappable( + tester, + find.text('Abbrechen'), + within: find.byType(ConnectBitboxView), + ); + }); + }, + ); + + testWidgets( + 'REGRESSION: iPhone SE + textScale 3.0 checkHash still tappable', + (tester) async { + final cell = MatrixCell( + kIosDeviceProfiles.firstWhere((d) => d.id == 'iphone_se_3'), + 3.0, + ); + await withTargetPlatform(cell.device.platform, () async { + stubState(BitboxCheckHash(device, _realChannelHash)); + when(() => cubit.confirmPairing()).thenAnswer((_) async {}); + + await expectNoLayoutOverflow(tester, () async { + await pumpSheet(tester, cell); + }); + await expectFullyTappable( + tester, + find.byType(AppFilledButton).first, + within: find.byType(ConnectBitboxView), + ); + verify(() => cubit.confirmPairing()).called(1); + }); + }, + ); +} diff --git a/test/screens/kyc/cubits/kyc/kyc_cubit_test.dart b/test/screens/kyc/cubits/kyc/kyc_cubit_test.dart index ff77d630b..27fcb1d91 100644 --- a/test/screens/kyc/cubits/kyc/kyc_cubit_test.dart +++ b/test/screens/kyc/cubits/kyc/kyc_cubit_test.dart @@ -11,12 +11,16 @@ import 'package:realunit_wallet/packages/service/dfx/models/kyc/dto/kyc_level_dt import 'package:realunit_wallet/packages/service/dfx/models/kyc/dto/kyc_session_dto.dart'; import 'package:realunit_wallet/packages/service/dfx/models/kyc/dto/kyc_step_dto.dart'; import 'package:realunit_wallet/packages/service/dfx/models/kyc/kyc_level.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/legal/dto/real_unit_legal_agreement_status_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/legal/dto/real_unit_legal_info_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/legal/real_unit_legal_agreement.dart'; import 'package:realunit_wallet/packages/service/dfx/models/registration/registration_email_status.dart'; import 'package:realunit_wallet/packages/service/dfx/models/registration/kyc/kyc_personal_data.dart'; import 'package:realunit_wallet/packages/service/dfx/models/user/dto/real_unit_user_data_dto.dart'; import 'package:realunit_wallet/packages/service/dfx/models/user/dto/user_dto.dart'; import 'package:realunit_wallet/packages/service/dfx/models/wallet/real_unit_registration_info_dto.dart'; import 'package:realunit_wallet/packages/service/dfx/models/wallet/real_unit_registration_state.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_legal_service.dart'; import 'package:realunit_wallet/packages/service/dfx/real_unit_registration_service.dart'; import 'package:realunit_wallet/packages/wallet/wallet.dart'; import 'package:realunit_wallet/screens/kyc/cubits/kyc/kyc_cubit.dart'; @@ -25,6 +29,8 @@ class _MockDfxKycService extends Mock implements DfxKycService {} class _MockRealUnitRegistrationService extends Mock implements RealUnitRegistrationService {} +class _MockRealUnitLegalService extends Mock implements RealUnitLegalService {} + class _MockAppStore extends Mock implements AppStore {} class _MockAWallet extends Mock implements AWallet {} @@ -92,10 +98,26 @@ RealUnitRegistrationInfoDto _walletStatus( RealUnitRegistrationState state, { RealUnitUserDataDto? userData, bool? emailConfirmed, + bool? manualReview, }) => RealUnitRegistrationInfoDto( state: state, realUnitUserDataDto: userData, emailConfirmed: emailConfirmed, + manualReview: manualReview, +); + +// Single-agreement legal info. `allAccepted:false` leaves the one agreement +// outstanding (so `outstandingAgreements` == [residenceConfirmation]). +RealUnitLegalInfoDto _legalInfo({bool allAccepted = true}) => RealUnitLegalInfoDto( + agreements: [ + RealUnitLegalAgreementStatusDto( + agreement: RealUnitLegalAgreement.residenceConfirmation, + currentVersion: '20260712', + acceptedVersion: allAccepted ? '20260712' : null, + accepted: allAccepted, + ), + ], + allAccepted: allAccepted, ); const _kycPersonalData = KycPersonalData( @@ -125,12 +147,18 @@ const _fixtureUserData = RealUnitUserDataDto( void main() { late DfxKycService kycService; late RealUnitRegistrationService registrationService; + late RealUnitLegalService legalService; late AppStore appStore; late AWallet wallet; + setUpAll(() { + registerFallbackValue([]); + }); + setUp(() { kycService = _MockDfxKycService(); registrationService = _MockRealUnitRegistrationService(); + legalService = _MockRealUnitLegalService(); appStore = _MockAppStore(); wallet = _MockAWallet(); when(() => appStore.wallet).thenReturn(wallet); @@ -142,9 +170,13 @@ void main() { when(() => registrationService.getRegistrationInfo()).thenAnswer( (_) async => _walletStatus(RealUnitRegistrationState.alreadyRegistered), ); + // Default: the server reports all legal agreements accepted, so the + // disclaimer gate passes and tests exercise downstream routing. Tests that + // exercise the gate itself override this per-test. + when(() => legalService.getLegalInfo()).thenAnswer((_) async => _legalInfo()); }); - KycCubit buildCubit() => KycCubit(kycService, registrationService, appStore); + KycCubit buildCubit() => KycCubit(kycService, registrationService, legalService, appStore); group('$KycCubit checkKyc', () { blocTest( @@ -185,13 +217,65 @@ void main() { }, ); + // Legal disclaimer gate is server-driven: the API is the single source of + // truth for whether outstanding agreements remain. `allAccepted:false` + // routes to the disclaimer step. + blocTest( + 'emits KycSuccess(legalDisclaimer) when the server reports outstanding agreements', + setUp: () { + when(() => kycService.getKycStatus()).thenAnswer( + (_) async => _kycStatus(level: KycLevel.level20), + ); + when(() => kycService.getUser()).thenAnswer((_) async => _user()); + when(() => legalService.getLegalInfo()).thenAnswer( + (_) async => _legalInfo(allAccepted: false), + ); + }, + build: buildCubit, + act: (cubit) => cubit.checkKyc(), + expect: () => [ + const KycLoading(), + const KycSuccess(currentStep: KycStep.legalDisclaimer), + ], + ); + + // `allAccepted:true` (default stub) passes the gate without ever showing + // the disclaimer — the once-per-session ceremony is gone; the server + // decides. + blocTest( + 'proceeds past the disclaimer gate when the server reports allAccepted', + setUp: () { + when(() => kycService.getKycStatus()).thenAnswer( + (_) async => _kycStatus( + level: KycLevel.level50, + processStatus: KycProcessStatus.completed, + ), + ); + when(() => kycService.getUser()).thenAnswer((_) async => _user()); + }, + build: buildCubit, + act: (cubit) => cubit.checkKyc(), + expect: () => [ + const KycLoading(), + const KycCompleted(), + ], + ); + + // Legacy tolerance (mirrors the `emailConfirmed` gate): a 404 means the + // legal endpoint is not deployed yet (pre-rollout backend), so the gate + // falls back to the local per-session flag. With nothing accepted yet the + // flag is `false`, so the disclaimer is shown — identical to the previous + // behaviour. blocTest( - 'emits KycSuccess(legalDisclaimer) when disclaimer not yet accepted', + 'falls back to the local session gate (shows disclaimer) when getLegalInfo returns 404', setUp: () { when(() => kycService.getKycStatus()).thenAnswer( (_) async => _kycStatus(level: KycLevel.level20), ); when(() => kycService.getUser()).thenAnswer((_) async => _user()); + when(() => legalService.getLegalInfo()).thenThrow( + const ApiException(statusCode: 404, code: 'NOT_FOUND', message: ''), + ); }, build: buildCubit, act: (cubit) => cubit.checkKyc(), @@ -201,6 +285,28 @@ void main() { ], ); + // Fail closed: any non-404 error on the compliance gate must surface as a + // KycFailure — never silently degrade to the local flag (no silent fallback + // on a legal gate). + blocTest( + 'emits KycFailure when getLegalInfo fails with a non-404 error', + setUp: () { + when(() => kycService.getKycStatus()).thenAnswer( + (_) async => _kycStatus(level: KycLevel.level20), + ); + when(() => kycService.getUser()).thenAnswer((_) async => _user()); + when(() => legalService.getLegalInfo()).thenThrow( + const ApiException(statusCode: 500, code: 'SERVER_ERROR', message: 'boom'), + ); + }, + build: buildCubit, + act: (cubit) => cubit.checkKyc(), + expect: () => [ + const KycLoading(), + isA(), + ], + ); + // Wave 3 — server-side registration state drives routing. The cubit // re-fetches `getRegistrationInfo()` after the disclaimer gate and dispatches // on its `state` field, with no local sign-produced flag. The DTO that @@ -221,10 +327,7 @@ void main() { ); }, build: buildCubit, - act: (cubit) async { - cubit.markLegalDisclaimerAccepted(); - await cubit.checkKyc(); - }, + act: (cubit) => cubit.checkKyc(), expect: () => [ const KycLoading(), const KycSuccess( @@ -246,10 +349,7 @@ void main() { ); }, build: buildCubit, - act: (cubit) async { - cubit.markLegalDisclaimerAccepted(); - await cubit.checkKyc(); - }, + act: (cubit) => cubit.checkKyc(), expect: () => [ const KycLoading(), const KycSuccess(currentStep: KycStep.registration), @@ -271,10 +371,7 @@ void main() { ); }, build: buildCubit, - act: (cubit) async { - cubit.markLegalDisclaimerAccepted(); - await cubit.checkKyc(); - }, + act: (cubit) => cubit.checkKyc(), expect: () => [ const KycLoading(), const KycSuccess( @@ -306,10 +403,7 @@ void main() { ); }, build: buildCubit, - act: (cubit) async { - cubit.markLegalDisclaimerAccepted(); - await cubit.checkKyc(); - }, + act: (cubit) => cubit.checkKyc(), expect: () => [ const KycLoading(), const KycSignatureUnsupportedFailure(), @@ -332,10 +426,7 @@ void main() { ); }, build: buildCubit, - act: (cubit) async { - cubit.markLegalDisclaimerAccepted(); - await cubit.checkKyc(); - }, + act: (cubit) => cubit.checkKyc(), expect: () => [ const KycLoading(), const KycSignatureUnsupportedFailure(), @@ -359,10 +450,7 @@ void main() { ); }, build: buildCubit, - act: (cubit) async { - cubit.markLegalDisclaimerAccepted(); - await cubit.checkKyc(); - }, + act: (cubit) => cubit.checkKyc(), expect: () => [ const KycLoading(), const KycCompleted(), @@ -371,8 +459,8 @@ void main() { // Email-confirmation gate (API-driven). Only an explicit // `emailConfirmed == false` on an already-registered wallet routes to the - // confirm step; `true` and `null` (legacy / grandfathered) proceed as - // before. + // confirm step; `true` (confirmed, or grandfathered) and `null` (pre-rollout + // backend) proceed as before. blocTest( 'emits KycSuccess(confirmEmail) when AlreadyRegistered and emailConfirmed=false', setUp: () { @@ -391,10 +479,7 @@ void main() { ); }, build: buildCubit, - act: (cubit) async { - cubit.markLegalDisclaimerAccepted(); - await cubit.checkKyc(); - }, + act: (cubit) => cubit.checkKyc(), expect: () => [ const KycLoading(), const KycSuccess(currentStep: KycStep.confirmEmail), @@ -419,10 +504,7 @@ void main() { ); }, build: buildCubit, - act: (cubit) async { - cubit.markLegalDisclaimerAccepted(); - await cubit.checkKyc(); - }, + act: (cubit) => cubit.checkKyc(), expect: () => [ const KycLoading(), const KycCompleted(), @@ -439,7 +521,8 @@ void main() { ), ); when(() => kycService.getUser()).thenAnswer((_) async => _user()); - // Explicit null — the pre-rollout backend / grandfathered account case. + // Explicit null — the pre-rollout backend case (grandfathered accounts + // report an explicit `true`, never `null`). when(() => registrationService.getRegistrationInfo()).thenAnswer( (_) async => _walletStatus( RealUnitRegistrationState.alreadyRegistered, @@ -447,16 +530,158 @@ void main() { ); }, build: buildCubit, - act: (cubit) async { - cubit.markLegalDisclaimerAccepted(); - await cubit.checkKyc(); + act: (cubit) => cubit.checkKyc(), + expect: () => [ + const KycLoading(), + const KycCompleted(), + ], + ); + + // Manual-review gate (API-driven). When the Aktionariat forward failed and + // staff must re-forward the registration, the API reports + // `manualReview == true` on the already-registered wallet and the cubit + // parks the user on a dedicated waiting state. `false` and `null` + // (pre-rollout backend) proceed as before. + blocTest( + 'emits KycManualReview when AlreadyRegistered and manualReview=true', + setUp: () { + when(() => kycService.getKycStatus()).thenAnswer( + (_) async => _kycStatus( + level: KycLevel.level50, + processStatus: KycProcessStatus.completed, + ), + ); + when(() => kycService.getUser()).thenAnswer((_) async => _user()); + when(() => registrationService.getRegistrationInfo()).thenAnswer( + (_) async => _walletStatus( + RealUnitRegistrationState.alreadyRegistered, + manualReview: true, + ), + ); + }, + build: buildCubit, + act: (cubit) => cubit.checkKyc(), + expect: () => [ + const KycLoading(), + const KycManualReview(), + ], + ); + + // Manual review takes precedence over the e-mail gate: even with + // `emailConfirmed == false`, an explicit `manualReview == true` parks the + // user on the review screen rather than routing to the confirm step. + blocTest( + 'manualReview=true takes precedence over emailConfirmed=false', + setUp: () { + when(() => kycService.getKycStatus()).thenAnswer( + (_) async => _kycStatus( + level: KycLevel.level50, + processStatus: KycProcessStatus.completed, + ), + ); + when(() => kycService.getUser()).thenAnswer((_) async => _user()); + when(() => registrationService.getRegistrationInfo()).thenAnswer( + (_) async => _walletStatus( + RealUnitRegistrationState.alreadyRegistered, + manualReview: true, + emailConfirmed: false, + ), + ); + }, + build: buildCubit, + act: (cubit) => cubit.checkKyc(), + expect: () => [ + const KycLoading(), + const KycManualReview(), + ], + ); + + // `manualReview == false` is no gate: the cubit proceeds exactly as before + // and still applies the e-mail gate below it (here → confirmEmail). + blocTest( + 'does NOT emit KycManualReview when manualReview=false (still hits the confirmEmail gate)', + setUp: () { + when(() => kycService.getKycStatus()).thenAnswer( + (_) async => _kycStatus( + level: KycLevel.level50, + processStatus: KycProcessStatus.completed, + ), + ); + when(() => kycService.getUser()).thenAnswer((_) async => _user()); + when(() => registrationService.getRegistrationInfo()).thenAnswer( + (_) async => _walletStatus( + RealUnitRegistrationState.alreadyRegistered, + manualReview: false, + emailConfirmed: false, + ), + ); + }, + build: buildCubit, + act: (cubit) => cubit.checkKyc(), + expect: () => [ + const KycLoading(), + const KycSuccess(currentStep: KycStep.confirmEmail), + ], + ); + + // Legacy fallback: `manualReview == null` (pre-rollout backend) is no gate — + // the cubit proceeds exactly as before (here → Completed). + blocTest( + 'legacy fallback: proceeds when AlreadyRegistered and manualReview=null', + setUp: () { + when(() => kycService.getKycStatus()).thenAnswer( + (_) async => _kycStatus( + level: KycLevel.level50, + processStatus: KycProcessStatus.completed, + ), + ); + when(() => kycService.getUser()).thenAnswer((_) async => _user()); + // Explicit null manualReview + confirmed e-mail — no gate at all. + when(() => registrationService.getRegistrationInfo()).thenAnswer( + (_) async => _walletStatus( + RealUnitRegistrationState.alreadyRegistered, + emailConfirmed: true, + ), + ); }, + build: buildCubit, + act: (cubit) => cubit.checkKyc(), expect: () => [ const KycLoading(), const KycCompleted(), ], ); + // The confirm gate is scoped to `alreadyRegistered`. In `addWallet`, + // `emailConfirmed` describes the other wallet's registration, so an explicit + // `false` must NOT route to the confirm step — the user proceeds to link the + // wallet and the gate is re-evaluated on the fresh registration afterwards. + blocTest( + 'does NOT gate on emailConfirmed=false when AddWallet (routes to linkWallet)', + setUp: () { + when(() => kycService.getKycStatus()).thenAnswer( + (_) async => _kycStatus(level: KycLevel.level20), + ); + when(() => kycService.getUser()).thenAnswer((_) async => _user()); + when(() => registrationService.getRegistrationInfo()).thenAnswer( + (_) async => _walletStatus( + RealUnitRegistrationState.addWallet, + userData: _fixtureUserData, + emailConfirmed: false, + ), + ); + }, + build: buildCubit, + act: (cubit) => cubit.checkKyc(), + expect: () => [ + const KycLoading(), + const KycSuccess( + currentStep: KycStep.linkWallet, + realUnitUserData: _fixtureUserData, + ), + ], + ); + // Failure-path coverage for the wallet-status round-trip: if // `getRegistrationInfo()` itself throws, the outer `catch` in `_runCheckKyc` // must surface a `KycFailure` rather than leaking the exception through @@ -473,10 +698,7 @@ void main() { ); }, build: buildCubit, - act: (cubit) async { - cubit.markLegalDisclaimerAccepted(); - await cubit.checkKyc(); - }, + act: (cubit) => cubit.checkKyc(), expect: () => [ const KycLoading(), isA(), @@ -500,10 +722,7 @@ void main() { when(() => kycService.getUser()).thenAnswer((_) async => _user()); }, build: buildCubit, - act: (cubit) async { - cubit.markLegalDisclaimerAccepted(); - await cubit.checkKyc(); - }, + act: (cubit) => cubit.checkKyc(), expect: () => [ const KycLoading(), const KycAccountMergeRequested(), @@ -525,10 +744,7 @@ void main() { when(() => kycService.getUser()).thenAnswer((_) async => _user()); }, build: buildCubit, - act: (cubit) async { - cubit.markLegalDisclaimerAccepted(); - await cubit.checkKyc(); - }, + act: (cubit) => cubit.checkKyc(), expect: () => [const KycLoading(), const KycCompleted()], ); @@ -544,10 +760,7 @@ void main() { when(() => kycService.getUser()).thenAnswer((_) async => _user()); }, build: buildCubit, - act: (cubit) async { - cubit.markLegalDisclaimerAccepted(); - await cubit.checkKyc(); - }, + act: (cubit) => cubit.checkKyc(), expect: () => [const KycLoading(), const KycMergeProcessing()], ); @@ -566,10 +779,7 @@ void main() { when(() => kycService.getUser()).thenAnswer((_) async => _user()); }, build: buildCubit, - act: (cubit) async { - cubit.markLegalDisclaimerAccepted(); - await cubit.checkKyc(); - }, + act: (cubit) => cubit.checkKyc(), expect: () => [const KycLoading(), const KycPending(KycStep.ident)], ); @@ -591,10 +801,7 @@ void main() { when(() => kycService.getUser()).thenAnswer((_) async => _user()); }, build: buildCubit, - act: (cubit) async { - cubit.markLegalDisclaimerAccepted(); - await cubit.checkKyc(); - }, + act: (cubit) => cubit.checkKyc(), expect: () => [ const KycLoading(), const KycUnsupportedStepFailure(null), @@ -624,10 +831,7 @@ void main() { when(() => kycService.getUser()).thenAnswer((_) async => _user()); }, build: buildCubit, - act: (cubit) async { - cubit.markLegalDisclaimerAccepted(); - await cubit.checkKyc(); - }, + act: (cubit) => cubit.checkKyc(), expect: () => [ const KycLoading(), const KycUnsupportedStepFailure(KycStepName.additionalDocuments), @@ -649,10 +853,7 @@ void main() { when(() => kycService.getUser()).thenAnswer((_) async => _user()); }, build: buildCubit, - act: (cubit) async { - cubit.markLegalDisclaimerAccepted(); - await cubit.checkKyc(); - }, + act: (cubit) => cubit.checkKyc(), expect: () => [const KycLoading(), const KycPending(KycStep.dfxApproval)], ); @@ -678,10 +879,7 @@ void main() { ); }, build: buildCubit, - act: (cubit) async { - cubit.markLegalDisclaimerAccepted(); - await cubit.checkKyc(); - }, + act: (cubit) => cubit.checkKyc(), expect: () => [ const KycLoading(), const KycSuccess( @@ -710,10 +908,7 @@ void main() { ); }, build: buildCubit, - act: (cubit) async { - cubit.markLegalDisclaimerAccepted(); - await cubit.checkKyc(); - }, + act: (cubit) => cubit.checkKyc(), expect: () => [ const KycLoading(), const KycUnsupportedStepFailure(KycStepName.personalData), @@ -742,10 +937,7 @@ void main() { ); }, build: buildCubit, - act: (cubit) async { - cubit.markLegalDisclaimerAccepted(); - await cubit.checkKyc(); - }, + act: (cubit) => cubit.checkKyc(), expect: () => [ const KycLoading(), const KycUnsupportedStepFailure(null), @@ -764,10 +956,7 @@ void main() { when(() => kycService.getUser()).thenAnswer((_) async => _user()); }, build: buildCubit, - act: (cubit) async { - cubit.markLegalDisclaimerAccepted(); - await cubit.checkKyc(); - }, + act: (cubit) => cubit.checkKyc(), expect: () => [const KycLoading(), isA()], ); @@ -788,10 +977,7 @@ void main() { when(() => kycService.getUser()).thenAnswer((_) async => _user()); }, build: buildCubit, - act: (cubit) async { - cubit.markLegalDisclaimerAccepted(); - await cubit.checkKyc(); - }, + act: (cubit) => cubit.checkKyc(), verify: (cubit) { final state = cubit.state as KycFailure; expect(state.message, 'KYC terminated'); @@ -802,8 +988,12 @@ void main() { ], ); + // Previously a returning user was always re-shown the disclaimer (the flag + // reset on every cubit construction). Now the server drives the gate: an + // already-accepted returning user (`allAccepted:true`, the default stub) + // skips the disclaimer entirely and lands on completed. blocTest( - 'returning user at completed processStatus still routes through the disclaimer first', + 'returning user with all agreements accepted skips the disclaimer and lands on completed', setUp: () { when(() => kycService.getKycStatus()).thenAnswer( (_) async => _kycStatus( @@ -814,12 +1004,10 @@ void main() { when(() => kycService.getUser()).thenAnswer((_) async => _user()); }, build: buildCubit, - act: (cubit) async { - await cubit.checkKyc(); - }, + act: (cubit) => cubit.checkKyc(), expect: () => [ const KycLoading(), - const KycSuccess(currentStep: KycStep.legalDisclaimer), + const KycCompleted(), ], ); @@ -902,6 +1090,46 @@ void main() { ); }); + // Per-arm coverage of `_mapStepName`: line coverage marks every switch- + // expression arm as hit the moment the switch is evaluated, so it cannot + // prove an individual arm maps its input to the right step. Drive + // `_continueKyc` with each step name and assert the resulting step — the + // assertion, not the line hit, pins the arm. (`ident` and `dfxApproval` are + // already exercised by the InProgress / PendingReview tests above.) + group('$KycCubit _mapStepName routing arms', () { + void expectStepMapsTo(KycStepName name, KycStep expected) { + blocTest( + '$name maps to $expected', + setUp: () { + when(() => kycService.getKycStatus()).thenAnswer( + (_) async => _kycStatus( + level: KycLevel.level20, + processStatus: KycProcessStatus.inProgress, + ), + ); + when(() => kycService.getUser()).thenAnswer((_) async => _user()); + when(() => kycService.continueKyc()).thenAnswer( + (_) async => _session( + level: KycLevel.level20, + steps: const [], + currentStep: _currentStep(name, url: 'https://example.com/step'), + ), + ); + }, + build: buildCubit, + act: (cubit) => cubit.checkKyc(), + expect: () => [ + const KycLoading(), + KycSuccess(currentStep: expected, urlOrToken: 'https://example.com/step'), + ], + ); + } + + expectStepMapsTo(KycStepName.contactData, KycStep.registration); + expectStepMapsTo(KycStepName.nationalityData, KycStep.nationality); + expectStepMapsTo(KycStepName.financialData, KycStep.financialData); + }); + group('$KycCubit timeout & generation handling', () { test( 'a late response from a timed-out call does NOT overwrite the fresh state of a retry (regression for #315 / #317)', @@ -919,8 +1147,9 @@ void main() { }); when(() => kycService.getUser()).thenAnswer((_) async => _user()); - final cubit = KycCubit(kycService, registrationService, appStore) - ..markLegalDisclaimerAccepted(); + // Server reports all agreements accepted (default stub), so the + // disclaimer gate passes and both runs reach the completed state. + final cubit = KycCubit(kycService, registrationService, legalService, appStore); final states = []; final sub = cubit.stream.listen(states.add); @@ -1012,10 +1241,7 @@ void main() { ); }, build: buildCubit, - act: (cubit) async { - cubit.markLegalDisclaimerAccepted(); - await cubit.checkKyc(context: 'RealunitBuy'); - }, + act: (cubit) => cubit.checkKyc(context: 'RealunitBuy'), expect: () => [ const KycLoading(), const KycSuccess( @@ -1042,7 +1268,6 @@ void main() { }, build: buildCubit, act: (cubit) async { - cubit.markLegalDisclaimerAccepted(); // First call sets the context. await cubit.checkKyc(context: 'RealunitBuy'); // Second call omits context — should reuse 'RealunitBuy'. @@ -1060,9 +1285,13 @@ void main() { ); }); - group('$KycCubit markLegalDisclaimerAccepted', () { + group('$KycCubit acceptLegalDisclaimer', () { + // Happy path: the user accepts on the disclaimer page. `acceptLegalDisclaimer` + // records acceptance server-side (`acceptLegal` with the outstanding + // agreements), then re-runs `checkKyc()`; the server now reports + // `allAccepted`, so the gate passes and the API drives the next routing. blocTest( - 'progresses through disclaimer gate and lets API drive the next routing decision', + 'records acceptance server-side, then re-checks and lets the API drive the next routing decision', setUp: () { when(() => kycService.getKycStatus()).thenAnswer( (_) async => _kycStatus( @@ -1071,12 +1300,21 @@ void main() { ), ); when(() => kycService.getUser()).thenAnswer((_) async => _user()); + // Outstanding before acceptance, all-accepted afterwards — mirrors the + // server stamping the version on PUT. + var accepted = false; + when(() => legalService.getLegalInfo()).thenAnswer( + (_) async => accepted ? _legalInfo() : _legalInfo(allAccepted: false), + ); + when(() => legalService.acceptLegal(any())).thenAnswer((_) async { + accepted = true; + return _legalInfo(); + }); }, build: buildCubit, act: (cubit) async { - await cubit.checkKyc(); // expects legalDisclaimer - cubit.markLegalDisclaimerAccepted(); - await cubit.checkKyc(); // expects KycCompleted (AlreadyRegistered + processStatus=Completed) + await cubit.checkKyc(); // outstanding -> disclaimer + await cubit.acceptLegalDisclaimer(); // records + re-checks -> completed }, expect: () => [ const KycLoading(), @@ -1084,6 +1322,84 @@ void main() { const KycLoading(), const KycCompleted(), ], + verify: (_) { + verify(() => legalService.acceptLegal(any())).called(1); + }, + ); + + // Pre-rollout path: both the acceptance PUT and the subsequent GET return + // 404 (endpoint not deployed). The local per-session flag (set inside + // `acceptLegalDisclaimer`) carries the session so the re-check passes the + // gate — the tolerated 404 never surfaces to the user. + blocTest( + 'falls back to the local session flag when the endpoint returns 404', + setUp: () { + when(() => kycService.getKycStatus()).thenAnswer( + (_) async => _kycStatus( + level: KycLevel.level50, + processStatus: KycProcessStatus.completed, + ), + ); + when(() => kycService.getUser()).thenAnswer((_) async => _user()); + when(() => legalService.getLegalInfo()).thenThrow( + const ApiException(statusCode: 404, code: 'NOT_FOUND', message: ''), + ); + when(() => legalService.acceptLegal(any())).thenThrow( + const ApiException(statusCode: 404, code: 'NOT_FOUND', message: ''), + ); + }, + build: buildCubit, + act: (cubit) async { + await cubit.checkKyc(); // gate falls back to local flag (false) -> disclaimer + await cubit.acceptLegalDisclaimer(); // sets local flag -> re-check passes -> completed + }, + expect: () => [ + const KycLoading(), + const KycSuccess(currentStep: KycStep.legalDisclaimer), + const KycLoading(), + const KycCompleted(), + ], + ); + + // Fail closed: a non-404 PUT failure (endpoint live but the write errors) + // surfaces as KycFailure instead of being silently swallowed into a + // re-prompt loop. + blocTest( + 'emits KycFailure when acceptLegal fails with a non-404 error', + setUp: () { + when(() => kycService.getKycStatus()).thenAnswer( + (_) async => _kycStatus( + level: KycLevel.level50, + processStatus: KycProcessStatus.completed, + ), + ); + when(() => kycService.getUser()).thenAnswer((_) async => _user()); + when(() => legalService.acceptLegal(any())).thenThrow( + const ApiException(statusCode: 500, code: 'SERVER_ERROR', message: 'boom'), + ); + }, + build: buildCubit, + act: (cubit) => cubit.acceptLegalDisclaimer(), + expect: () => [isA()], + ); + + // A non-ApiException failure (network/parse error) also fails closed via the + // generic catch, surfacing as KycFailure rather than being swallowed. + blocTest( + 'emits KycFailure when acceptLegal throws a non-ApiException error', + setUp: () { + when(() => kycService.getKycStatus()).thenAnswer( + (_) async => _kycStatus( + level: KycLevel.level50, + processStatus: KycProcessStatus.completed, + ), + ); + when(() => kycService.getUser()).thenAnswer((_) async => _user()); + when(() => legalService.acceptLegal(any())).thenThrow(Exception('boom')); + }, + build: buildCubit, + act: (cubit) => cubit.acceptLegalDisclaimer(), + expect: () => [isA()], ); }); } diff --git a/test/screens/kyc/kyc_merge_link_responsive_matrix_test.dart b/test/screens/kyc/kyc_merge_link_responsive_matrix_test.dart new file mode 100644 index 000000000..99a47cf7f --- /dev/null +++ b/test/screens/kyc/kyc_merge_link_responsive_matrix_test.dart @@ -0,0 +1,299 @@ +// Responsive matrix gate for KYC merge / link-wallet screens after migration +// onto ScrollableActionsLayout. Proves CTAs stay fully tappable across the +// full device × text-scale matrix (see test/helper/responsive_matrix.dart). +import 'package:bitbox_flutter/bitbox_flutter.dart' as sdk; +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/packages/hardware_wallet/bitbox.dart'; +import 'package:realunit_wallet/packages/service/app_store.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_kyc_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/registration/kyc/kyc_personal_data.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/user/dto/real_unit_user_data_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_registration_service.dart'; +import 'package:realunit_wallet/packages/service/wallet_service.dart'; +import 'package:realunit_wallet/packages/wallet/wallet.dart'; +import 'package:realunit_wallet/screens/kyc/cubits/kyc/kyc_cubit.dart'; +import 'package:realunit_wallet/screens/kyc/steps/link_wallet/cubits/kyc_link_wallet_cubit.dart'; +import 'package:realunit_wallet/screens/kyc/steps/link_wallet/kyc_link_wallet_page.dart'; +import 'package:realunit_wallet/screens/kyc/subpages/kyc_account_merge_page.dart'; +import 'package:realunit_wallet/screens/kyc/subpages/kyc_merge_processing_page.dart'; +import 'package:realunit_wallet/styles/themes.dart'; +import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart'; + +import '../../helper/helper.dart'; + +class _MockKycCubit extends MockCubit implements KycCubit {} + +class _MockKycLinkWalletCubit extends MockCubit + implements KycLinkWalletCubit {} + +class _MockAppStore extends Mock implements AppStore {} + +class _MockRealUnitRegistrationService extends Mock + implements RealUnitRegistrationService {} + +class _MockBitboxService extends Mock implements BitboxService {} + +class _MockWalletService extends Mock implements WalletService {} + +class _MockDfxKycService extends Mock implements DfxKycService {} + +const _kycData = KycPersonalData( + accountType: KycAccountType.personal, + firstName: 'Ada', + lastName: 'Lovelace', + phone: '+41790000000', + address: KycAddress(street: 'S', zip: '8000', city: 'Zurich', country: 41), +); + +const _userData = RealUnitUserDataDto( + email: 'ada@example.com', + name: 'Ada Lovelace', + type: 'HUMAN', + phoneNumber: '+41790000000', + birthday: '1815-12-10', + nationality: 'CH', + addressStreet: 'S', + addressPostalCode: '8000', + addressCity: 'Zurich', + addressCountry: 'CH', + swissTaxResidence: true, + lang: 'de', + kycData: _kycData, +); + +const _debugAddress = '0xfaeefaeefaeefaeefaeefaeefaeefaeefaeeb6a0'; + +Future _pumpMatrixPage( + WidgetTester tester, { + required Widget widget, + required MatrixCell cell, +}) async { + await tester.binding.setSurfaceSize(cell.device.size); + addTearDown(() async => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + MediaQuery( + data: cell.mediaQuery, + child: MaterialApp( + theme: realUnitTheme, + locale: const Locale('de'), + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + supportedLocales: S.delegate.supportedLocales, + home: widget, + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); +} + +void main() { + // --------------------------------------------------------------------------- + // Group A — KycAccountMergePage + // --------------------------------------------------------------------------- + group('$KycAccountMergePage responsive matrix (full device × textScale)', () { + late _MockKycCubit cubit; + + setUp(() { + cubit = _MockKycCubit(); + when(() => cubit.state).thenReturn(const KycInitial()); + when(() => cubit.checkKyc()).thenAnswer((_) async {}); + }); + + for (final cell in kFullResponsiveMatrix) { + testWidgets('account_merge · ${cell.id}', (tester) async { + await withTargetPlatform(cell.device.platform, () async { + await expectNoLayoutOverflow( + tester, + () async { + await _pumpMatrixPage( + tester, + widget: BlocProvider.value( + value: cubit, + child: const KycAccountMergePage(), + ), + cell: cell, + ); + }, + reason: 'overflow on account_merge / ${cell.label}', + ); + + await expectFullyTappable( + tester, + find.byType(AppFilledButton), + within: find.byType(KycAccountMergePage), + reason: 'account_merge / ${cell.label}: CTA not tappable', + ); + }); + }); + } + }); + + // --------------------------------------------------------------------------- + // Group B — KycMergeProcessingPage + // --------------------------------------------------------------------------- + group( + '$KycMergeProcessingPage responsive matrix (full device × textScale)', + () { + late _MockKycCubit cubit; + + setUp(() { + cubit = _MockKycCubit(); + when(() => cubit.state).thenReturn(const KycInitial()); + when(() => cubit.checkKyc()).thenAnswer((_) async {}); + }); + + for (final cell in kFullResponsiveMatrix) { + testWidgets('merge_processing · ${cell.id}', (tester) async { + await withTargetPlatform(cell.device.platform, () async { + await expectNoLayoutOverflow( + tester, + () async { + await _pumpMatrixPage( + tester, + widget: BlocProvider.value( + value: cubit, + child: const KycMergeProcessingPage(), + ), + cell: cell, + ); + }, + reason: 'overflow on merge_processing / ${cell.label}', + ); + + await expectFullyTappable( + tester, + find.byType(AppFilledButton), + within: find.byType(KycMergeProcessingPage), + reason: 'merge_processing / ${cell.label}: CTA not tappable', + ); + }); + }); + } + }, + ); + + // --------------------------------------------------------------------------- + // Group C — KycLinkWalletView (_LinkWalletBody via Ready state) + // --------------------------------------------------------------------------- + group( + '$KycLinkWalletView responsive matrix (full device × textScale)', + () { + late _MockKycLinkWalletCubit linkCubit; + late _MockKycCubit kycCubit; + + setUpAll(() { + registerFallbackValue(_userData); + + final getIt = GetIt.instance; + final appStore = _MockAppStore(); + when( + () => appStore.wallet, + ).thenReturn(DebugWallet(1, 'Test', _debugAddress)); + getIt.registerSingleton(appStore); + getIt.registerSingleton( + _MockRealUnitRegistrationService(), + ); + + final bitboxService = _MockBitboxService(); + when( + () => bitboxService.getAllUsbDevices(), + ).thenAnswer((_) async => []); + when(() => bitboxService.startScan()).thenAnswer((_) async => false); + getIt.registerSingleton(bitboxService); + getIt.registerSingleton(_MockWalletService()); + getIt.registerSingleton(_MockDfxKycService()); + }); + + tearDownAll(() async => await GetIt.instance.reset()); + + setUp(() { + linkCubit = _MockKycLinkWalletCubit(); + kycCubit = _MockKycCubit(); + when( + () => linkCubit.state, + ).thenReturn(const KycLinkWalletReady(_userData)); + when(() => kycCubit.state).thenReturn(const KycInitial()); + when(() => kycCubit.checkKyc()).thenAnswer((_) async {}); + when(() => linkCubit.submit(any())).thenAnswer((_) async {}); + }); + + Widget buildSubject() => MultiBlocProvider( + providers: [ + BlocProvider.value(value: kycCubit), + BlocProvider.value(value: linkCubit), + ], + child: const KycLinkWalletView(), + ); + + for (final cell in kFullResponsiveMatrix) { + testWidgets('link_wallet · ${cell.id}', (tester) async { + await withTargetPlatform(cell.device.platform, () async { + await expectNoLayoutOverflow( + tester, + () async { + await _pumpMatrixPage( + tester, + widget: buildSubject(), + cell: cell, + ); + }, + reason: 'overflow on link_wallet / ${cell.label}', + ); + + await expectFullyTappable( + tester, + find.byType(AppFilledButton), + within: find.byType(KycLinkWalletView), + reason: 'link_wallet / ${cell.label}: CTA not tappable', + ); + }); + }); + } + + // Focused regression: textScale 1.5 is NOT in kFullResponsiveMatrix and + // is the exact scale this page was measured to first break at. + testWidgets( + 'REGRESSION: link_wallet · iphone_se_3@1.5x first-break scale', + (tester) async { + final cell = MatrixCell( + kIosDeviceProfiles.firstWhere((d) => d.id == 'iphone_se_3'), + 1.5, + ); + await withTargetPlatform(cell.device.platform, () async { + await expectNoLayoutOverflow( + tester, + () async { + await _pumpMatrixPage( + tester, + widget: buildSubject(), + cell: cell, + ); + }, + reason: 'overflow on link_wallet / ${cell.label}', + ); + + await expectFullyTappable( + tester, + find.byType(AppFilledButton), + within: find.byType(KycLinkWalletView), + reason: 'link_wallet / ${cell.label}: CTA not tappable', + ); + }); + }, + ); + }, + ); +} diff --git a/test/screens/kyc/kyc_page_manager_test.dart b/test/screens/kyc/kyc_page_manager_test.dart index cf2f56b2a..af5204ce2 100644 --- a/test/screens/kyc/kyc_page_manager_test.dart +++ b/test/screens/kyc/kyc_page_manager_test.dart @@ -1,5 +1,8 @@ +import 'package:flutter/widgets.dart'; +import 'package:bloc_test/bloc_test.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; import 'package:mocktail/mocktail.dart'; import 'package:realunit_wallet/packages/service/app_store.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_kyc_service.dart'; @@ -7,31 +10,38 @@ import 'package:realunit_wallet/packages/service/dfx/models/kyc/dto/kyc_level_dt import 'package:realunit_wallet/packages/service/dfx/models/kyc/dto/kyc_session_dto.dart'; import 'package:realunit_wallet/packages/service/dfx/models/kyc/dto/kyc_step_dto.dart'; import 'package:realunit_wallet/packages/service/dfx/models/kyc/kyc_level.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/legal/dto/real_unit_legal_info_dto.dart'; import 'package:realunit_wallet/packages/service/dfx/models/user/dto/user_dto.dart'; import 'package:realunit_wallet/packages/service/dfx/models/wallet/real_unit_registration_info_dto.dart'; import 'package:realunit_wallet/packages/service/dfx/models/wallet/real_unit_registration_state.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_legal_service.dart'; import 'package:realunit_wallet/packages/service/dfx/real_unit_registration_service.dart'; import 'package:realunit_wallet/packages/wallet/wallet.dart'; import 'package:realunit_wallet/screens/kyc/cubits/kyc/kyc_cubit.dart'; import 'package:realunit_wallet/screens/kyc/kyc_page_manager.dart'; +import 'package:realunit_wallet/screens/kyc/subpages/kyc_failure_page.dart'; +import 'package:realunit_wallet/screens/kyc/subpages/kyc_manual_review_page.dart'; import 'package:realunit_wallet/screens/kyc/subpages/kyc_pending_page.dart'; +import 'package:realunit_wallet/screens/legal/legal_disclaimer_page.dart'; import '../../helper/helper.dart'; class _MockDfxKycService extends Mock implements DfxKycService {} -class _MockRealUnitRegistrationService extends Mock - implements RealUnitRegistrationService {} +class _MockRealUnitRegistrationService extends Mock implements RealUnitRegistrationService {} + +class _MockRealUnitLegalService extends Mock implements RealUnitLegalService {} class _MockAppStore extends Mock implements AppStore {} class _MockAWallet extends Mock implements AWallet {} +class _MockKycCubit extends MockCubit implements KycCubit {} + UserKycDto _kycHeader({KycLevel level = KycLevel.level0}) => UserKycDto(hash: 'h', level: level, dataComplete: false); -UserDto _user({String? mail = 'test@example.com'}) => - UserDto(mail: mail, kyc: _kycHeader()); +UserDto _user({String? mail = 'test@example.com'}) => UserDto(mail: mail, kyc: _kycHeader()); KycLevelDto _kycStatus({ required KycLevel level, @@ -67,12 +77,14 @@ KycStepSessionDto _currentStep( void main() { late _MockDfxKycService kycService; late _MockRealUnitRegistrationService registrationService; + late _MockRealUnitLegalService legalService; late _MockAppStore appStore; late _MockAWallet wallet; setUp(() { kycService = _MockDfxKycService(); registrationService = _MockRealUnitRegistrationService(); + legalService = _MockRealUnitLegalService(); appStore = _MockAppStore(); wallet = _MockAWallet(); when(() => appStore.wallet).thenReturn(wallet); @@ -82,6 +94,11 @@ void main() { state: RealUnitRegistrationState.alreadyRegistered, ), ); + // Default: server reports all legal agreements accepted, so the disclaimer + // gate passes and tests exercise downstream routing. + when(() => legalService.getLegalInfo()).thenAnswer( + (_) async => const RealUnitLegalInfoDto(agreements: [], allAccepted: true), + ); }); // An in-progress `dfxApproval` step used to land on a blank white Scaffold @@ -105,7 +122,7 @@ void main() { ), ); - final cubit = KycCubit(kycService, registrationService, appStore); + final cubit = KycCubit(kycService, registrationService, legalService, appStore); await tester.pumpApp( BlocProvider.value( value: cubit, @@ -113,7 +130,8 @@ void main() { ), ); - cubit.markLegalDisclaimerAccepted(); + // Server reports all agreements accepted (default stub), so the disclaimer + // gate passes and the cubit reaches the dfxApproval routing under test. await cubit.checkKyc(); await tester.pumpAndSettle(); @@ -127,4 +145,97 @@ void main() { await cubit.close(); }, ); + + Widget viewWithState(KycCubit cubit) => BlocProvider.value( + value: cubit, + child: const KycViewManager(), + ); + + // Exercises the KycPageManager DI wrapper itself (not just KycViewManager): + // the create closure must build a KycCubit from getIt and kick off checkKyc + // with the passed context. + testWidgets( + 'KycPageManager builds the KycCubit from getIt and runs checkKyc with the context', + (tester) async { + final getIt = GetIt.instance; + getIt.registerSingleton(kycService); + getIt.registerSingleton(registrationService); + getIt.registerSingleton(legalService); + getIt.registerSingleton(appStore); + addTearDown(() async => getIt.reset()); + + // Fail fast so the created cubit settles into KycFailure without leaving a + // pending 30s timeout timer — the assertion is on the DI wiring, not the + // resulting state. + when(() => kycService.getKycStatus(context: 'RealunitBuy')).thenThrow( + Exception('boom'), + ); + when(() => kycService.getUser()).thenAnswer((_) async => _user()); + + // Non-const construction so the constructor runs at runtime instead of + // folding to a compile-time constant. + // ignore: prefer_const_constructors + await tester.pumpApp(KycPageManager(kycContext: 'RealunitBuy')); + await tester.pumpAndSettle(); + + // Proves both halves of the wiring: the cubit was built from getIt (else + // this mock is never reached) and checkKyc forwarded the context. + verify(() => kycService.getKycStatus(context: 'RealunitBuy')).called(1); + expect(find.byType(KycFailurePage), findsOneWidget); + }, + ); + + // The KycUnsupportedStepFailure arm renders a KycFailurePage with the + // unsupported step name — a state no page test drives directly. + testWidgets( + 'KycViewManager renders KycFailurePage for KycUnsupportedStepFailure', + (tester) async { + final cubit = _MockKycCubit(); + when(() => cubit.state).thenReturn( + const KycUnsupportedStepFailure(KycStepName.personalData), + ); + + await tester.pumpApp(viewWithState(cubit)); + + expect(find.byType(KycFailurePage), findsOneWidget); + }, + ); + + // The KycManualReview arm renders the dedicated registration-under-review + // waiting page — a state no page test drives directly. + testWidgets( + 'KycViewManager renders KycManualReviewPage for KycManualReview', + (tester) async { + final cubit = _MockKycCubit(); + when(() => cubit.state).thenReturn(const KycManualReview()); + + await tester.pumpApp(viewWithState(cubit)); + + expect(find.byType(KycManualReviewPage), findsOneWidget); + }, + ); + + // The legalDisclaimer arm wires LegalDisclaimerPage.onCompleted to + // acceptLegalDisclaimer (which records acceptance server-side and re-checks); + // drive that callback directly. + testWidgets( + 'KycViewManager legalDisclaimer onCompleted records acceptance', + (tester) async { + final cubit = _MockKycCubit(); + when(() => cubit.state).thenReturn( + const KycSuccess(currentStep: KycStep.legalDisclaimer), + ); + when(() => cubit.acceptLegalDisclaimer()).thenAnswer((_) async {}); + + await tester.pumpApp(viewWithState(cubit)); + await tester.pumpAndSettle(); + + // Drive the disclaimer's completion callback exactly as the page would on + // acceptance, without exercising the disclaimer UI itself. + final page = tester.widget(find.byType(LegalDisclaimerPage)); + page.onCompleted!(); + + verify(() => cubit.acceptLegalDisclaimer()).called(1); + }, + ); } diff --git a/test/screens/kyc/kyc_static_pages_responsive_matrix_test.dart b/test/screens/kyc/kyc_static_pages_responsive_matrix_test.dart new file mode 100644 index 000000000..bb33480bb --- /dev/null +++ b/test/screens/kyc/kyc_static_pages_responsive_matrix_test.dart @@ -0,0 +1,127 @@ +// Responsive matrix gate for CTA-less KYC static pages. +// +// Proves KycFailurePage and KycSignatureUnsupportedPage keep their message +// fully reachable (scrollable + tappable geometry) across the full device × +// text-scale matrix. Regression lock for bare Column + Spacer() with no scroll +// view, which overflowed/clipped long DE copy at high accessibility text scale. +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/screens/kyc/steps/signature_unsupported/kyc_signature_unsupported_page.dart'; +import 'package:realunit_wallet/screens/kyc/subpages/kyc_failure_page.dart'; +import 'package:realunit_wallet/styles/themes.dart'; + +import '../../helper/helper.dart'; + +/// Long DE failure detail interpolated into the already-long +/// `kycFailureDescription` template — long enough to force scrolling on small +/// phones at high text scale. +const _longFailureMessage = + 'Die Verbindung zum Server wurde unerwartet unterbrochen, während Ihre ' + 'Dokumente zur Identitätsprüfung hochgeladen wurden. Möglicherweise liegt ' + 'ein vorübergehendes Problem bei unserem Partner für die ' + 'Identitätsüberprüfung vor, oder Ihre Internetverbindung war instabil.'; + +const _localizationsDelegates = >[ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, +]; + +void main() { + Future pumpPage( + WidgetTester tester, + MatrixCell cell, + Widget page, + ) async { + await tester.binding.setSurfaceSize(cell.device.size); + addTearDown(() async { + await tester.binding.setSurfaceSize(null); + }); + + await tester.pumpWidget( + MediaQuery( + data: cell.mediaQuery, + child: MaterialApp( + theme: realUnitTheme, + locale: const Locale('de'), + localizationsDelegates: _localizationsDelegates, + supportedLocales: S.delegate.supportedLocales, + home: page, + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + } + + group('KycFailurePage responsive matrix (full device × textScale)', () { + for (final cell in kFullResponsiveMatrix) { + testWidgets(cell.id, (tester) async { + await withTargetPlatform(cell.device.platform, () async { + await expectNoLayoutOverflow( + tester, + () async { + await pumpPage( + tester, + cell, + const KycFailurePage(message: _longFailureMessage), + ); + }, + reason: 'overflow on KycFailurePage / ${cell.label}', + ); + + final messageFinder = find.textContaining(_longFailureMessage); + expect(messageFinder, findsOneWidget); + await tester.ensureVisible(messageFinder); + + final viewportRect = Offset.zero & cell.device.size; + final messageRect = tester.getRect(messageFinder); + expect( + messageRect.overlaps(viewportRect), + isTrue, + reason: + 'KycFailurePage / ${cell.label}: message rect $messageRect ' + 'does not intersect viewport $viewportRect', + ); + }); + }); + } + }); + + group('KycSignatureUnsupportedPage responsive matrix (full device × textScale)', () { + for (final cell in kFullResponsiveMatrix) { + testWidgets(cell.id, (tester) async { + await withTargetPlatform(cell.device.platform, () async { + await expectNoLayoutOverflow( + tester, + () async { + await pumpPage( + tester, + cell, + const KycSignatureUnsupportedPage(), + ); + }, + reason: 'overflow on KycSignatureUnsupportedPage / ${cell.label}', + ); + + final messageFinder = find.text(S.current.kycSignatureUnsupportedDescription); + expect(messageFinder, findsOneWidget); + await tester.ensureVisible(messageFinder); + + final viewportRect = Offset.zero & cell.device.size; + final messageRect = tester.getRect(messageFinder); + expect( + messageRect.overlaps(viewportRect), + isTrue, + reason: + 'KycSignatureUnsupportedPage / ${cell.label}: message rect $messageRect ' + 'does not intersect viewport $viewportRect', + ); + }); + }); + } + }); +} diff --git a/test/screens/kyc/steps/confirm_email/kyc_confirm_email_cubit_test.dart b/test/screens/kyc/steps/confirm_email/kyc_confirm_email_cubit_test.dart index 14b096495..5c806942e 100644 --- a/test/screens/kyc/steps/confirm_email/kyc_confirm_email_cubit_test.dart +++ b/test/screens/kyc/steps/confirm_email/kyc_confirm_email_cubit_test.dart @@ -147,5 +147,108 @@ void main() { }); }, ); + + // Concurrency guards: `Future.timeout` does not cancel the underlying HTTP + // call, so a late continuation from a superseded tap must not emit over the + // newer run, and a tap after the cubit closed must not emit at all. These + // pin the guard branches (the early `return`s) that the happy-path tests + // above execute but never actually take. + test( + 'recheck() after close returns early without touching the service (isClosed guard)', + () async { + when(() => registrationService.getRegistrationInfo()).thenAnswer( + (_) async => _info(emailConfirmed: true), + ); + + final cubit = build(); + await cubit.close(); + + // Must not throw "Cannot emit after close": the isClosed guard returns + // before the loading emit, and the service is never reached. + await cubit.recheck(); + + expect(cubit.isClosed, isTrue); + verifyNever(() => registrationService.getRegistrationInfo()); + }, + ); + + test( + 'a superseded recheck bails on the stale generation instead of emitting ' + 'over the newer run (catch path)', + () async { + final first = Completer(); + final second = Completer(); + final calls = [first, second]; + var i = 0; + when(() => registrationService.getRegistrationInfo()).thenAnswer( + (_) => calls[i++].future, + ); + + final cubit = build(); + final states = []; + final sub = cubit.stream.listen(states.add); + + unawaited(cubit.recheck()); + unawaited(cubit.recheck()); + + // The superseded FIRST call FAILS: the catch block must also bail on the + // stale generation rather than emit its fail-closed NotConfirmed over + // the newer run. + first.completeError(Exception('late failure')); + await Future.delayed(Duration.zero); + second.complete(_info(emailConfirmed: true)); + await Future.delayed(Duration.zero); + + expect(states, const [KycConfirmEmailLoading(), KycConfirmEmailConfirmed()]); + + await sub.cancel(); + await cubit.close(); + }, + ); + + // A late response from a superseded `recheck()` must not overwrite the + // fresh state of a newer one. The first call hangs on a `Completer`; a + // second call resolves immediately to `Confirmed`; when the first call's + // response finally arrives (still `false`) its generation no longer matches, + // so it emits nothing. Two back-to-back `Loading`s collapse to one via + // Equatable, so the expected sequence is `[Loading, Confirmed]`. Mirrors the + // `KycCubit` generation-counter regression test. + test( + 'a late response from a superseded recheck does NOT overwrite the fresh Confirmed state', + () async { + final call1Completer = Completer(); + var firstCall = true; + when(() => registrationService.getRegistrationInfo()).thenAnswer((_) { + if (firstCall) { + firstCall = false; + return call1Completer.future; + } + return Future.value(_info(emailConfirmed: true)); + }); + + final cubit = build(); + final states = []; + final sub = cubit.stream.listen(states.add); + + final call1Future = cubit.recheck(); + + await Future.delayed(Duration.zero); + + await cubit.recheck(); + + call1Completer.complete(_info(emailConfirmed: false)); + await call1Future; + await Future.delayed(Duration.zero); + + await sub.cancel(); + await cubit.close(); + + expect(states, const [ + KycConfirmEmailLoading(), + KycConfirmEmailConfirmed(), + ]); + expect(cubit.state, const KycConfirmEmailConfirmed()); + }, + ); }); } diff --git a/test/screens/kyc/steps/financial_data/kyc_financial_data_responsive_matrix_test.dart b/test/screens/kyc/steps/financial_data/kyc_financial_data_responsive_matrix_test.dart new file mode 100644 index 000000000..0bb3e7db7 --- /dev/null +++ b/test/screens/kyc/steps/financial_data/kyc_financial_data_responsive_matrix_test.dart @@ -0,0 +1,195 @@ +// Responsive matrix gate for KycFinancialDataQuestionsPage CTA. +// +// Proves the "Weiter"/"Abschließen" button stays fully tappable across the full +// device × text-scale matrix with a realistic long German KYC question. +// Regression lock for the iPhone SE + textScale 2.0 bug where the CTA was +// scrolled below the fold (or clipped even when scrolled) inside the legacy +// Spacer layout. +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/kyc/dto/kyc_financial_data_dto.dart'; +import 'package:realunit_wallet/screens/kyc/steps/financial_data/cubits/kyc_financial_data_cubit.dart'; +import 'package:realunit_wallet/screens/kyc/steps/financial_data/subpages/kyc_financial_data_questions_page.dart'; +import 'package:realunit_wallet/styles/themes.dart'; +import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart'; + +import '../../../../helper/helper.dart'; + +class _MockKycFinancialDataCubit extends MockCubit + implements KycFinancialDataCubit {} + +/// Realistic long German KYC compliance question that stresses the layout the +/// way the production bug report describes. +const _longGermanQuestion = KycFinancialQuestion( + key: 'source_of_wealth_detail', + type: QuestionType.text, + title: + 'Bitte beschreiben Sie detailliert die Herkunft Ihres Vermögens und ' + 'die wirtschaftliche Herkunft der Mittel, die Sie über diese Plattform ' + 'anlegen oder transferieren möchten.', + description: + 'Gemäss den geltenden Geldwäschebestimmungen sind wir verpflichtet, die ' + 'wirtschaftliche Herkunft Ihrer Mittel nachvollziehen zu können. Nennen ' + 'Sie bitte die Art der Einkünfte (z. B. Gehalt, Unternehmensverkauf, ' + 'Erbschaft, Kapitalerträge), den ungefähren Zeitraum sowie ggf. ' + 'beteiligte Dritte oder Institutionen. Unvollständige Angaben können zu ' + 'Rückfragen oder einer Verzögerung der Freigabe führen.', +); + +const _loadedState = KycFinancialDataLoadedSuccess( + currentIndex: 0, + visibleQuestions: [_longGermanQuestion], + allQuestions: [_longGermanQuestion], + responses: {'source_of_wealth_detail': 'answer'}, + url: 'url', +); + +Future _pumpScreen( + WidgetTester tester, + Widget widget, + MediaQueryData mediaQuery, +) async { + await tester.binding.setSurfaceSize(mediaQuery.size); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget( + MediaQuery( + data: mediaQuery, + child: MaterialApp( + theme: realUnitTheme, + locale: const Locale('de'), + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + supportedLocales: S.delegate.supportedLocales, + home: widget, + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); +} + +void main() { + late _MockKycFinancialDataCubit cubit; + + setUp(() { + cubit = _MockKycFinancialDataCubit(); + when(() => cubit.state).thenReturn(_loadedState); + whenListen( + cubit, + const Stream.empty(), + initialState: _loadedState, + ); + when(() => cubit.submitAndNext()).thenAnswer((_) async {}); + when(() => cubit.answerQuestion(any(), any())).thenReturn(null); + }); + + Widget buildSubject() => BlocProvider.value( + value: cubit, + child: const KycFinancialDataQuestionsPage(_loadedState), + ); + + group( + 'KycFinancialDataQuestionsPage responsive matrix (full device × textScale)', + () { + for (final cell in kFullResponsiveMatrix) { + testWidgets(cell.id, (tester) async { + await withTargetPlatform(cell.device.platform, () async { + await expectNoLayoutOverflow( + tester, + () async { + await _pumpScreen(tester, buildSubject(), cell.mediaQuery); + }, + reason: 'overflow on ${cell.label}', + ); + + await expectFullyTappable( + tester, + find.byType(AppFilledButton), + within: find.byType(KycFinancialDataQuestionsPage), + reason: '${cell.label}: CTA not tappable', + ); + }); + }); + } + }, + ); + + // Focused regression: iPhone SE 375×667 DE + textScale 2.0 with a long + // German question — proven fail pre-fix (CTA clipped even when scrolled). + testWidgets( + 'REGRESSION: iPhone SE DE textScale 2.0 long question — CTA tappable and tap submits', + (tester) async { + final cell = MatrixCell( + kIosDeviceProfiles.firstWhere((d) => d.id == 'iphone_se_3'), + 2.0, + ); + await withTargetPlatform(cell.device.platform, () async { + await expectNoLayoutOverflow(tester, () async { + await _pumpScreen(tester, buildSubject(), cell.mediaQuery); + }); + + await expectFullyTappable( + tester, + find.byType(AppFilledButton), + within: find.byType(KycFinancialDataQuestionsPage), + ); + verify(() => cubit.submitAndNext()).called(1); + }); + }, + ); + + // Keyboard: Scaffold.resizeToAvoidBottomInset (default true) shrinks the + // body; ScrollableActionsLayout receives the reduced bounded height. Prove + // the CTA stays fully tappable with a simulated open keyboard on the text + // question field. + testWidgets( + 'KEYBOARD: iPhone SE textScale 1.3 — CTA tappable with viewInsets keyboard', + (tester) async { + final cell = MatrixCell( + kIosDeviceProfiles.firstWhere((d) => d.id == 'iphone_se_3'), + 1.3, + ); + final keyboardMediaQuery = cell.mediaQuery.copyWith( + viewInsets: const EdgeInsets.only(bottom: 336), + ); + + await withTargetPlatform(cell.device.platform, () async { + await expectNoLayoutOverflow( + tester, + () async { + await _pumpScreen(tester, buildSubject(), cell.mediaQuery); + await tester.enterText( + find.byType(TextField), + 'Gehalt aus unselbstständiger Tätigkeit und Kapitalerträge.', + ); + await tester.pump(); + // Re-pump same tree with keyboard open (viewInsets). + await _pumpScreen(tester, buildSubject(), keyboardMediaQuery); + await tester.enterText( + find.byType(TextField), + 'Gehalt aus unselbstständiger Tätigkeit und Kapitalerträge.', + ); + await tester.pump(); + }, + reason: 'overflow with keyboard on ${cell.label}', + ); + + await expectFullyTappable( + tester, + find.byType(AppFilledButton), + within: find.byType(KycFinancialDataQuestionsPage), + reason: '${cell.label} + keyboard: CTA not tappable', + ); + }); + }, + ); +} diff --git a/test/screens/kyc/steps/kyc_registration_page_test.dart b/test/screens/kyc/steps/kyc_registration_page_test.dart index 7aa30fd53..1b2987d9a 100644 --- a/test/screens/kyc/steps/kyc_registration_page_test.dart +++ b/test/screens/kyc/steps/kyc_registration_page_test.dart @@ -13,6 +13,8 @@ import 'package:realunit_wallet/packages/hardware_wallet/bitbox.dart'; import 'package:realunit_wallet/packages/service/app_store.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_country_service.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_kyc_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/registration_rejected_exception.dart'; import 'package:realunit_wallet/packages/service/dfx/models/country/country.dart'; import 'package:realunit_wallet/packages/service/dfx/models/registration/dto/real_unit_registration_request_dto.dart'; import 'package:realunit_wallet/packages/service/dfx/models/registration/kyc/kyc_personal_data.dart'; @@ -412,10 +414,111 @@ void main() { await tester.pump(); expect(find.byType(SnackBar), findsOne); + expect(find.textContaining('Registration failed'), findsOne); // A failed submit must not re-arm the wallet services — the re-arm is // gated behind KycRegistrationSubmitSuccess. verifyNever(() => homeBloc.add(any(that: isA()))); }); + + testWidgets( + 'surfaces the server reason and the nothing-was-saved hint when the ' + 'API rejects the submit', + (tester) async { + // A structured rejection (e.g. a 400 from register/complete) must not + // be shown as a raw exception toString: the user needs the server + // reason plus the context that nothing was persisted — otherwise a + // rejected submit is indistinguishable from a hang and the re-shown + // wizard looks like a routing bug. + whenListen( + registrationSubmitCubit, + Stream.fromIterable([ + const KycRegistrationSubmitFailure( + 'RegistrationRejectedException: Registration date must be today ' + '(code: UNKNOWN, statusCode: 400)', + cause: RegistrationRejectedException( + statusCode: 400, + code: 'UNKNOWN', + message: 'Registration date must be today', + ), + ), + ]), + initialState: KycRegistrationSubmitInitial(), + ); + + await tester.pumpApp(buildSubject(const KycRegistrationView())); + await tester.pump(); + + expect(find.byType(SnackBar), findsOne); + expect(find.textContaining('Registration date must be today'), findsOne); + expect(find.textContaining('Your data has not been saved'), findsOne); + expect(find.textContaining('RegistrationRejectedException'), findsNothing); + verifyNever(() => homeBloc.add(any(that: isA()))); + }, + ); + + testWidgets( + 'keeps the generic failure message for an auth error — a 401 is not a ' + 'rejection of the entries', + (tester) async { + // The service only types non-auth 4xx of register/complete as + // RegistrationRejectedException; a persistent 401 (e.g. after the + // one-shot token-refresh retry) stays a plain ApiException and must + // not tell the user to check their entries. + whenListen( + registrationSubmitCubit, + Stream.fromIterable([ + const KycRegistrationSubmitFailure( + 'RealUnitApiException: Unauthorized ' + '(code: UNKNOWN, statusCode: 401)', + cause: ApiException( + statusCode: 401, + code: 'UNKNOWN', + message: 'Unauthorized', + ), + ), + ]), + initialState: KycRegistrationSubmitInitial(), + ); + + await tester.pumpApp(buildSubject(const KycRegistrationView())); + await tester.pump(); + + expect(find.byType(SnackBar), findsOne); + expect(find.textContaining('Registration failed'), findsOne); + expect(find.textContaining('Your data has not been saved'), findsNothing); + verifyNever(() => homeBloc.add(any(that: isA()))); + }, + ); + + testWidgets( + 'keeps the generic failure message for a 5xx — no "check your entries" ' + 'instruction for a server-side fault', + (tester) async { + whenListen( + registrationSubmitCubit, + Stream.fromIterable([ + const KycRegistrationSubmitFailure( + 'RealUnitApiException: Internal server error ' + '(code: UNKNOWN, statusCode: 500)', + cause: ApiException( + statusCode: 500, + code: 'UNKNOWN', + message: 'Internal server error', + ), + ), + ]), + initialState: KycRegistrationSubmitInitial(), + ); + + await tester.pumpApp(buildSubject(const KycRegistrationView())); + await tester.pump(); + + expect(find.byType(SnackBar), findsOne); + expect(find.textContaining('Registration failed'), findsOne); + expect(find.textContaining('Your data has not been saved'), findsNothing); + verifyNever(() => homeBloc.add(any(that: isA()))); + }, + ); }); group('$KycRegistrationPage prefill', () { @@ -737,25 +840,6 @@ void main() { await tester.pumpAndSettle(); } - Future selectTaxCountry(WidgetTester tester, String name) async { - // The tax step hosts a single mandatory country dropdown. Selecting a - // value makes the form validatable and drives the derived - // `swissTaxResidence` on submit. - final taxDropdown = find.descendant( - of: find.byType(KycRegistrationTaxStep), - matching: find.byType(DropdownButtonFormField), - ); - await tester.scrollUntilVisible( - taxDropdown, - 100, - scrollable: taxScrollable(), - ); - await tester.tap(taxDropdown); - await tester.pumpAndSettle(); - await tester.tap(find.text(name).last); - await tester.pumpAndSettle(); - } - Future tapComplete(WidgetTester tester) async { final completeButton = find.descendant( of: find.byType(KycRegistrationTaxStep), @@ -782,90 +866,216 @@ void main() { addressStreetNumber: any(named: 'addressStreetNumber'), addressPostalCode: any(named: 'addressPostalCode'), addressCity: any(named: 'addressCity'), - addressCountry: any(named: 'addressCountry'), + addressCountry: captureAny(named: 'addressCountry'), swissTaxResidence: captureAny(named: 'swissTaxResidence'), countryAndTINs: captureAny(named: 'countryAndTINs'), ), ).captured; + Future enterTinAt(WidgetTester tester, int index, String value) async { + final context = tester.element(find.byType(KycRegistrationTaxStep)); + final tinFields = find.widgetWithText(TextFormField, S.of(context).tinHint); + final field = tinFields.at(index); + await tester.scrollUntilVisible(field, 100, scrollable: taxScrollable()); + await tester.enterText(field, value); + await tester.pump(); + } + + Future addTaxResidence(WidgetTester tester) async { + final context = tester.element(find.byType(KycRegistrationTaxStep)); + final addButton = find.text(S.of(context).addTaxResidence); + await tester.scrollUntilVisible(addButton, 100, scrollable: taxScrollable()); + await tester.tap(addButton); + await tester.pumpAndSettle(); + } + + Future selectFreeCountry(WidgetTester tester, String name) async { + final taxDropdown = find + .descendant( + of: find.byType(KycRegistrationTaxStep), + matching: find.byType(DropdownButtonFormField), + ) + .last; + await tester.scrollUntilVisible(taxDropdown, 100, scrollable: taxScrollable()); + await tester.tap(taxDropdown); + await tester.pumpAndSettle(); + + final nameFinder = find.text(name); + if (nameFinder.evaluate().isEmpty || find.text(name).hitTestable().evaluate().isEmpty) { + await tester.dragUntilVisible( + nameFinder, + find.byType(Scrollable).last, + const Offset(0, -300), + maxIteration: 120, + ); + await tester.pumpAndSettle(); + } + await tester.tap(find.text(name).last); + await tester.pumpAndSettle(); + } + + // S1 — Address CH, tax: CH only → swiss=true, countryAndTINs=null testWidgets( - 'Swiss tax residence forwards swissTaxResidence:true and null countryAndTINs', + 'S1 CH only: locked Swiss address → swissTaxResidence true, null countryAndTINs', (tester) async { await showTaxStep(tester); - await selectTaxCountry(tester, 'Switzerland'); - // A Swiss (CH) tax residence derives Swiss-only — no TIN is collected. await tapComplete(tester); final captured = captureSubmit(); - expect(captured[0], isTrue); - expect(captured[1], isNull); + expect(captured[0], isA().having((c) => c.symbol, 'symbol', 'CH')); + expect(captured[1], isTrue); + expect(captured[2], isNull); }, ); + // S2 — Address DE, tax: DE only → swiss=false, countryAndTINs=[{DE,tin}] testWidgets( - 'non-Swiss tax residence forwards swissTaxResidence:false and a countryAndTINs entry', + 'S2 DE only: locked German address requires TIN and forwards countryAndTINs:DE', (tester) async { - await showTaxStep(tester); - await selectTaxCountry(tester, 'Germany'); + await showTaxStep(tester, dto: initialUserDataDe); - final context = tester.element(find.byType(KycRegistrationTaxStep)); - final tinField = find.widgetWithText(TextFormField, S.of(context).tinHint); - await tester.scrollUntilVisible(tinField, 100, scrollable: taxScrollable()); - // Enter with surrounding whitespace: _onSubmit must trim it before + // Enter with surrounding whitespace: the tax step trims before // forwarding, so the captured TIN is the trimmed value. - await tester.enterText(tinField, ' 12 345 678 901 '); - await tester.pump(); - + await enterTinAt(tester, 0, ' 12 345 678 901 '); await tapComplete(tester); final captured = captureSubmit(); - expect(captured[0], isFalse); - final tins = captured[1] as List; + expect(captured[0], isA().having((c) => c.symbol, 'symbol', 'DE')); + expect(captured[1], isFalse); + final tins = captured[2] as List; expect(tins, hasLength(1)); expect(tins.single.country, 'DE'); expect(tins.single.tin, '12 345 678 901'); }, ); + // S3 — Address CH, tax: CH + FR → swiss=true, countryAndTINs=[{FR,tin}] testWidgets( - 'defaults the tax residence to the address country, forwarding it without a manual pick', + 'S3 CH + FR: locked Swiss + free France → swiss true, countryAndTINs FR only', (tester) async { await showTaxStep(tester); - // No selectTaxCountry: the Swiss address country pre-selected the tax - // residence, so the mandatory field is already valid and submit forwards - // the derived Swiss-only result. + await addTaxResidence(tester); + await selectFreeCountry(tester, 'France'); + await enterTinAt(tester, 0, 'FR999'); await tapComplete(tester); final captured = captureSubmit(); - expect(captured[0], isTrue); - expect(captured[1], isNull); + expect(captured[0], isA().having((c) => c.symbol, 'symbol', 'CH')); + expect(captured[1], isTrue); + final tins = captured[2] as List; + expect(tins, hasLength(1)); + expect(tins.single.country, 'FR'); + expect(tins.single.tin, 'FR999'); }, ); + // S4 — Address DE, tax: DE + CH → swiss=true, countryAndTINs=[{DE,tin}] testWidgets( - 'sources the tax residence from a non-Swiss address country, revealing ' - 'the TIN and forwarding it without a manual pick', + 'S4 DE + CH: locked DE + free CH → swiss true, countryAndTINs DE only', (tester) async { await showTaxStep(tester, dto: initialUserDataDe); - // The residence (DE) — not the nationality (CH) — pre-selected the tax - // residence, so the TIN is revealed with no manual pick and the derived - // non-Swiss result is forwarded. + await enterTinAt(tester, 0, 'DE111'); + await addTaxResidence(tester); + await selectFreeCountry(tester, 'Switzerland'); + await tapComplete(tester); + + final captured = captureSubmit(); + // DE (address, with TIN) + CH (additional) → swissTaxResidence true, + // countryAndTINs only carries the non-CH entry. + expect(captured[0], isA().having((c) => c.symbol, 'symbol', 'DE')); + expect(captured[1], isTrue); + final tins = captured[2] as List; + expect(tins, hasLength(1)); + expect(tins.single.country, 'DE'); + expect(tins.single.tin, 'DE111'); + }, + ); + + // S5 — Address DE, tax: DE + FR + US → swiss=false, three TINs + testWidgets( + 'S5 DE + FR + US: multi non-CH residences forward all countryAndTINs', + (tester) async { + await showTaxStep(tester, dto: initialUserDataDe); + + await enterTinAt(tester, 0, 'DE111'); + await addTaxResidence(tester); + await selectFreeCountry(tester, 'France'); + await enterTinAt(tester, 1, 'FR999'); + await addTaxResidence(tester); + await selectFreeCountry(tester, 'United States'); + await enterTinAt(tester, 2, 'US123'); + await tapComplete(tester); + + final captured = captureSubmit(); + expect(captured[0], isA().having((c) => c.symbol, 'symbol', 'DE')); + expect(captured[1], isFalse); + final tins = captured[2] as List; + expect(tins, hasLength(3)); + expect(tins[0].country, 'DE'); + expect(tins[0].tin, 'DE111'); + expect(tins[1].country, 'FR'); + expect(tins[1].tin, 'FR999'); + expect(tins[2].country, 'US'); + expect(tins[2].tin, 'US123'); + }, + ); + + // Prefill wiring: swissTaxResidence + countryAndTINs from initialUserData + // must seed the tax step and round-trip on submit (no silent drop). + testWidgets( + 'prefills CH + DE tax residences from initialUserData and submits both', + (tester) async { + const prefillWithForeignTin = RealUnitUserDataDto( + email: 'a@b.com', + name: 'Ada Lovelace', + type: 'HUMAN', + phoneNumber: '+41 79 000 00 00', + birthday: '1815-12-10', + nationality: 'CH', + addressStreet: 'Bahnhofstrasse 1', + addressPostalCode: '8000', + addressCity: 'Zurich', + addressCountry: 'CH', + swissTaxResidence: true, + countryAndTINs: [CountryAndTin(country: 'DE', tin: 'DE123')], + lang: 'de', + kycData: KycPersonalData( + accountType: KycAccountType.personal, + firstName: 'Ada', + lastName: 'Lovelace', + phone: '+41 79 000 00 00', + address: KycAddress( + street: 'Bahnhofstrasse', + houseNumber: '1', + zip: '8000', + city: 'Zurich', + country: 41, + ), + ), + ); + + await showTaxStep(tester, dto: prefillWithForeignTin); + + // Seeds resolved: CH (from swissTaxResidence) + DE (from countryAndTINs). + expect(find.text('Switzerland'), findsWidgets); + expect(find.text('Germany'), findsWidgets); final context = tester.element(find.byType(KycRegistrationTaxStep)); final tinField = find.widgetWithText(TextFormField, S.of(context).tinHint); - await tester.scrollUntilVisible(tinField, 100, scrollable: taxScrollable()); - await tester.enterText(tinField, '12 345 678 901'); - await tester.pump(); + expect(tinField, findsOneWidget); + expect(tester.widget(tinField).controller!.text, 'DE123'); await tapComplete(tester); final captured = captureSubmit(); - expect(captured[0], isFalse); - final tins = captured[1] as List; + expect(captured[0], isA().having((c) => c.symbol, 'symbol', 'CH')); + expect(captured[1], isTrue); + final tins = captured[2] as List; + expect(tins, hasLength(1)); expect(tins.single.country, 'DE'); - expect(tins.single.tin, '12 345 678 901'); + expect(tins.single.tin, 'DE123'); }, ); }); diff --git a/test/screens/kyc/steps/registration/steps/kyc_registration_personal_step_test.dart b/test/screens/kyc/steps/registration/steps/kyc_registration_personal_step_test.dart new file mode 100644 index 000000000..f6f634b64 --- /dev/null +++ b/test/screens/kyc/steps/registration/steps/kyc_registration_personal_step_test.dart @@ -0,0 +1,186 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_country_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/country/country.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/registration/registration_user_type.dart'; +import 'package:realunit_wallet/screens/kyc/steps/registration/cubits/registration_step/kyc_registration_step_cubit.dart'; +import 'package:realunit_wallet/screens/kyc/steps/registration/steps/kyc_registration_personal_step.dart'; +import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart'; +import 'package:realunit_wallet/widgets/form/dropdown_field.dart'; + +import '../../../../../helper/country_fixture.dart'; +import '../../../../../helper/pump_app.dart'; + +class _MockKycRegistrationStepCubit extends MockCubit + implements KycRegistrationStepCubit {} + +// Matches the 'CH' entry in the committed country fixture so CountryField +// pre-selects it (and propagates it into the nationality controller). +const _switzerland = Country(id: 41, symbol: 'CH', name: 'Switzerland', kycAllowed: true); + +void main() { + late _MockKycRegistrationStepCubit stepCubit; + + setUp(() { + GetIt.instance.registerSingleton(fixtureCountryService()); + + stepCubit = _MockKycRegistrationStepCubit(); + when(() => stepCubit.state).thenReturn( + const KycRegistrationStepState( + step: KycRegistrationStep.personal, + steps: [ + KycRegistrationStep.personal, + KycRegistrationStep.address, + KycRegistrationStep.taxResidence, + ], + ), + ); + }); + + tearDown(() async => GetIt.instance.reset()); + + // Prefill every field with a valid value so the only variable under test is + // the first/last-name input. Birthday and phone seed their sub-widgets from + // the passed controllers; the nationality picker pre-selects from the country + // fixture — leaving the name validators as the sole outstanding gate. + Future<_Harness> pump( + WidgetTester tester, { + String firstName = 'Ada', + String lastName = 'Lovelace', + bool withNationality = true, + }) async { + final harness = _Harness(firstName: firstName, lastName: lastName); + + await tester.pumpApp( + BlocProvider.value( + value: stepCubit, + child: Scaffold( + body: KycRegistrationPersonalStep( + typeCtrl: harness.typeCtrl, + firstNameCtrl: harness.firstNameCtrl, + lastNameCtrl: harness.lastNameCtrl, + phoneCtrl: harness.phoneCtrl, + nationalityCtrl: harness.nationalityCtrl, + birthdayCtrl: harness.birthdayCtrl, + initialNationality: withNationality ? _switzerland : null, + ), + ), + ), + ); + await tester.pumpAndSettle(); + return harness; + } + + Finder scrollable() => find.byType(Scrollable).first; + + Future tapNext(WidgetTester tester) async { + final button = find.byType(AppFilledButton); + await tester.scrollUntilVisible(button, 100, scrollable: scrollable()); + await tester.tap(button); + await tester.pumpAndSettle(); + } + + group('$KycRegistrationPersonalStep', () { + testWidgets('renders the personal fields and a next button', (tester) async { + await pump(tester); + + expect(find.byType(DropdownField), findsOneWidget); + expect(find.byType(AppFilledButton), findsOneWidget); + }); + + testWidgets('advances to the next step once every field is valid', (tester) async { + await pump(tester); + + await tapNext(tester); + + verify(() => stepCubit.next()).called(1); + }); + + testWidgets('does not advance while the names are empty', (tester) async { + await pump(tester, firstName: '', lastName: ''); + + // Empty first/last name → the validators return the empty sentinel and + // keep the form invalid, so the step must not advance. + await tapNext(tester); + + verifyNever(() => stepCubit.next()); + }); + + testWidgets('does not advance while the names are not Swiss-payment text', (tester) async { + // Cyrillic sample ("Test"): non-empty but outside the SwissPaymentText + // character set, so the validators reject it client-side. + await pump(tester, firstName: 'Тест', lastName: 'Тест'); + + await tapNext(tester); + + verifyNever(() => stepCubit.next()); + }); + + testWidgets('updates the account-type controller when the dropdown changes', (tester) async { + final harness = await pump(tester); + + final dropdown = tester.widget>( + find.byType(DropdownField), + ); + // A null selection is ignored (guard) and leaves the initial value; a + // concrete value replaces it, so assert the observable transition to the + // other enum value (fails if the set-branch assignment is removed). + dropdown.onChanged!(null); + expect(harness.typeCtrl.value, RegistrationUserType.human); + + dropdown.onChanged!(RegistrationUserType.corporation); + expect(harness.typeCtrl.value, RegistrationUserType.corporation); + }); + + testWidgets('dismisses the keyboard when tapping outside the fields', (tester) async { + await pump(tester); + + // Focus the first text field so there is a primary focus to clear. + final firstField = find + .descendant( + of: find.byType(KycRegistrationPersonalStep), + matching: find.byType(EditableText), + ) + .first; + final firstFocus = tester.widget(firstField).focusNode; + await tester.tap(firstField); + await tester.pump(); + expect(firstFocus.hasFocus, isTrue); + + // The opaque GestureDetector wrapping the form clears focus on tap — it is + // the only opaque GestureDetector that is an ancestor of the Form (field + // and button gesture handlers are descendants of the Form). + final dismissArea = find.ancestor( + of: find.descendant( + of: find.byType(KycRegistrationPersonalStep), + matching: find.byType(Form), + ), + matching: find.byWidgetPredicate( + (w) => w is GestureDetector && w.behavior == HitTestBehavior.opaque && w.onTap != null, + ), + ); + expect(dismissArea, findsOneWidget); + tester.widget(dismissArea).onTap!(); + await tester.pump(); + + expect(firstFocus.hasFocus, isFalse); + }); + }); +} + +class _Harness { + _Harness({required String firstName, required String lastName}) + : firstNameCtrl = TextEditingController(text: firstName), + lastNameCtrl = TextEditingController(text: lastName); + + final typeCtrl = ValueNotifier(RegistrationUserType.human); + final TextEditingController firstNameCtrl; + final TextEditingController lastNameCtrl; + final phoneCtrl = ValueNotifier('+41791234567'); + final nationalityCtrl = ValueNotifier(null); + final birthdayCtrl = ValueNotifier('1990-05-15'); +} diff --git a/test/screens/kyc/steps/registration/steps/kyc_registration_tax_step_test.dart b/test/screens/kyc/steps/registration/steps/kyc_registration_tax_step_test.dart index 2eef53b31..24459e81a 100644 --- a/test/screens/kyc/steps/registration/steps/kyc_registration_tax_step_test.dart +++ b/test/screens/kyc/steps/registration/steps/kyc_registration_tax_step_test.dart @@ -10,10 +10,10 @@ import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart'; import '../../../../../helper/country_fixture.dart'; import '../../../../../helper/pump_app.dart'; -// Ids match the committed country fixture (Country equality is id-keyed), so an -// initialCountry resolves against the list the real DfxCountryService loads. +// Ids match the committed country fixture (Country equality is id-keyed). const _switzerland = Country(id: 41, symbol: 'CH', name: 'Switzerland', kycAllowed: true); const _germany = Country(id: 55, symbol: 'DE', name: 'Germany', kycAllowed: true); +const _france = Country(id: 73, symbol: 'FR', name: 'France', kycAllowed: true); void main() { setUp(() { @@ -22,16 +22,22 @@ void main() { tearDown(() async => GetIt.instance.reset()); - Future<_Harness> pump(WidgetTester tester, {Country? initialCountry}) async { + Future<_Harness> pump( + WidgetTester tester, { + required Country? residenceCountry, + List initialTaxResidences = const [], + }) async { final harness = _Harness(); await tester.pumpApp( Scaffold( body: KycRegistrationTaxStep( - taxCountryCtrl: harness.taxCountryCtrl, - tinCtrl: harness.tinCtrl, - initialCountry: initialCountry, - onSubmit: () async => harness.submitCount++, + residenceCountry: residenceCountry, + initialTaxResidences: initialTaxResidences, + onSubmit: (result) async { + harness.lastResult = result; + harness.submitCount++; + }, ), ), ); @@ -41,15 +47,41 @@ void main() { Finder scrollable() => find.byType(Scrollable).first; - Future selectTaxCountry(WidgetTester tester, String name) async { - final dropdown = find.byType(DropdownButtonFormField); + S sOf(WidgetTester tester) => + S.of(tester.element(find.byType(KycRegistrationTaxStep))); + + Future selectFreeCountry(WidgetTester tester, String name) async { + final dropdown = find.byType(DropdownButtonFormField).last; await tester.scrollUntilVisible(dropdown, 100, scrollable: scrollable()); await tester.tap(dropdown); await tester.pumpAndSettle(); - await tester.tap(find.text(name).last); + + // Priority-sorted near the top: CH, DE, IT, FR. United States is far down + // the alphabetical tail — scroll the open menu until the name is present. + final nameFinder = find.text(name); + if (nameFinder.evaluate().isEmpty || find.text(name).hitTestable().evaluate().isEmpty) { + final menuScrollable = find.byType(Scrollable).last; + await tester.dragUntilVisible( + nameFinder, + menuScrollable, + const Offset(0, -300), + maxIteration: 120, + ); + await tester.pumpAndSettle(); + } + final item = find.text(name).last; + await tester.tap(item); await tester.pumpAndSettle(); } + Future enterTinAt(WidgetTester tester, int index, String value) async { + final tinFields = find.widgetWithText(TextFormField, sOf(tester).tinHint); + final field = tinFields.at(index); + await tester.scrollUntilVisible(field, 100, scrollable: scrollable()); + await tester.enterText(field, value); + await tester.pump(); + } + Future tapComplete(WidgetTester tester) async { final button = find.byType(AppFilledButton); await tester.scrollUntilVisible(button, 100, scrollable: scrollable()); @@ -57,105 +89,635 @@ void main() { await tester.pumpAndSettle(); } - group('$KycRegistrationTaxStep', () { + Future addTaxResidence(WidgetTester tester) async { + final addButton = find.text(sOf(tester).addTaxResidence); + await tester.scrollUntilVisible(addButton, 100, scrollable: scrollable()); + await tester.tap(addButton); + await tester.pumpAndSettle(); + } + + Finder taxRowKeys() => find.byWidgetPredicate( + (w) => w.key is ValueKey && (w.key! as ValueKey).value.startsWith('tax-row-'), + ); + + // --------------------------------------------------------------------------- + // S1 — Address CH, tax residences: CH only + // Expected: swissTaxResidence=true, countryAndTINs=null + // --------------------------------------------------------------------------- + group('S1 CH only (locked Swiss residence)', () { + testWidgets('locked UI shows Switzerland without a free country dropdown', (tester) async { + await pump(tester, residenceCountry: _switzerland); + + expect(find.text('Switzerland'), findsWidgets); + expect(find.byType(DropdownButtonFormField), findsNothing); + }); + + testWidgets('TIN visibility: CH row has no TIN field', (tester) async { + await pump(tester, residenceCountry: _switzerland); + + expect(find.text(sOf(tester).taxIdentificationNumber), findsNothing); + expect(find.widgetWithText(TextFormField, sOf(tester).tinHint), findsNothing); + }); + testWidgets( - 'keeps the TIN field hidden for a Swiss tax residence', + 'happy-path submit: swissTaxResidence=true, countryAndTINs=null', (tester) async { - await pump(tester); - await selectTaxCountry(tester, 'Switzerland'); + final harness = await pump(tester, residenceCountry: _switzerland); - final context = tester.element(find.byType(KycRegistrationTaxStep)); - expect(find.text(S.of(context).taxIdentificationNumber), findsNothing); + await tapComplete(tester); + + expect(harness.submitCount, 1); + expect(harness.lastResult!.swissTaxResidence, isTrue); + expect(harness.lastResult!.countryAndTINs, isNull); }, ); + }); + + // --------------------------------------------------------------------------- + // S2 — Address DE, tax residences: DE only + // Expected: swissTaxResidence=false, countryAndTINs=[{DE, tin}] + // --------------------------------------------------------------------------- + group('S2 DE only (locked German residence)', () { + testWidgets('locked UI shows Germany without a free country dropdown', (tester) async { + await pump(tester, residenceCountry: _germany); + + expect(find.text('Germany'), findsWidgets); + expect(find.byType(DropdownButtonFormField), findsNothing); + }); + + testWidgets('TIN visibility: DE row shows taxIdentificationNumber', (tester) async { + await pump(tester, residenceCountry: _germany); + + expect(find.text(sOf(tester).taxIdentificationNumber), findsOneWidget); + expect(find.widgetWithText(TextFormField, sOf(tester).tinHint), findsOneWidget); + }); + + testWidgets('validation: empty TIN blocks submit and shows tinRequired', (tester) async { + final harness = await pump(tester, residenceCountry: _germany); + + await tapComplete(tester); + + expect(find.text(sOf(tester).tinRequired), findsOneWidget); + expect(harness.submitCount, 0); + }); + + testWidgets('validation: whitespace-only TIN is rejected', (tester) async { + final harness = await pump(tester, residenceCountry: _germany); + + await enterTinAt(tester, 0, ' '); + await tapComplete(tester); + + expect(find.text(sOf(tester).tinRequired), findsOneWidget); + expect(harness.submitCount, 0); + }); testWidgets( - 'reveals a required TIN field for a non-Swiss tax residence', + 'happy-path submit: swissTaxResidence=false, countryAndTINs=[{DE,tin}]', (tester) async { - final harness = await pump(tester); - await selectTaxCountry(tester, 'Germany'); + final harness = await pump(tester, residenceCountry: _germany); - final context = tester.element(find.byType(KycRegistrationTaxStep)); - expect(find.text(S.of(context).taxIdentificationNumber), findsOneWidget); + await enterTinAt(tester, 0, ' DE123456789 '); + await tapComplete(tester); + + expect(harness.submitCount, 1); + expect(harness.lastResult!.swissTaxResidence, isFalse); + final tins = harness.lastResult!.countryAndTINs!; + expect(tins, hasLength(1)); + expect(tins.single.country, 'DE'); + expect(tins.single.tin, 'DE123456789'); + }, + ); + }); + + // --------------------------------------------------------------------------- + // S3 — Address CH, tax residences: CH + FR + // Expected: swissTaxResidence=true, countryAndTINs=[{FR, tin}] + // --------------------------------------------------------------------------- + group('S3 CH + FR (locked Swiss + free France)', () { + Future<_Harness> pumpS3Ready(WidgetTester tester) async { + final harness = await pump(tester, residenceCountry: _switzerland); + await addTaxResidence(tester); + await selectFreeCountry(tester, 'France'); + return harness; + } + + testWidgets('locked UI keeps Switzerland locked; free dropdown for FR row', (tester) async { + await pumpS3Ready(tester); + + expect(find.text('Switzerland'), findsWidgets); + // Free row after selection still holds a dropdown (editable free country). + expect(find.byType(DropdownButtonFormField), findsOneWidget); + expect(find.text('France'), findsWidgets); + }); + + testWidgets('TIN visibility: CH no TIN; FR has TIN', (tester) async { + await pumpS3Ready(tester); + + expect(find.text(sOf(tester).taxIdentificationNumber), findsOneWidget); + expect(find.widgetWithText(TextFormField, sOf(tester).tinHint), findsOneWidget); + }); + + testWidgets('validation: empty FR TIN blocks submit', (tester) async { + final harness = await pumpS3Ready(tester); + + await tapComplete(tester); + + expect(find.text(sOf(tester).tinRequired), findsOneWidget); + expect(harness.submitCount, 0); + }); - // Submitting with an empty TIN must surface the required error and must - // not fire onSubmit — the derived non-Swiss residence needs a TIN. + testWidgets('validation: whitespace FR TIN is rejected', (tester) async { + final harness = await pumpS3Ready(tester); + + await enterTinAt(tester, 0, ' \t '); + await tapComplete(tester); + + expect(find.text(sOf(tester).tinRequired), findsOneWidget); + expect(harness.submitCount, 0); + }); + + testWidgets( + 'happy-path submit: swissTaxResidence=true, countryAndTINs=[{FR,tin}]', + (tester) async { + final harness = await pumpS3Ready(tester); + + await enterTinAt(tester, 0, ' FR999 '); await tapComplete(tester); - expect(find.text(S.of(context).tinRequired), findsOneWidget); - expect(harness.submitCount, 0); + expect(harness.submitCount, 1); + expect(harness.lastResult!.swissTaxResidence, isTrue); + final tins = harness.lastResult!.countryAndTINs!; + expect(tins, hasLength(1)); + expect(tins.single.country, 'FR'); + expect(tins.single.tin, 'FR999'); }, ); + }); + + // --------------------------------------------------------------------------- + // S4 — Address DE, tax residences: DE + CH + // Expected: swissTaxResidence=true, countryAndTINs=[{DE, tin}] + // --------------------------------------------------------------------------- + group('S4 DE + CH (locked German + free Switzerland)', () { + Future<_Harness> pumpS4Ready(WidgetTester tester) async { + final harness = await pump(tester, residenceCountry: _germany); + await enterTinAt(tester, 0, 'DE111'); + await addTaxResidence(tester); + await selectFreeCountry(tester, 'Switzerland'); + return harness; + } + + testWidgets('locked UI shows Germany; free CH selectable', (tester) async { + await pumpS4Ready(tester); + + expect(find.text('Germany'), findsWidgets); + expect(find.text('Switzerland'), findsWidgets); + expect(find.byType(DropdownButtonFormField), findsOneWidget); + }); + + testWidgets('TIN visibility: only DE has TIN; CH has none', (tester) async { + await pumpS4Ready(tester); + + expect(find.text(sOf(tester).taxIdentificationNumber), findsOneWidget); + expect(find.widgetWithText(TextFormField, sOf(tester).tinHint), findsOneWidget); + }); + + testWidgets('validation: empty DE TIN blocks submit (CH has no TIN)', (tester) async { + final harness = await pump(tester, residenceCountry: _germany); + await addTaxResidence(tester); + await selectFreeCountry(tester, 'Switzerland'); + + await tapComplete(tester); + + expect(find.text(sOf(tester).tinRequired), findsOneWidget); + expect(harness.submitCount, 0); + }); + + testWidgets('validation: whitespace DE TIN is rejected', (tester) async { + final harness = await pump(tester, residenceCountry: _germany); + await enterTinAt(tester, 0, ' '); + await addTaxResidence(tester); + await selectFreeCountry(tester, 'Switzerland'); + + await tapComplete(tester); + + expect(find.text(sOf(tester).tinRequired), findsOneWidget); + expect(harness.submitCount, 0); + }); testWidgets( - 'rejects a whitespace-only TIN', + 'happy-path submit: swissTaxResidence=true, countryAndTINs=[{DE,tin}]', (tester) async { - final harness = await pump(tester); - await selectTaxCountry(tester, 'Germany'); + final harness = await pumpS4Ready(tester); - final context = tester.element(find.byType(KycRegistrationTaxStep)); - final tinField = find.widgetWithText(TextFormField, S.of(context).tinHint); - await tester.scrollUntilVisible(tinField, 100, scrollable: scrollable()); - await tester.enterText(tinField, ' '); - await tester.pump(); + await tapComplete(tester); + + expect(harness.submitCount, 1); + expect(harness.lastResult!.swissTaxResidence, isTrue); + final tins = harness.lastResult!.countryAndTINs!; + expect(tins, hasLength(1)); + expect(tins.single.country, 'DE'); + expect(tins.single.tin, 'DE111'); + }, + ); + + testWidgets( + 'free picker excludes the locked residence country (no DE duplicate option)', + (tester) async { + await pump(tester, residenceCountry: _germany); + await addTaxResidence(tester); + + final dropdown = find.byType(DropdownButtonFormField); + await tester.scrollUntilVisible(dropdown, 100, scrollable: scrollable()); + await tester.tap(dropdown); + await tester.pumpAndSettle(); + + // DE is locked on the primary row — only that one display may show + // "Germany"; the open free-picker menu must not offer it again. + // Switzerland remains available on the priority list. + expect(find.text('Switzerland').last, findsOneWidget); + expect(find.text('Germany'), findsOneWidget); + }, + ); + }); + + // --------------------------------------------------------------------------- + // S5 — Address DE, tax residences: DE + FR + US + // Expected: swissTaxResidence=false, countryAndTINs=[{DE},{FR},{US}] + // --------------------------------------------------------------------------- + group('S5 DE + FR + US (locked German + free FR + free US)', () { + Future<_Harness> pumpS5WithCountries(WidgetTester tester) async { + final harness = await pump(tester, residenceCountry: _germany); + await addTaxResidence(tester); + await selectFreeCountry(tester, 'France'); + await addTaxResidence(tester); + await selectFreeCountry(tester, 'United States'); + return harness; + } + + testWidgets('locked UI shows Germany; FR and US free rows present', (tester) async { + await pumpS5WithCountries(tester); + + expect(find.text('Germany'), findsWidgets); + expect(find.text('France'), findsWidgets); + expect(find.text('United States'), findsWidgets); + // Two free country dropdowns (FR, US); locked primary has none. + expect(find.byType(DropdownButtonFormField), findsNWidgets(2)); + }); + + testWidgets('TIN visibility: three TIN fields for DE, FR, US', (tester) async { + await pumpS5WithCountries(tester); + + expect(find.text(sOf(tester).taxIdentificationNumber), findsNWidgets(3)); + expect(find.widgetWithText(TextFormField, sOf(tester).tinHint), findsNWidgets(3)); + }); + + testWidgets('validation: each empty non-CH TIN blocks submit', (tester) async { + final harness = await pumpS5WithCountries(tester); + + // All three empty → three tinRequired messages. + await tapComplete(tester); + expect(find.text(sOf(tester).tinRequired), findsNWidgets(3)); + expect(harness.submitCount, 0); + + // Fill DE only → FR + US still error. + await enterTinAt(tester, 0, 'DE111'); + await tapComplete(tester); + expect(find.text(sOf(tester).tinRequired), findsNWidgets(2)); + expect(harness.submitCount, 0); + + // Fill DE + FR → US still error. + await enterTinAt(tester, 1, 'FR999'); + await tapComplete(tester); + expect(find.text(sOf(tester).tinRequired), findsOneWidget); + expect(harness.submitCount, 0); + }); + + testWidgets('validation: whitespace TIN rejected on at least one non-CH row', (tester) async { + final harness = await pumpS5WithCountries(tester); + + await enterTinAt(tester, 0, 'DE111'); + await enterTinAt(tester, 1, 'FR999'); + await enterTinAt(tester, 2, ' '); + await tapComplete(tester); + + expect(find.text(sOf(tester).tinRequired), findsOneWidget); + expect(harness.submitCount, 0); + }); + + testWidgets( + 'happy-path submit: swissTaxResidence=false, countryAndTINs=[{DE},{FR},{US}]', + (tester) async { + final harness = await pumpS5WithCountries(tester); + await enterTinAt(tester, 0, ' DE111 '); + await enterTinAt(tester, 1, ' FR999 '); + await enterTinAt(tester, 2, ' US123 '); await tapComplete(tester); - expect(find.text(S.of(context).tinRequired), findsOneWidget); - expect(harness.submitCount, 0); + expect(harness.submitCount, 1); + expect(harness.lastResult!.swissTaxResidence, isFalse); + final tins = harness.lastResult!.countryAndTINs!; + expect(tins, hasLength(3)); + expect(tins[0].country, 'DE'); + expect(tins[0].tin, 'DE111'); + expect(tins[1].country, 'FR'); + expect(tins[1].tin, 'FR999'); + expect(tins[2].country, 'US'); + expect(tins[2].tin, 'US123'); }, ); + }); + // --------------------------------------------------------------------------- + // Remove interaction — the free-row "remove" button and its effect on + // _usedSymbols and the submit payload. The locked residence primary row + // must never expose a functioning remove button. + // --------------------------------------------------------------------------- + group('$KycRegistrationTaxStep remove tax residence row', () { testWidgets( - 'submits once a non-Swiss country and a TIN are provided', + 'locked residence primary row has no remove button', (tester) async { - final harness = await pump(tester); - await selectTaxCountry(tester, 'Germany'); + await pump(tester, residenceCountry: _switzerland); - final context = tester.element(find.byType(KycRegistrationTaxStep)); - final tinField = find.widgetWithText(TextFormField, S.of(context).tinHint); - await tester.scrollUntilVisible(tinField, 100, scrollable: scrollable()); - await tester.enterText(tinField, '12 345 678 901'); - await tester.pump(); + expect(find.text(sOf(tester).removeTaxResidence), findsNothing); + }, + ); + + testWidgets( + 'removing an additional row clears it, frees its country for ' + 're-selection, and drops it from the submit payload', + (tester) async { + final harness = await pump(tester, residenceCountry: _switzerland); + + await addTaxResidence(tester); + await selectFreeCountry(tester, 'France'); + await enterTinAt(tester, 0, 'FR999'); + + final removeButton = find.text(sOf(tester).removeTaxResidence); + expect(removeButton, findsOneWidget); + await tester.scrollUntilVisible(removeButton, 100, scrollable: scrollable()); + await tester.tap(removeButton); + await tester.pumpAndSettle(); + + // (a) the row is gone from the UI. + expect(find.text('France'), findsNothing); + expect(find.text(sOf(tester).taxIdentificationNumber), findsNothing); + expect(find.text(sOf(tester).removeTaxResidence), findsNothing); + + // (c) the removed country is not part of the submit payload. + await tapComplete(tester); + expect(harness.submitCount, 1); + expect(harness.lastResult!.swissTaxResidence, isTrue); + expect(harness.lastResult!.countryAndTINs, isNull); + + // (b) the country is selectable again in a newly added free picker — + // proves the _usedSymbols computation was updated on removal. + await addTaxResidence(tester); + await selectFreeCountry(tester, 'France'); + expect(find.text('France'), findsWidgets); + }, + ); + }); + + // --------------------------------------------------------------------------- + // Prefill seeds + row/TIN limits + // --------------------------------------------------------------------------- + group('$KycRegistrationTaxStep prefill and bounds', () { + testWidgets( + 'prefill residence CH: locked CH without TIN, DE row prefilled, submit keeps both', + (tester) async { + final harness = await pump( + tester, + residenceCountry: _switzerland, + initialTaxResidences: const [ + KycTaxResidenceSeed(country: _switzerland, tin: ''), + KycTaxResidenceSeed(country: _germany, tin: 'DE123'), + ], + ); + + expect(find.text('Switzerland'), findsWidgets); + expect(find.text('Germany'), findsWidgets); + // Locked primary only — free DE row has a dropdown. + expect(find.byType(DropdownButtonFormField), findsOneWidget); + // CH has no TIN; DE TIN is prefilled. + expect(find.text(sOf(tester).taxIdentificationNumber), findsOneWidget); + final tinField = find.widgetWithText(TextFormField, sOf(tester).tinHint); + expect(tinField, findsOneWidget); + expect(tester.widget(tinField).controller!.text, 'DE123'); + + await tapComplete(tester); + + expect(harness.submitCount, 1); + expect(harness.lastResult!.swissTaxResidence, isTrue); + final tins = harness.lastResult!.countryAndTINs!; + expect(tins, hasLength(1)); + expect(tins.single.country, 'DE'); + expect(tins.single.tin, 'DE123'); + }, + ); + + testWidgets( + 'prefill residence DE plus CH: locked DE TIN prefilled, free CH without TIN', + (tester) async { + final harness = await pump( + tester, + residenceCountry: _germany, + initialTaxResidences: const [ + KycTaxResidenceSeed(country: _germany, tin: 'DE123'), + KycTaxResidenceSeed(country: _switzerland, tin: ''), + ], + ); + + expect(find.text('Germany'), findsWidgets); + expect(find.text('Switzerland'), findsWidgets); + expect(find.byType(DropdownButtonFormField), findsOneWidget); + expect(find.text(sOf(tester).taxIdentificationNumber), findsOneWidget); + final tinField = find.widgetWithText(TextFormField, sOf(tester).tinHint); + expect(tester.widget(tinField).controller!.text, 'DE123'); await tapComplete(tester); - expect(harness.tinCtrl.text, '12 345 678 901'); expect(harness.submitCount, 1); + expect(harness.lastResult!.swissTaxResidence, isTrue); + final tins = harness.lastResult!.countryAndTINs!; + expect(tins, hasLength(1)); + expect(tins.single.country, 'DE'); + expect(tins.single.tin, 'DE123'); + }, + ); + + testWidgets('no prefill: exactly one locked residence row', (tester) async { + await pump( + tester, + residenceCountry: _switzerland, + initialTaxResidences: const [], + ); + + expect(taxRowKeys(), findsOneWidget); + expect(find.byType(DropdownButtonFormField), findsNothing); + expect(find.text(sOf(tester).addTaxResidence), findsOneWidget); + }); + + testWidgets( + 'row limit: add button disappears once 10 free (non-CH) tax residences are present', + (tester) async { + await pump(tester, residenceCountry: _switzerland); + + // Locked CH primary does not count against the countryAndTINs cap. + // 10 free non-CH rows + locked CH = 11 total; further adds are not triggerable. + for (var i = 0; i < 10; i++) { + await addTaxResidence(tester); + } + + expect(taxRowKeys(), findsNWidgets(11)); + expect(find.text(sOf(tester).addTaxResidence), findsNothing); + }, + ); + + testWidgets( + 'CH resident + 10 foreign seeds: all 11 rows appear (none of the 10 non-CH dropped)', + (tester) async { + // CH never occupies a countryAndTINs slot, so a CH seed + 10 non-CH seeds + // must all render (11 rows total). + final seeds = [ + const KycTaxResidenceSeed(country: _switzerland, tin: ''), + for (var i = 0; i < 10; i++) + KycTaxResidenceSeed( + country: Country(id: 2000 + i, symbol: 'X$i', name: 'Country $i', kycAllowed: true), + tin: 'TIN$i', + ), + ]; + + await pump( + tester, + residenceCountry: _switzerland, + initialTaxResidences: seeds, + ); + + expect(taxRowKeys(), findsNWidgets(11)); + // 10 free (prefilled) non-CH rows keep a dropdown; primary CH is locked. + expect(find.byType(DropdownButtonFormField), findsNWidgets(10)); + expect(find.text(sOf(tester).addTaxResidence), findsNothing); }, ); + testWidgets('TIN length: entering 70 characters is truncated to 64', (tester) async { + await pump(tester, residenceCountry: _germany); + + final longTin = 'A' * 70; + await enterTinAt(tester, 0, longTin); + + final tinField = find.widgetWithText(TextFormField, sOf(tester).tinHint); + final text = tester.widget(tinField).controller!.text; + expect(text.length, 64); + expect(text, 'A' * 64); + }); + testWidgets( - 'submits a Swiss tax residence without requiring a TIN', + 'seeded TIN over 64 chars is truncated in the field and on submit', (tester) async { - final harness = await pump(tester); - await selectTaxCountry(tester, 'Switzerland'); + final longTin = 'B' * 70; + final harness = await pump( + tester, + residenceCountry: _switzerland, + initialTaxResidences: [ + const KycTaxResidenceSeed(country: _switzerland, tin: ''), + KycTaxResidenceSeed(country: _germany, tin: longTin), + ], + ); + + final tinField = find.widgetWithText(TextFormField, sOf(tester).tinHint); + expect(tinField, findsOneWidget); + final text = tester.widget(tinField).controller!.text; + expect(text.length, 64); + expect(text, 'B' * 64); await tapComplete(tester); expect(harness.submitCount, 1); + final tins = harness.lastResult!.countryAndTINs!; + expect(tins, hasLength(1)); + expect(tins.single.country, 'DE'); + expect(tins.single.tin.length, 64); + expect(tins.single.tin, 'B' * 64); }, ); + testWidgets('prefill seeds over the limit keep 10 free non-CH rows (11 total with CH)', (tester) async { + // 12 foreign seeds + locked CH primary would be 13 without the cap; only + // 10 free (non-CH) seeds are kept after the primary, for 11 total (CH + 10). + final seeds = [ + for (var i = 0; i < 12; i++) + KycTaxResidenceSeed( + country: Country(id: 1000 + i, symbol: 'X$i', name: 'Country $i', kycAllowed: true), + tin: 'TIN$i', + ), + ]; + + await pump( + tester, + residenceCountry: _switzerland, + initialTaxResidences: seeds, + ); + + expect(taxRowKeys(), findsNWidgets(11)); + // 10 free (prefilled) rows keep a dropdown; primary is locked. + expect(find.byType(DropdownButtonFormField), findsNWidgets(10)); + expect(find.text(sOf(tester).addTaxResidence), findsNothing); + }); + }); + + // --------------------------------------------------------------------------- + // Empty-form fallback (null residence) + keyboard dismiss + residence re-lock + // --------------------------------------------------------------------------- + group('$KycRegistrationTaxStep without residence country (empty fallback)', () { testWidgets( 'does not submit while no tax-residence country is picked', (tester) async { - final harness = await pump(tester); + final harness = await pump(tester, residenceCountry: null); - // The mandatory country picker keeps the form invalid until a choice - // is made, so onSubmit must never fire. await tapComplete(tester); expect(harness.submitCount, 0); }, ); + testWidgets( + 'submits a free Swiss pick without TIN', + (tester) async { + final harness = await pump(tester, residenceCountry: null); + await selectFreeCountry(tester, 'Switzerland'); + await tapComplete(tester); + + expect(harness.submitCount, 1); + expect(harness.lastResult!.swissTaxResidence, isTrue); + expect(harness.lastResult!.countryAndTINs, isNull); + }, + ); + + testWidgets( + 'submits a free German pick with TIN', + (tester) async { + final harness = await pump(tester, residenceCountry: null); + await selectFreeCountry(tester, 'Germany'); + await enterTinAt(tester, 0, '12 345 678 901'); + await tapComplete(tester); + + expect(harness.submitCount, 1); + expect(harness.lastResult!.swissTaxResidence, isFalse); + final tins = harness.lastResult!.countryAndTINs!; + expect(tins, hasLength(1)); + expect(tins.single.country, 'DE'); + expect(tins.single.tin, '12 345 678 901'); + }, + ); + testWidgets( 'dismisses the keyboard when tapping outside the fields', (tester) async { - await pump(tester); - // Reveal the TIN field so there is a focusable input to clear. - await selectTaxCountry(tester, 'Germany'); + await pump(tester, residenceCountry: null); + await selectFreeCountry(tester, 'Germany'); final tinField = find .descendant( @@ -168,7 +730,6 @@ void main() { await tester.pump(); expect(tinFocus.hasFocus, isTrue); - // The opaque GestureDetector wrapping the form clears focus on tap. final dismissArea = find.ancestor( of: find.descendant( of: find.byType(KycRegistrationTaxStep), @@ -185,52 +746,167 @@ void main() { expect(tinFocus.hasFocus, isFalse); }, ); + + testWidgets( + 'does not render a remove button on the sole free row ' + '(regression guard: index 0 must never be removable)', + (tester) async { + await pump(tester, residenceCountry: null); + + expect(find.text(sOf(tester).removeTaxResidence), findsNothing); + }, + ); + + testWidgets( + 'does not render a remove button on free row 0 when a second free row ' + 'is added; row 1 remove works and does not resurrect a button on row 0 ' + '(regression guard: only index > 0 is removable)', + (tester) async { + await pump(tester, residenceCountry: null); + await selectFreeCountry(tester, 'Switzerland'); + await addTaxResidence(tester); + await selectFreeCountry(tester, 'Germany'); + + final removeButton = find.text(sOf(tester).removeTaxResidence); + expect(removeButton, findsOneWidget); + await tester.scrollUntilVisible(removeButton, 100, scrollable: scrollable()); + await tester.tap(removeButton); + await tester.pumpAndSettle(); + + expect(find.text(sOf(tester).removeTaxResidence), findsNothing); + }, + ); }); - group('$KycRegistrationTaxStep prefill', () { + group('$KycRegistrationTaxStep residence country re-lock', () { testWidgets( - 'pre-selects a non-Swiss initial country, propagating it and revealing the TIN', + 're-locks the primary row when the residence country changes', (tester) async { - final harness = await pump(tester, initialCountry: _germany); + final harness = _Harness(); + final residence = ValueNotifier(_switzerland); + addTearDown(residence.dispose); + + await tester.pumpApp( + Scaffold( + body: ValueListenableBuilder( + valueListenable: residence, + builder: (_, country, _) => KycRegistrationTaxStep( + residenceCountry: country, + initialTaxResidences: const [], + onSubmit: (result) async { + harness.lastResult = result; + harness.submitCount++; + }, + ), + ), + ), + ); + await tester.pumpAndSettle(); - // The residence country handed in as initialCountry propagates into the - // controller (so the derived swissTaxResidence is set) and reveals the - // required TIN — without any manual pick. - expect(harness.taxCountryCtrl.value, _germany); - final context = tester.element(find.byType(KycRegistrationTaxStep)); + var context = tester.element(find.byType(KycRegistrationTaxStep)); + expect(find.text(S.of(context).taxIdentificationNumber), findsNothing); + + residence.value = _germany; + await tester.pumpAndSettle(); + + context = tester.element(find.byType(KycRegistrationTaxStep)); + expect(find.text('Germany'), findsWidgets); expect(find.text(S.of(context).taxIdentificationNumber), findsOneWidget); }, ); testWidgets( - 'pre-selects a Swiss initial country without a TIN', + 'keeps an additional row including its entered TIN when the residence ' + 'country changes without a collision', (tester) async { - final harness = await pump(tester, initialCountry: _switzerland); + final harness = _Harness(); + final residence = ValueNotifier(_germany); + addTearDown(residence.dispose); - expect(harness.taxCountryCtrl.value, _switzerland); - final context = tester.element(find.byType(KycRegistrationTaxStep)); - expect(find.text(S.of(context).taxIdentificationNumber), findsNothing); + await tester.pumpApp( + Scaffold( + body: ValueListenableBuilder( + valueListenable: residence, + builder: (_, country, _) => KycRegistrationTaxStep( + residenceCountry: country, + initialTaxResidences: const [], + onSubmit: (result) async { + harness.lastResult = result; + harness.submitCount++; + }, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await addTaxResidence(tester); + await selectFreeCountry(tester, 'France'); + await enterTinAt(tester, 1, 'FR999'); + + residence.value = _switzerland; + await tester.pumpAndSettle(); + + expect(find.text('Switzerland'), findsWidgets); + expect(find.text('France'), findsWidgets); + expect(find.byType(DropdownButtonFormField), findsOneWidget); + + await tapComplete(tester); + + expect(harness.submitCount, 1); + expect(harness.lastResult!.swissTaxResidence, isTrue); + final tins = harness.lastResult!.countryAndTINs!; + expect(tins, hasLength(1)); + expect(tins.single.country, 'FR'); + expect(tins.single.tin, 'FR999'); }, ); testWidgets( - 'submits the pre-selected Swiss residence without a manual pick', + 'drops an additional row that collides with the new residence country', (tester) async { - final harness = await pump(tester, initialCountry: _switzerland); + final harness = _Harness(); + final residence = ValueNotifier(_germany); + addTearDown(residence.dispose); + + await tester.pumpApp( + Scaffold( + body: ValueListenableBuilder( + valueListenable: residence, + builder: (_, country, _) => KycRegistrationTaxStep( + residenceCountry: country, + initialTaxResidences: const [], + onSubmit: (result) async { + harness.lastResult = result; + harness.submitCount++; + }, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await addTaxResidence(tester); + await selectFreeCountry(tester, 'Switzerland'); + + residence.value = _switzerland; + await tester.pumpAndSettle(); + + expect(find.text('Switzerland'), findsOneWidget); + expect(find.byType(DropdownButtonFormField), findsNothing); + expect(find.text(sOf(tester).removeTaxResidence), findsNothing); - // The prefill alone satisfies the mandatory country field. await tapComplete(tester); expect(harness.submitCount, 1); + expect(harness.lastResult!.swissTaxResidence, isTrue); + expect(harness.lastResult!.countryAndTINs, isNull); }, ); testWidgets( - 're-defaults when the residence country changes, revealing the TIN', + 'drops a colliding additional seeded row when the residence country changes', (tester) async { - // Mirror the page wiring: the tax step is rebuilt via a - // ValueListenableBuilder on the residence, so changing the residence - // after the initial prefill must re-default the tax country. final harness = _Harness(); final residence = ValueNotifier(_switzerland); addTearDown(residence.dispose); @@ -240,36 +916,52 @@ void main() { body: ValueListenableBuilder( valueListenable: residence, builder: (_, country, _) => KycRegistrationTaxStep( - taxCountryCtrl: harness.taxCountryCtrl, - tinCtrl: harness.tinCtrl, - initialCountry: country, - onSubmit: () async => harness.submitCount++, + residenceCountry: country, + initialTaxResidences: const [ + KycTaxResidenceSeed(country: _switzerland, tin: ''), + KycTaxResidenceSeed(country: _germany, tin: 'DE123'), + KycTaxResidenceSeed(country: _france, tin: 'FR999'), + ], + onSubmit: (result) async { + harness.lastResult = result; + harness.submitCount++; + }, ), ), ), ); await tester.pumpAndSettle(); - // Swiss residence: pre-selected, no TIN. - var context = tester.element(find.byType(KycRegistrationTaxStep)); - expect(harness.taxCountryCtrl.value, _switzerland); - expect(find.text(S.of(context).taxIdentificationNumber), findsNothing); + // CH locked + DE free + FR free. + expect(taxRowKeys(), findsNWidgets(3)); + expect(find.text('Germany'), findsWidgets); + expect(find.text('France'), findsWidgets); - // Residence switches to a non-Swiss country: the tax step re-defaults - // and reveals the required TIN without a manual pick. + // Residence becomes DE → the free DE row collides and is dropped; FR remains. + // didUpdateWidget rebuilds the primary without TIN prefill (user changed + // the country themselves). residence.value = _germany; await tester.pumpAndSettle(); - context = tester.element(find.byType(KycRegistrationTaxStep)); - expect(harness.taxCountryCtrl.value, _germany); - expect(find.text(S.of(context).taxIdentificationNumber), findsOneWidget); + expect(find.text('Germany'), findsWidgets); + expect(find.text('France'), findsWidgets); + // Primary locked DE + free FR only (colliding free DE removed). + expect(taxRowKeys(), findsNWidgets(2)); + // Two free-country dropdowns would mean DE still has a free row; only FR. + expect(find.byType(DropdownButtonFormField), findsOneWidget); + + // Primary DE has no prefilled TIN after the re-lock path. + final tinField = find.widgetWithText(TextFormField, sOf(tester).tinHint); + // DE (primary, empty) + FR (prefilled FR999). + expect(tinField, findsNWidgets(2)); + expect(tester.widget(tinField.at(0)).controller!.text, isEmpty); + expect(tester.widget(tinField.at(1)).controller!.text, 'FR999'); }, ); }); } class _Harness { - final taxCountryCtrl = ValueNotifier(null); - final tinCtrl = TextEditingController(); int submitCount = 0; + KycTaxResidenceSubmit? lastResult; } diff --git a/test/screens/kyc/subpages/kyc_manual_review_page_test.dart b/test/screens/kyc/subpages/kyc_manual_review_page_test.dart new file mode 100644 index 000000000..5b60cb091 --- /dev/null +++ b/test/screens/kyc/subpages/kyc_manual_review_page_test.dart @@ -0,0 +1,49 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/screens/kyc/cubits/kyc/kyc_cubit.dart'; +import 'package:realunit_wallet/screens/kyc/subpages/kyc_manual_review_page.dart'; +import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart'; + +import '../../../helper/helper.dart'; + +class _MockKycCubit extends MockCubit implements KycCubit {} + +Widget _host(KycCubit cubit) => BlocProvider.value( + value: cubit, + child: const KycManualReviewPage(), + ); + +void main() { + late _MockKycCubit cubit; + + setUp(() { + cubit = _MockKycCubit(); + when(() => cubit.state).thenReturn(const KycInitial()); + when(() => cubit.checkKyc()).thenAnswer((_) async {}); + }); + + group('$KycManualReviewPage', () { + testWidgets('renders an AppBar + headline + body + refresh button', + (tester) async { + await tester.pumpApp(_host(cubit)); + + expect(find.byType(AppBar), findsOneWidget); + expect(find.byType(AppFilledButton), findsOneWidget); + // Headline + description + button label = 4 Texts (AppBar title + 2 + // copy lines + button label). + expect(find.byType(Text), findsNWidgets(4)); + }); + + testWidgets('tapping refresh fires KycCubit.checkKyc', (tester) async { + await tester.pumpApp(_host(cubit)); + + await tester.tap(find.byType(AppFilledButton)); + await tester.pump(); + + verify(() => cubit.checkKyc()).called(1); + }); + }); +} diff --git a/test/screens/kyc/subpages/kyc_status_pages_responsive_matrix_test.dart b/test/screens/kyc/subpages/kyc_status_pages_responsive_matrix_test.dart new file mode 100644 index 000000000..8f42e609b --- /dev/null +++ b/test/screens/kyc/subpages/kyc_status_pages_responsive_matrix_test.dart @@ -0,0 +1,292 @@ +// Responsive matrix gate for KYC status page CTAs. +// +// Proves the three status pages keep their single sticky CTA fully tappable +// across the full device × text-scale matrix (see test/helper/responsive_matrix.dart). +// This is the regression lock for the dead-CTA bug: long DE copy + high +// textScale + Column(Spacer/copy/button/Spacer) with no scroll view overflowed +// the Scaffold body so the button painted outside the hit-testable region. +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/screens/kyc/cubits/kyc/kyc_cubit.dart'; +import 'package:realunit_wallet/screens/kyc/subpages/kyc_completed_page.dart'; +import 'package:realunit_wallet/screens/kyc/subpages/kyc_manual_review_page.dart'; +import 'package:realunit_wallet/screens/kyc/subpages/kyc_pending_page.dart'; +import 'package:realunit_wallet/styles/themes.dart'; +import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart'; + +import '../../../helper/helper.dart'; + +class _MockKycCubit extends MockCubit implements KycCubit {} + +/// Longest enum name → longest interpolated DE copy ("…FINANCIALDATA…"). +const _longestPendingStep = KycStep.financialData; + +const _localizationsDelegates = >[ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, +]; + +/// Root marker so completed-page REGRESSION can assert a real `context.pop`. +const _rootMarker = 'KYC_COMPLETED_ROOT'; + +void main() { + late _MockKycCubit cubit; + + setUp(() { + cubit = _MockKycCubit(); + when(() => cubit.state).thenReturn(const KycInitial()); + when(() => cubit.checkKyc()).thenAnswer((_) async {}); + }); + + Future pumpPage( + WidgetTester tester, + MatrixCell cell, + Widget page, + ) async { + await tester.binding.setSurfaceSize(cell.device.size); + addTearDown(() async { + await tester.binding.setSurfaceSize(null); + }); + + await tester.pumpWidget( + MediaQuery( + data: cell.mediaQuery, + child: MaterialApp( + theme: realUnitTheme, + locale: const Locale('de'), + localizationsDelegates: _localizationsDelegates, + supportedLocales: S.delegate.supportedLocales, + home: page, + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + } + + /// Hosts [KycCompletedPage] under a two-route GoRouter so `context.pop` has + /// somewhere to go back to (mirrors support_email_capture_page_test). + Future pumpCompletedPage( + WidgetTester tester, + MatrixCell cell, + ) async { + await tester.binding.setSurfaceSize(cell.device.size); + addTearDown(() async { + await tester.binding.setSurfaceSize(null); + }); + + final router = GoRouter( + initialLocation: '/', + routes: [ + GoRoute( + path: '/', + builder: (_, _) => const Scaffold( + body: Center(child: Text(_rootMarker)), + ), + ), + GoRoute( + path: '/completed', + builder: (_, _) => const KycCompletedPage(), + ), + ], + ); + addTearDown(router.dispose); + + await tester.pumpWidget( + MediaQuery( + data: cell.mediaQuery, + child: MaterialApp.router( + theme: realUnitTheme, + locale: const Locale('de'), + localizationsDelegates: _localizationsDelegates, + supportedLocales: S.delegate.supportedLocales, + routerConfig: router, + ), + ), + ); + await tester.pump(); + + router.push('/completed'); + await tester.pumpAndSettle(); + + return router; + } + + group('KycCompletedPage responsive matrix (full device × textScale)', () { + for (final cell in kFullResponsiveMatrix) { + testWidgets(cell.id, (tester) async { + await withTargetPlatform(cell.device.platform, () async { + await expectNoLayoutOverflow( + tester, + () async { + await pumpCompletedPage(tester, cell); + }, + reason: 'overflow on KycCompletedPage / ${cell.label}', + ); + + await expectFullyTappable( + tester, + find.byType(AppFilledButton), + within: find.byType(KycCompletedPage), + reason: 'KycCompletedPage / ${cell.label}: CTA not tappable', + ); + }); + }); + } + }); + + group('KycManualReviewPage responsive matrix (full device × textScale)', () { + for (final cell in kFullResponsiveMatrix) { + testWidgets(cell.id, (tester) async { + await withTargetPlatform(cell.device.platform, () async { + await expectNoLayoutOverflow( + tester, + () async { + await pumpPage( + tester, + cell, + BlocProvider.value( + value: cubit, + child: const KycManualReviewPage(), + ), + ); + }, + reason: 'overflow on KycManualReviewPage / ${cell.label}', + ); + + await expectFullyTappable( + tester, + find.byType(AppFilledButton), + within: find.byType(KycManualReviewPage), + reason: 'KycManualReviewPage / ${cell.label}: CTA not tappable', + ); + }); + }); + } + }); + + group('KycPendingPage responsive matrix (full device × textScale)', () { + for (final cell in kFullResponsiveMatrix) { + testWidgets(cell.id, (tester) async { + await withTargetPlatform(cell.device.platform, () async { + await expectNoLayoutOverflow( + tester, + () async { + await pumpPage( + tester, + cell, + BlocProvider.value( + value: cubit, + child: const KycPendingPage(pendingStep: _longestPendingStep), + ), + ); + }, + reason: 'overflow on KycPendingPage / ${cell.label}', + ); + + await expectFullyTappable( + tester, + find.byType(AppFilledButton), + within: find.byType(KycPendingPage), + reason: 'KycPendingPage / ${cell.label}: CTA not tappable', + ); + }); + }); + } + }); + + // Focused regressions: pre-fix breakage cells (measured facts). + // expectFullyTappable already taps; we additionally verify the side effect. + + testWidgets( + 'REGRESSION: iPhone SE + textScale 2.0 completed — tap pops to root', + (tester) async { + final cell = MatrixCell( + kIosDeviceProfiles.firstWhere((d) => d.id == 'iphone_se_3'), + 2.0, + ); + await withTargetPlatform(cell.device.platform, () async { + await expectNoLayoutOverflow(tester, () async { + await pumpCompletedPage(tester, cell); + }); + + expect(find.byType(KycCompletedPage), findsOneWidget); + expect(find.text(_rootMarker), findsNothing); + + await expectFullyTappable( + tester, + find.byType(AppFilledButton), + within: find.byType(KycCompletedPage), + ); + await tester.pumpAndSettle(); + + expect(find.text(_rootMarker), findsOneWidget); + expect(find.byType(KycCompletedPage), findsNothing); + }); + }, + ); + + testWidgets( + 'REGRESSION: iPhone SE + textScale 2.0 manual review — tap calls checkKyc', + (tester) async { + final cell = MatrixCell( + kIosDeviceProfiles.firstWhere((d) => d.id == 'iphone_se_3'), + 2.0, + ); + await withTargetPlatform(cell.device.platform, () async { + await expectNoLayoutOverflow(tester, () async { + await pumpPage( + tester, + cell, + BlocProvider.value( + value: cubit, + child: const KycManualReviewPage(), + ), + ); + }); + await expectFullyTappable( + tester, + find.byType(AppFilledButton), + within: find.byType(KycManualReviewPage), + ); + verify(() => cubit.checkKyc()).called(1); + }); + }, + ); + + testWidgets( + 'REGRESSION: iPhone SE + textScale 3.0 pending — tap calls checkKyc', + (tester) async { + final cell = MatrixCell( + kIosDeviceProfiles.firstWhere((d) => d.id == 'iphone_se_3'), + 3.0, + ); + await withTargetPlatform(cell.device.platform, () async { + await expectNoLayoutOverflow(tester, () async { + await pumpPage( + tester, + cell, + BlocProvider.value( + value: cubit, + child: const KycPendingPage(pendingStep: _longestPendingStep), + ), + ); + }); + await expectFullyTappable( + tester, + find.byType(AppFilledButton), + within: find.byType(KycPendingPage), + ); + verify(() => cubit.checkKyc()).called(1); + }); + }, + ); +} diff --git a/test/screens/onboarding/onboarding_completed_responsive_matrix_test.dart b/test/screens/onboarding/onboarding_completed_responsive_matrix_test.dart new file mode 100644 index 000000000..d0b2f9c50 --- /dev/null +++ b/test/screens/onboarding/onboarding_completed_responsive_matrix_test.dart @@ -0,0 +1,135 @@ +// Responsive matrix gate for OnboardingCompletedPage CTA. +// +// Proves the "Weiter" button stays fully tappable across the full device × +// text-scale matrix. Regression lock for the iPhone SE + textScale ≥ 1.3 bug +// where the CTA was scrolled below the fold inside the legacy Spacer layout. +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/screens/home/bloc/home_bloc.dart'; +import 'package:realunit_wallet/screens/onboarding/onboarding_completed_page.dart'; +import 'package:realunit_wallet/styles/themes.dart'; +import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart'; + +import '../../helper/helper.dart'; + +class _MockHomeBloc extends MockBloc implements HomeBloc {} + +Future _pumpScreen( + WidgetTester tester, + Widget widget, + MediaQueryData mediaQuery, +) async { + await tester.binding.setSurfaceSize(mediaQuery.size); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget( + MediaQuery( + data: mediaQuery, + child: MaterialApp( + theme: realUnitTheme, + locale: const Locale('de'), + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + supportedLocales: S.delegate.supportedLocales, + home: widget, + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); +} + +void main() { + late _MockHomeBloc homeBloc; + + setUp(() { + homeBloc = _MockHomeBloc(); + when(() => homeBloc.state).thenReturn(const HomeState()); + whenListen( + homeBloc, + const Stream.empty(), + initialState: const HomeState(), + ); + // expectFullyTappable performs a real tap → onPressed → add(...). + when(() => homeBloc.add(const CompleteOnboardingEvent())).thenReturn(null); + }); + + Widget buildSubject() => BlocProvider.value( + value: homeBloc, + child: const OnboardingCompletedPage(), + ); + + group('OnboardingCompletedPage responsive matrix (full device × textScale)', () { + for (final cell in kFullResponsiveMatrix) { + testWidgets(cell.id, (tester) async { + await withTargetPlatform(cell.device.platform, () async { + await expectNoLayoutOverflow( + tester, + () async { + await _pumpScreen(tester, buildSubject(), cell.mediaQuery); + }, + reason: 'overflow on ${cell.label}', + ); + + await expectFullyTappable( + tester, + find.byType(AppFilledButton), + within: find.byType(OnboardingCompletedPage), + reason: '${cell.label}: CTA not tappable', + ); + }); + }); + } + }); + + // Focused regression: iPhone SE 375×667 DE + textScale 1.3 — the exact + // reported failure mode where the CTA dropped below the fold. + testWidgets( + 'REGRESSION: iPhone SE DE textScale 1.3 — CTA tappable and tap completes onboarding', + (tester) async { + final cell = MatrixCell( + kIosDeviceProfiles.firstWhere((d) => d.id == 'iphone_se_3'), + 1.3, + ); + await withTargetPlatform(cell.device.platform, () async { + await _pumpScreen(tester, buildSubject(), cell.mediaQuery); + + await expectFullyTappable( + tester, + find.byType(AppFilledButton), + within: find.byType(OnboardingCompletedPage), + ); + verify(() => homeBloc.add(const CompleteOnboardingEvent())).called(1); + }); + }, + ); + + testWidgets( + 'REGRESSION: iPhone SE DE textScale 2.0 — CTA still tappable', + (tester) async { + final cell = MatrixCell( + kIosDeviceProfiles.firstWhere((d) => d.id == 'iphone_se_3'), + 2.0, + ); + await withTargetPlatform(cell.device.platform, () async { + await expectNoLayoutOverflow(tester, () async { + await _pumpScreen(tester, buildSubject(), cell.mediaQuery); + }); + await expectFullyTappable( + tester, + find.byType(AppFilledButton), + within: find.byType(OnboardingCompletedPage), + ); + verify(() => homeBloc.add(const CompleteOnboardingEvent())).called(1); + }); + }, + ); +} diff --git a/test/screens/pin/pin_auth_cubit_test.dart b/test/screens/pin/pin_auth_cubit_test.dart index 6df234d79..d6f52e388 100644 --- a/test/screens/pin/pin_auth_cubit_test.dart +++ b/test/screens/pin/pin_auth_cubit_test.dart @@ -75,7 +75,7 @@ void main() { }); test('onAppResumed when PIN not setup is a no-op (no emit)', () async { - final cubit = build()..onAppHidden(); + final cubit = build()..onAppHidden(null); final before = cubit.state; cubit.onAppResumed(); expect(cubit.state, before); @@ -93,7 +93,7 @@ void main() { // lockoutDuration constant pin below — flipping that constant or the // comparator surfaces in the auth flow's own tests. cubit - ..onAppHidden() + ..onAppHidden(null) ..onAppResumed(); expect(cubit.state.isPinVerified, isTrue); @@ -110,7 +110,7 @@ void main() { final cubit = build()..onPinSetupComplete(); expect(cubit.state.isPinVerified, isTrue); - cubit.onAppHidden(); + cubit.onAppHidden(null); async.elapse(lockoutDuration + const Duration(seconds: 1)); cubit.onAppResumed(); @@ -132,6 +132,102 @@ void main() { }); }); + group('resume location capture', () { + test('a fresh non-gate capture replaces the previous one', () { + // Freshest non-null (non-gate) location wins: backgrounding again on a + // different in-flight route updates the restore target. + final cubit = build() + ..onAppHidden('/kyc') + ..onAppHidden('/settings'); + expect(cubit.peekResumeLocation(), '/settings'); + }); + + test('a null (gate) background keeps the earlier in-flight capture', () { + // The caller passes null for gate routes; a nested re-lock must not + // clobber the good `/kyc` capture with the gate. + final cubit = build() + ..onAppHidden('/kyc') + ..onAppHidden(null); + expect(cubit.peekResumeLocation(), '/kyc'); + }); + + test('a null first capture does not lock out a later real location', () { + final cubit = build() + ..onAppHidden(null) + ..onAppHidden('/kyc'); + expect(cubit.peekResumeLocation(), '/kyc'); + }); + + test('clearResumeLocation drops the capture', () { + final cubit = build()..onAppHidden('/kyc'); + expect(cubit.peekResumeLocation(), '/kyc'); + cubit.clearResumeLocation(); + expect(cubit.peekResumeLocation(), isNull); + }); + + test('a long hide keeps the resume location for the consumer', () { + // The re-lock (isPinVerified -> false) and the resume-location capture + // are independent: on relock we still hand the captured route to + // `_navigate`, which restores it after the PIN gate and clears it there. + fakeAsync((async) { + final cubit = build() + ..onPinSetupComplete() + ..onAppHidden('/kyc'); + async.elapse(lockoutDuration + const Duration(seconds: 1)); + cubit.onAppResumed(); + + expect(cubit.state.isPinVerified, isFalse); + expect(cubit.peekResumeLocation(), '/kyc'); + cubit.close(); + }); + }); + + test('a short hide in the verified state clears the capture eagerly', () { + // The episode ended without a re-lock: nothing consumes the capture, so + // it is dropped here — a much later, unrelated re-lock must not restore + // a route from a long-finished episode. + final cubit = build() + ..onPinSetupComplete() + ..onAppHidden('/kyc') + ..onAppResumed(); + + expect(cubit.state.isPinVerified, isTrue); + expect(cubit.peekResumeLocation(), isNull); + }); + + test('a short hide while the PIN gate is showing keeps the capture', () { + // Nested re-lock: a short away-switch on /verifyPin (isPinVerified is + // still false) must not lose the in-flight capture the pending unlock is + // about to restore. + fakeAsync((async) { + final cubit = build() + ..onPinSetupComplete() + ..onAppHidden('/kyc'); + async.elapse(lockoutDuration + const Duration(seconds: 1)); + cubit.onAppResumed(); // re-lock: isPinVerified -> false + + cubit + ..onAppHidden(null) // backgrounded again on the gate route + ..onAppResumed(); // short: no lockout elapsed + + expect(cubit.state.isPinVerified, isFalse); + expect(cubit.peekResumeLocation(), '/kyc'); + cubit.close(); + }); + }); + + test('reset clears the captured resume location', () async { + when(() => storage.deletePinHash()).thenAnswer((_) async {}); + when(() => storage.deleteBiometricEnabled()).thenAnswer((_) async {}); + when(() => storage.resetPinLockout()).thenAnswer((_) async {}); + + final cubit = build()..onAppHidden('/kyc'); + expect(cubit.peekResumeLocation(), '/kyc'); + await cubit.reset(); + expect(cubit.peekResumeLocation(), isNull); + }); + }); + group('reset', () { blocTest( 'wipes pin hash + biometric + lockout and emits the initial state', diff --git a/test/screens/pin/pin_sheets_responsive_matrix_test.dart b/test/screens/pin/pin_sheets_responsive_matrix_test.dart new file mode 100644 index 000000000..e5960c1dc --- /dev/null +++ b/test/screens/pin/pin_sheets_responsive_matrix_test.dart @@ -0,0 +1,193 @@ +// Responsive matrix gate for ForgotPin + EnableBiometric bottom sheets. +// +// Proves CTAs stay fully tappable across the full device × text-scale matrix +// (see test/helper/responsive_matrix.dart) when presented via a real +// showModalBottomSheet(isScrollControlled: true). Regression lock for the +// pre-fix Column(mainAxisSize: .min) host that pushed CTAs outside the +// hit-test region at large accessibility text scales. +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/screens/pin/widgets/enable_biometric_bottom_sheet.dart'; +import 'package:realunit_wallet/screens/pin/widgets/forgot_pin_bottom_sheet.dart'; +import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart'; + +import '../../helper/helper.dart'; + +const _openSheetKey = Key('pin_sheets_matrix.open'); + +void main() { + Future pumpAndOpenSheet( + WidgetTester tester, + MatrixCell cell, { + required Widget sheet, + required bool useGoRouter, + }) async { + await tester.binding.setSurfaceSize(cell.mediaQuery.size); + addTearDown(() async { + await tester.binding.setSurfaceSize(null); + }); + + Widget openButton(BuildContext context) => Center( + child: ElevatedButton( + key: _openSheetKey, + onPressed: () { + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => sheet, + ); + }, + child: const Text('open'), + ), + ); + + if (useGoRouter) { + // ForgotPinBottomSheet calls context.pop via go_router; a plain + // MaterialApp Navigator is not enough for that extension to resolve. + final router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (context, state) => Scaffold( + body: openButton(context), + ), + ), + ], + ); + + await tester.pumpWidget( + MediaQuery( + data: cell.mediaQuery, + child: MaterialApp.router( + locale: const Locale('de'), + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + supportedLocales: S.delegate.supportedLocales, + routerConfig: router, + ), + ), + ); + } else { + await tester.pumpWidget( + MediaQuery( + data: cell.mediaQuery, + child: MaterialApp( + locale: const Locale('de'), + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + supportedLocales: S.delegate.supportedLocales, + home: Scaffold( + body: Builder(builder: openButton), + ), + ), + ), + ); + } + + await tester.pump(); + await tester.tap(find.byKey(_openSheetKey)); + // Modal bottom sheet enter transition is ~250ms; budget 300ms to settle. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + } + + group('ForgotPinBottomSheet responsive matrix', () { + for (final cell in kFullResponsiveMatrix) { + testWidgets('forgotPin · ${cell.id}', (tester) async { + await withTargetPlatform(cell.device.platform, () async { + await expectNoLayoutOverflow( + tester, + () async { + await pumpAndOpenSheet( + tester, + cell, + sheet: const ForgotPinBottomSheet(), + useGoRouter: true, + ); + }, + reason: 'ForgotPinBottomSheet overflow / ${cell.label}', + ); + + expect( + find.byType(AppFilledButton), + findsNWidgets(2), + reason: 'ForgotPinBottomSheet / ${cell.label}: expected 2 CTAs', + ); + + await expectFullyTappable( + tester, + find.byType(AppFilledButton).first, + within: find.byType(ForgotPinBottomSheet), + reason: + 'ForgotPinBottomSheet / ${cell.label}: Close CTA not tappable', + ); + + // expectFullyTappable taps for real and dismisses the sheet — reopen + // fresh before asserting the second button. + await expectNoLayoutOverflow( + tester, + () async { + await pumpAndOpenSheet( + tester, + cell, + sheet: const ForgotPinBottomSheet(), + useGoRouter: true, + ); + }, + reason: + 'ForgotPinBottomSheet re-open overflow / ${cell.label}', + ); + + await expectFullyTappable( + tester, + find.byType(AppFilledButton).last, + within: find.byType(ForgotPinBottomSheet), + reason: + 'ForgotPinBottomSheet / ${cell.label}: Reset CTA not tappable', + ); + }); + }); + } + }); + + group('EnableBiometricBottomSheet responsive matrix', () { + for (final cell in kFullResponsiveMatrix) { + testWidgets('enableBiometric · ${cell.id}', (tester) async { + await withTargetPlatform(cell.device.platform, () async { + await expectNoLayoutOverflow( + tester, + () async { + await pumpAndOpenSheet( + tester, + cell, + sheet: const EnableBiometricBottomSheet(), + useGoRouter: false, + ); + }, + reason: 'EnableBiometricBottomSheet overflow / ${cell.label}', + ); + + await expectFullyTappable( + tester, + find.byType(AppFilledButton), + within: find.byType(EnableBiometricBottomSheet), + reason: + 'EnableBiometricBottomSheet / ${cell.label}: ' + 'Enable CTA not tappable', + ); + }); + }); + } + }); +} diff --git a/test/screens/pin/setup_pin_responsive_matrix_test.dart b/test/screens/pin/setup_pin_responsive_matrix_test.dart new file mode 100644 index 000000000..56cefac9f --- /dev/null +++ b/test/screens/pin/setup_pin_responsive_matrix_test.dart @@ -0,0 +1,156 @@ +// Responsive matrix gate for SetupPinView keypad. +// +// Proves digit keys stay fully tappable across the full device × text-scale +// matrix (see test/helper/responsive_matrix.dart). Regression lock for the +// IntrinsicHeight + Spacer shape that pushed the NumberPad below the viewport +// on first frame once header copy grew at high accessibility text scale. +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/packages/service/biometric_service.dart'; +import 'package:realunit_wallet/packages/storage/secure_storage.dart'; +import 'package:realunit_wallet/screens/pin/bloc/auth/pin_auth_cubit.dart'; +import 'package:realunit_wallet/screens/pin/bloc/setup_pin/setup_pin_cubit.dart'; +import 'package:realunit_wallet/screens/pin/setup_pin_page.dart'; +import 'package:realunit_wallet/widgets/number_pad.dart'; + +import '../../helper/helper.dart'; + +class MockSetupPinCubit extends MockCubit implements SetupPinCubit {} + +class MockPinAuthCubit extends MockCubit implements PinAuthCubit {} + +class MockSecureStorage extends Mock implements SecureStorage {} + +class MockBiometricService extends Mock implements BiometricService {} + +void main() { + late SetupPinCubit setupPinCubit; + late PinAuthCubit pinAuthCubit; + + void setupDependencyInjection() { + final getIt = GetIt.instance; + getIt.registerSingleton(MockBiometricService()); + getIt.registerSingleton(MockSecureStorage()); + getIt.registerSingleton(pinAuthCubit); + } + + setUpAll(() { + pinAuthCubit = MockPinAuthCubit(); + when(() => pinAuthCubit.state).thenReturn(const PinAuthState()); + when(() => pinAuthCubit.onPinSetupComplete()).thenAnswer((_) => Future.value()); + setupDependencyInjection(); + }); + + tearDownAll(() async => await GetIt.instance.reset()); + + Future pumpScreen( + WidgetTester tester, + MatrixCell cell, { + required SetupPinState state, + }) async { + setupPinCubit = MockSetupPinCubit(); + when(() => setupPinCubit.state).thenReturn(state); + when(() => setupPinCubit.isBiometricAvailable()).thenAnswer((_) => Future.value(false)); + whenListen( + setupPinCubit, + const Stream.empty(), + initialState: state, + ); + + await tester.binding.setSurfaceSize(cell.mediaQuery.size); + addTearDown(() async { + await tester.binding.setSurfaceSize(null); + }); + + await tester.pumpWidget( + MediaQuery( + data: cell.mediaQuery, + child: MaterialApp( + locale: const Locale('de'), + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + supportedLocales: S.delegate.supportedLocales, + home: BlocProvider.value( + value: setupPinCubit, + child: const SetupPinView(), + ), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + } + + group('SetupPinMode.create responsive matrix', () { + for (final cell in kFullResponsiveMatrix) { + testWidgets('create · ${cell.id}', (tester) async { + await withTargetPlatform(cell.device.platform, () async { + await expectNoLayoutOverflow( + tester, + () async { + await pumpScreen( + tester, + cell, + state: const SetupPinState(), + ); + }, + reason: 'overflow on SetupPin create / ${cell.label}', + ); + + expect(find.byType(NumberPad), findsOneWidget); + + await expectFullyTappable( + tester, + find.text('1'), + within: find.byType(SetupPinView), + reason: 'SetupPin create / ${cell.label}: digit key not tappable', + ); + }); + }); + } + }); + + group('SetupPinMode.confirm with mismatch responsive matrix', () { + for (final cell in kFullResponsiveMatrix) { + testWidgets('confirm mismatch · ${cell.id}', (tester) async { + await withTargetPlatform(cell.device.platform, () async { + await expectNoLayoutOverflow( + tester, + () async { + await pumpScreen( + tester, + cell, + state: const SetupPinState( + mode: SetupPinMode.confirm, + mismatch: true, + ), + ); + }, + reason: 'overflow on SetupPin confirm mismatch / ${cell.label}', + ); + + expect(find.byType(NumberPad), findsOneWidget); + expect(find.text(S.current.pinConfirmFailed), findsOneWidget); + + await expectFullyTappable( + tester, + find.text('1'), + within: find.byType(SetupPinView), + reason: + 'SetupPin confirm mismatch / ${cell.label}: digit key not tappable', + ); + }); + }); + } + }); +} diff --git a/test/screens/pin/verify_pin_responsive_matrix_test.dart b/test/screens/pin/verify_pin_responsive_matrix_test.dart new file mode 100644 index 000000000..a6e089d1b --- /dev/null +++ b/test/screens/pin/verify_pin_responsive_matrix_test.dart @@ -0,0 +1,199 @@ +// Responsive matrix gate for VerifyPinView keypad + Forgot-PIN CTA. +// +// Proves digit keys and the Forgot-PIN action stay fully tappable across the +// full device × text-scale matrix (see test/helper/responsive_matrix.dart). +// Regression lock for the IntrinsicHeight + Spacer collapse that pushed the +// NumberPad / Forgot-PIN control below the viewport on first frame. +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/packages/service/biometric_service.dart'; +import 'package:realunit_wallet/packages/storage/secure_storage.dart'; +import 'package:realunit_wallet/screens/pin/bloc/auth/pin_auth_cubit.dart'; +import 'package:realunit_wallet/screens/pin/bloc/verify_pin/verify_pin_cubit.dart'; +import 'package:realunit_wallet/screens/pin/verify_pin_page.dart'; +import 'package:realunit_wallet/widgets/buttons/app_text_button.dart'; +import 'package:realunit_wallet/widgets/number_pad.dart'; + +import '../../helper/helper.dart'; + +class MockVerifyPinCubit extends MockCubit + implements VerifyPinCubit {} + +class MockPinAuthCubit extends MockCubit implements PinAuthCubit {} + +class MockSecureStorage extends Mock implements SecureStorage {} + +class MockBiometricService extends Mock implements BiometricService {} + +void main() { + late VerifyPinCubit verifyPinCubit; + late PinAuthCubit pinAuthCubit; + + void setupDependencyInjection() { + final getIt = GetIt.instance; + final secureStorage = MockSecureStorage(); + when(() => secureStorage.getPinLockedUntil()).thenAnswer((_) => Future.value(null)); + when(() => secureStorage.getPinFailedAttempts()).thenAnswer((_) => Future.value(0)); + getIt.registerSingleton(secureStorage); + final biometricService = MockBiometricService(); + when(() => biometricService.isEnabled()).thenAnswer((_) => Future.value(false)); + when(() => biometricService.isAvailable()).thenAnswer((_) => Future.value(false)); + getIt.registerSingleton(biometricService); + getIt.registerSingleton(pinAuthCubit); + } + + setUpAll(() { + pinAuthCubit = MockPinAuthCubit(); + when(() => pinAuthCubit.state).thenReturn(const PinAuthState()); + when(() => pinAuthCubit.onPinVerified()).thenAnswer((_) => Future.value()); + setupDependencyInjection(); + }); + + tearDownAll(() async => await GetIt.instance.reset()); + + setUp(() { + verifyPinCubit = MockVerifyPinCubit(); + when(() => verifyPinCubit.state).thenReturn(const VerifyPinState()); + when(() => verifyPinCubit.checkBiometricAvailability()).thenAnswer((_) => Future.value()); + whenListen( + verifyPinCubit, + const Stream.empty(), + initialState: const VerifyPinState(), + ); + }); + + Future pumpScreen( + WidgetTester tester, + MatrixCell cell, { + required Widget page, + }) async { + await tester.binding.setSurfaceSize(cell.mediaQuery.size); + addTearDown(() async { + await tester.binding.setSurfaceSize(null); + }); + + await tester.pumpWidget( + MediaQuery( + data: cell.mediaQuery, + child: MaterialApp( + locale: const Locale('de'), + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + supportedLocales: S.delegate.supportedLocales, + home: page, + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + } + + /// [VerifyPinPage] builds its own cubit; for matrix layout we host + /// [VerifyPinView] under a mock cubit so state is deterministic. + Widget appLockView() => MultiBlocProvider( + providers: [ + BlocProvider.value(value: verifyPinCubit), + BlocProvider.value(value: pinAuthCubit), + ], + child: VerifyPinView( + onAuthenticated: () {}, + // Mirrors VerifyPinPage.appLock()'s bottom so Forgot-PIN is present. + bottom: Builder( + builder: (context) => Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: SizedBox( + height: 52.0, + child: AppTextButton( + fullWidth: false, + onPressed: () async { + await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => const SizedBox( + height: 200, + child: Center(child: Text('forgot-pin-sheet')), + ), + ); + }, + label: S.of(context).pinForgotten, + ), + ), + ), + ), + ), + ); + + Widget gateView() => BlocProvider.value( + value: verifyPinCubit, + child: VerifyPinView(onAuthenticated: () {}), + ); + + group('VerifyPinView.appLock responsive matrix (keypad + Forgot-PIN)', () { + for (final cell in kFullResponsiveMatrix) { + testWidgets('appLock · ${cell.id}', (tester) async { + await withTargetPlatform(cell.device.platform, () async { + await expectNoLayoutOverflow( + tester, + () async { + await pumpScreen(tester, cell, page: appLockView()); + }, + reason: 'overflow on appLock / ${cell.label}', + ); + + expect(find.byType(NumberPad), findsOneWidget); + + await expectFullyTappable( + tester, + find.text('1'), + within: find.byType(VerifyPinView), + reason: 'appLock / ${cell.label}: digit key not tappable', + ); + + // Forgot-PIN last: opens a modal that would obscure further asserts. + await expectFullyTappable( + tester, + find.byType(AppTextButton), + within: find.byType(VerifyPinView), + reason: 'appLock / ${cell.label}: Forgot-PIN not tappable', + ); + }); + }); + } + }); + + group('VerifyPinView gate responsive matrix (no Forgot-PIN)', () { + for (final cell in kFullResponsiveMatrix) { + testWidgets('gate · ${cell.id}', (tester) async { + await withTargetPlatform(cell.device.platform, () async { + await expectNoLayoutOverflow( + tester, + () async { + await pumpScreen(tester, cell, page: gateView()); + }, + reason: 'overflow on gate / ${cell.label}', + ); + + expect(find.byType(NumberPad), findsOneWidget); + expect(find.byType(AppTextButton), findsNothing); + + await expectFullyTappable( + tester, + find.text('1'), + within: find.byType(VerifyPinView), + reason: 'gate / ${cell.label}: digit key not tappable', + ); + }); + }); + } + }); +} diff --git a/test/screens/restore_wallet/restore_wallet_responsive_matrix_test.dart b/test/screens/restore_wallet/restore_wallet_responsive_matrix_test.dart new file mode 100644 index 000000000..8e95c845e --- /dev/null +++ b/test/screens/restore_wallet/restore_wallet_responsive_matrix_test.dart @@ -0,0 +1,123 @@ +// Responsive matrix gate for RestoreWalletView next CTA. +// +// Proves the restore-wallet primary button stays fully tappable across the +// full device × text-scale matrix (see test/helper/responsive_matrix.dart). +// Regression lock for the IntrinsicHeight + Spacer collapse that pushed the +// CTA below the viewport on first frame without a RenderFlex overflow. +// +// Realistic worst-case content (12-word mnemonic shape; state is mocked): +// 'cheese trigger cannon mention judge hire snack sustain annual predict illness celery' +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_kyc_service.dart'; +import 'package:realunit_wallet/packages/service/wallet_service.dart'; +import 'package:realunit_wallet/screens/home/bloc/home_bloc.dart'; +import 'package:realunit_wallet/screens/restore_wallet/cubit/restore_wallet/restore_wallet_cubit.dart'; +import 'package:realunit_wallet/screens/restore_wallet/cubit/validate_seed/validate_seed_cubit.dart'; +import 'package:realunit_wallet/screens/restore_wallet/restore_wallet_view.dart'; +import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart'; + +import '../../helper/helper.dart'; + +class MockRestoreWalletCubit extends MockCubit + implements RestoreWalletCubit {} + +class MockValidateSeedCubit extends MockCubit + implements ValidateSeedCubit {} + +class MockHomeBloc extends MockBloc implements HomeBloc {} + +class MockWalletService extends Mock implements WalletService {} + +class MockDfxKycService extends Mock implements DfxKycService {} + +void main() { + late RestoreWalletCubit restoreWalletCubit; + late ValidateSeedCubit validateSeedCubit; + late HomeBloc homeBloc; + + void setupDependencyInjection() { + final getIt = GetIt.instance; + getIt.registerSingleton(MockWalletService()); + getIt.registerSingleton(MockDfxKycService()); + } + + setUpAll(setupDependencyInjection); + + tearDownAll(() async => await GetIt.instance.reset()); + + setUp(() { + restoreWalletCubit = MockRestoreWalletCubit(); + validateSeedCubit = MockValidateSeedCubit(); + homeBloc = MockHomeBloc(); + + // Primary CTA state: seed is complete (enabled "Next" button). + // Realistic worst-case mnemonic content (mocked, not typed into fields): + // 'cheese trigger cannon mention judge hire snack sustain annual predict illness celery' + when(() => restoreWalletCubit.state).thenReturn(const RestoreWalletState()); + when(() => validateSeedCubit.state).thenReturn(ValidateSeedState.complete); + }); + + Widget buildSubject() { + return MultiBlocProvider( + providers: [ + BlocProvider.value(value: homeBloc), + BlocProvider.value(value: restoreWalletCubit), + BlocProvider.value(value: validateSeedCubit), + ], + child: const RestoreWalletView(), + ); + } + + Future pumpScreen(WidgetTester tester, MatrixCell cell, Widget child) async { + await tester.binding.setSurfaceSize(cell.mediaQuery.size); + addTearDown(() async => await tester.binding.setSurfaceSize(null)); + await tester.pumpWidget( + MediaQuery( + data: cell.mediaQuery, + child: MaterialApp( + locale: const Locale('de'), + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + supportedLocales: S.delegate.supportedLocales, + home: child, + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + } + + group('RestoreWalletView responsive matrix (full device × textScale)', () { + for (final cell in kFullResponsiveMatrix) { + testWidgets(cell.id, (tester) async { + await withTargetPlatform(cell.device.platform, () async { + await expectNoLayoutOverflow( + tester, + () async { + await pumpScreen(tester, cell, buildSubject()); + }, + reason: 'overflow on ${cell.label}', + ); + + await expectFullyTappable( + tester, + find.byType(AppFilledButton), + within: find.byType(RestoreWalletView), + reason: '${cell.label}: restore-wallet CTA not tappable', + ); + }); + }); + } + }); +} diff --git a/test/screens/sell/sell_responsive_matrix_test.dart b/test/screens/sell/sell_responsive_matrix_test.dart new file mode 100644 index 000000000..d90540cb0 --- /dev/null +++ b/test/screens/sell/sell_responsive_matrix_test.dart @@ -0,0 +1,297 @@ +// Responsive matrix gate for SellView primary CTA. +// +// Proves the sell button stays fully tappable across the full device × +// text-scale matrix (see test/helper/responsive_matrix.dart). +// Regression lock for the IntrinsicHeight + Spacer collapse that pushed the +// CTA below the viewport on first frame without a RenderFlex overflow. +// +// Also proves the sticky actions stay above the soft keyboard via Scaffold's +// default resizeToAvoidBottomInset (viewInsets shrink the bounded height). +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/models/balance.dart'; +import 'package:realunit_wallet/packages/config/api_config.dart'; +import 'package:realunit_wallet/packages/repository/balance_repository.dart'; +import 'package:realunit_wallet/packages/repository/supported_fiat_repository.dart'; +import 'package:realunit_wallet/packages/service/app_store.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_bank_account_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_brokerbot_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_price_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/bank_account/bank_account.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_sell_payment_info_service.dart'; +import 'package:realunit_wallet/packages/utils/default_assets.dart'; +import 'package:realunit_wallet/packages/wallet/wallet.dart'; +import 'package:realunit_wallet/screens/sell/cubits/sell_balance/sell_balance_cubit.dart'; +import 'package:realunit_wallet/screens/sell/cubits/sell_converter/sell_converter_cubit.dart'; +import 'package:realunit_wallet/screens/sell/cubits/sell_payment_info/sell_payment_info_cubit.dart'; +import 'package:realunit_wallet/screens/sell/cubits/sell_selected_bank_account/sell_selected_bank_account_cubit.dart'; +import 'package:realunit_wallet/screens/sell/sell_page.dart'; +import 'package:realunit_wallet/styles/currency.dart'; +import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../helper/helper.dart'; + +class MockSellConverterCubit extends MockCubit implements SellConverterCubit {} + +class MockSellPaymentInfoCubit extends MockCubit + implements SellPaymentInfoCubit {} + +class MockSellSelectedBankAccountCubit extends MockCubit + implements SellSelectedBankAccountCubit {} + +class MockSellBalanceCubit extends MockCubit implements SellBalanceCubit {} + +class MockDfxBrokerbotService extends Mock implements DfxBrokerbotService {} + +class MockDfxBankAccountService extends Mock implements DfxBankAccountService {} + +class MockDfxPriceService extends Mock implements DFXPriceService {} + +class MockRealUnitSellPaymentInfoService extends Mock implements RealUnitSellPaymentInfoService {} + +class MockBalanceRepository extends Mock implements BalanceRepository {} + +class MockSupportedFiatRepository extends Mock implements SupportedFiatRepository {} + +class MockApiConfig extends Mock implements ApiConfig {} + +class MockAppStore extends Mock implements AppStore {} + +class MockWallet extends Mock implements SoftwareWallet {} + +void main() { + late SellConverterCubit converterCubit; + late SellPaymentInfoCubit sellPaymentInfoCubit; + late SellSelectedBankAccountCubit sellSelectedBankAccountCubit; + late SellBalanceCubit sellBalanceCubit; + + setUpAll(() { + registerFallbackValue( + Balance( + chainId: 1, + contractAddress: '0x0', + walletAddress: '0x0', + balance: BigInt.zero, + asset: realUnitAsset, + ), + ); + }); + + Future setupDependencyInjection() async { + SharedPreferences.setMockInitialValues({}); + final getIt = GetIt.instance; + final apiConfig = MockApiConfig(); + when(() => apiConfig.asset).thenReturn(realUnitAsset); + final appStore = MockAppStore(); + when(() => appStore.apiConfig).thenReturn(apiConfig); + when(() => appStore.wallet).thenReturn(MockWallet()); + when(() => appStore.primaryAddress).thenReturn('0x0000000000000000000000000000000000000000'); + getIt.registerSingleton(appStore); + getIt.registerSingleton(MockDfxBrokerbotService()); + getIt.registerSingleton(MockDfxBankAccountService()); + getIt.registerSingleton(MockDfxPriceService()); + getIt.registerSingleton(MockRealUnitSellPaymentInfoService()); + getIt.registerSingleton(await SharedPreferences.getInstance()); + final balanceRepository = MockBalanceRepository(); + when(() => balanceRepository.watchBalance(any())).thenAnswer( + (_) => Stream.value( + Balance( + chainId: 1, + contractAddress: '0x0', + walletAddress: '0x0', + balance: BigInt.zero, + asset: realUnitAsset, + ), + ), + ); + when(() => balanceRepository.saveBalance(any())).thenAnswer((_) async {}); + getIt.registerSingleton(balanceRepository); + + final fiatRepo = MockSupportedFiatRepository(); + when(() => fiatRepo.getSellable()).thenAnswer((_) async => const [Currency.chf]); + when(() => fiatRepo.getBuyable()).thenAnswer((_) async => const [Currency.chf, Currency.eur]); + when(() => fiatRepo.getAll()).thenAnswer((_) async => const [Currency.chf, Currency.eur]); + getIt.registerSingleton(fiatRepo); + } + + setUpAll(() async => await setupDependencyInjection()); + + tearDownAll(() async => await GetIt.instance.reset()); + + setUp(() { + converterCubit = MockSellConverterCubit(); + sellPaymentInfoCubit = MockSellPaymentInfoCubit(); + sellSelectedBankAccountCubit = MockSellSelectedBankAccountCubit(); + sellBalanceCubit = MockSellBalanceCubit(); + + when(() => converterCubit.state).thenReturn(const SellConverterState()); + when(() => sellPaymentInfoCubit.state).thenReturn(const SellPaymentInfoInitial()); + when( + () => sellPaymentInfoCubit.getPaymentInfo( + amount: any(named: 'amount'), + iban: any(named: 'iban'), + currency: Currency.chf, + ), + ).thenAnswer((_) => Future.value()); + when(() => sellSelectedBankAccountCubit.state).thenReturn(null); + when(() => sellBalanceCubit.state).thenReturn( + Balance( + chainId: 1, + contractAddress: '0x0', + walletAddress: '0x0', + balance: BigInt.zero, + asset: realUnitAsset, + ), + ); + }); + + /// Primary CTA state matching "enabled button when amount and bankaccount + /// are given". SellButton takes amount from the view controller, which is + /// synced only on loading true→false (see SellView listener) — so we emit + /// that transition with long amount strings as worst-case content. Tap then + /// only calls the stubbed getPaymentInfo (no router). + void stubPrimaryCtaState() { + whenListen( + converterCubit, + Stream.fromIterable([ + const SellConverterState( + fiatText: '999999999', + sharesText: '1234567.89', + loading: true, + ), + const SellConverterState( + fiatText: '999999999', + sharesText: '1234567.89', + loading: false, + ), + ]), + initialState: const SellConverterState( + fiatText: '999999999', + sharesText: '1234567.89', + loading: true, + ), + ); + when(() => converterCubit.state).thenReturn( + const SellConverterState(fiatText: '999999999', sharesText: '1234567.89'), + ); + when(() => sellSelectedBankAccountCubit.state).thenReturn( + const BankAccount(id: 1, iban: 'CH12 3456 7890 1234 5678 9'), + ); + when(() => sellPaymentInfoCubit.state).thenReturn(const SellPaymentInfoInitial()); + } + + Widget buildSubject() { + return MultiBlocProvider( + providers: [ + BlocProvider.value(value: converterCubit), + BlocProvider.value(value: sellPaymentInfoCubit), + BlocProvider.value(value: sellSelectedBankAccountCubit), + BlocProvider.value(value: sellBalanceCubit), + ], + child: const SellView(), + ); + } + + Future pumpScreen( + WidgetTester tester, + MatrixCell cell, + Widget child, { + MediaQueryData? mediaQueryOverride, + }) async { + final mediaQuery = mediaQueryOverride ?? cell.mediaQuery; + await tester.binding.setSurfaceSize(mediaQuery.size); + addTearDown(() async => await tester.binding.setSurfaceSize(null)); + await tester.pumpWidget( + MediaQuery( + data: mediaQuery, + child: MaterialApp( + locale: const Locale('de'), + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + supportedLocales: S.delegate.supportedLocales, + home: child, + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + // Settle the loading true→false emission so amount controllers sync. + await tester.pumpAndSettle(); + } + + group('SellView responsive matrix (full device × textScale)', () { + for (final cell in kFullResponsiveMatrix) { + testWidgets(cell.id, (tester) async { + await withTargetPlatform(cell.device.platform, () async { + stubPrimaryCtaState(); + + await expectNoLayoutOverflow( + tester, + () async { + await pumpScreen(tester, cell, buildSubject()); + }, + reason: 'overflow on ${cell.label}', + ); + + await expectFullyTappable( + tester, + find.byType(AppFilledButton), + within: find.byType(SellView), + reason: '${cell.label}: sell CTA not tappable', + ); + }); + }); + } + }); + + // Keyboard: Scaffold.resizeToAvoidBottomInset (default true) shrinks the + // body; ScrollableActionsLayout receives the reduced bounded height. Prove + // the CTA stays fully tappable with a simulated open keyboard. + testWidgets( + 'KEYBOARD: iPhone SE textScale 1.0 — CTA tappable with viewInsets keyboard', + (tester) async { + final cell = MatrixCell( + kIosDeviceProfiles.firstWhere((d) => d.id == 'iphone_se_3'), + 1.0, + ); + final keyboardMediaQuery = cell.mediaQuery.copyWith( + viewInsets: const EdgeInsets.only(bottom: 336), + ); + + await withTargetPlatform(cell.device.platform, () async { + stubPrimaryCtaState(); + + await expectNoLayoutOverflow( + tester, + () async { + await pumpScreen( + tester, + cell, + buildSubject(), + mediaQueryOverride: keyboardMediaQuery, + ); + }, + reason: 'overflow with keyboard on ${cell.label}', + ); + + await expectFullyTappable( + tester, + find.byType(AppFilledButton), + within: find.byType(SellView), + reason: 'sell CTA not tappable above the keyboard (viewInsets.bottom=336)', + ); + }); + }, + ); +} diff --git a/test/screens/sell/sell_sheets_responsive_matrix_test.dart b/test/screens/sell/sell_sheets_responsive_matrix_test.dart new file mode 100644 index 000000000..75274ea73 --- /dev/null +++ b/test/screens/sell/sell_sheets_responsive_matrix_test.dart @@ -0,0 +1,267 @@ +// Responsive matrix gate for SellConfirmSheetView and SellExecutedSheet CTAs. +// +// Proves both sell bottom sheets stay fully tappable and overflow-free across +// the full device × text-scale matrix (see test/helper/responsive_matrix.dart). +// Regression lock for: +// - CTA clipped out of the hit-test region at large system text scale (no red +// overflow stripe in release builds). +// - Horizontal RenderFlex overflow of the IBAN row in the confirm info card. +// - SellExecutedSheet shown without isScrollControlled (modal height clamped +// to 9/16 of the screen) — the dedicated test below documents/demonstrates +// why sell_button.dart needs isScrollControlled: true by reproducing the +// underlying Flutter clamp behavior in isolation. It does NOT exercise the +// real SellButton widget, so it cannot detect a regression at that call site +// itself — only the behavior the call site depends on. The matrix loops +// always pump with isScrollControlled: true and do not cover this case. +// +// Matrix sheets are pumped via a real showModalBottomSheet(isScrollControlled: +// true) so the screen-height bound production imposes is reproduced. +// pumpClippedSheet is intentionally not used — it does not reproduce that +// modal constraint. +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/sell/dto/eip7702/eip7702_data_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/sell/dto/real_unit_sell_payment_info_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/sell/sell_payment_info.dart'; +import 'package:realunit_wallet/screens/sell/cubits/sell_confirm/sell_confirm_cubit.dart'; +import 'package:realunit_wallet/screens/sell/widgets/sell_confirm_sheet.dart'; +import 'package:realunit_wallet/screens/sell/widgets/sell_executed_sheet.dart'; +import 'package:realunit_wallet/styles/currency.dart'; +import 'package:realunit_wallet/styles/themes.dart'; +import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart'; + +import '../../helper/helper.dart'; + +class _MockSellConfirmCubit extends MockCubit implements SellConfirmCubit {} + +class _FakeSellPaymentInfo extends Fake implements SellPaymentInfo {} + +/// Worst-case-but-realistic confirm payload: long IBAN forces the unfixed +/// spaceBetween Row to overflow on every matrix device width; amounts stay +/// plausible so they do not gratuitously blow the other info rows. +SellPaymentInfo _worstCasePaymentInfo() => const SellPaymentInfo( + id: 42, + eip7702: Eip7702Data( + relayerAddress: '0x1', + delegationManagerAddress: '0x2', + delegatorAddress: '0x3', + userNonce: 0, + domain: Eip7702Domain( + name: 'RealUnit', + version: '1', + chainId: 1, + verifyingContract: '0x4', + ), + types: Eip7702Types(delegation: [], caveat: []), + message: Eip7702Message( + delegate: '0x5', + delegator: '0x6', + authority: '0x7', + caveats: [], + salt: 0, + ), + tokenAddress: '0x8', + amountWei: '0', + depositAddress: '0x9', + ), + amount: 12345, + exchangeRate: 1.0, + rate: 1.0, + // Two standard Swiss IBANs concatenated — long enough to overflow the + // unfixed receiver Row on every matrix width, realistic character set. + beneficiary: BeneficiaryDto( + iban: 'CH93 ACCT-000017 CH93 ACCT-000017', + ), + estimatedAmount: 12345.67, + currency: Currency.chf, + depositAddress: '0xA', + tokenAddress: '0xB', + chainId: 1, + ethBalance: 0.01, + requiredGasEth: 0.001, +); + +void main() { + late _MockSellConfirmCubit confirmCubit; + + setUpAll(() { + registerFallbackValue(_FakeSellPaymentInfo()); + }); + + setUp(() { + confirmCubit = _MockSellConfirmCubit(); + when(() => confirmCubit.state).thenReturn(SellConfirmInitial()); + whenListen( + confirmCubit, + const Stream.empty(), + initialState: SellConfirmInitial(), + ); + when(() => confirmCubit.confirmPayment(any())).thenAnswer((_) async {}); + }); + + /// Pumps a sheet the way production shows it: real [showModalBottomSheet] + /// with [isScrollControlled] matching the fixed call site (true for both). + Future pumpModalSheet( + WidgetTester tester, + MatrixCell cell, { + required bool isScrollControlled, + required WidgetBuilder sheetBuilder, + }) async { + await tester.binding.setSurfaceSize(cell.device.size); + addTearDown(() async => await tester.binding.setSurfaceSize(null)); + + final router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (context, _) => Scaffold( + body: Center( + child: ElevatedButton( + onPressed: () { + showModalBottomSheet( + isScrollControlled: isScrollControlled, + context: context, + builder: sheetBuilder, + ); + }, + child: const Text('open-sheet'), + ), + ), + ), + ), + ], + ); + addTearDown(router.dispose); + + await tester.pumpWidget( + MediaQuery( + data: cell.mediaQuery, + child: MaterialApp.router( + theme: realUnitTheme, + locale: const Locale('de'), + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + supportedLocales: S.delegate.supportedLocales, + routerConfig: router, + ), + ), + ); + await tester.pump(); + + await tester.tap(find.text('open-sheet')); + await tester.pumpAndSettle(); + } + + group('SellConfirmSheetView responsive matrix (full device × textScale)', () { + for (final cell in kFullResponsiveMatrix) { + testWidgets(cell.id, (tester) async { + await withTargetPlatform(cell.device.platform, () async { + await expectNoLayoutOverflow( + tester, + () async { + await pumpModalSheet( + tester, + cell, + isScrollControlled: true, + sheetBuilder: (_) => BlocProvider.value( + value: confirmCubit, + child: SellConfirmSheetView(paymentInfo: _worstCasePaymentInfo()), + ), + ); + }, + reason: 'overflow on SellConfirmSheetView / ${cell.label}', + ); + + await expectFullyTappable( + tester, + find.byType(AppFilledButton), + within: find.byType(SellConfirmSheetView), + reason: 'SellConfirmSheetView / ${cell.label}: CTA not tappable', + ); + }); + }); + } + }); + + group('SellExecutedSheet responsive matrix (full device × textScale)', () { + for (final cell in kFullResponsiveMatrix) { + testWidgets(cell.id, (tester) async { + await withTargetPlatform(cell.device.platform, () async { + await expectNoLayoutOverflow( + tester, + () async { + await pumpModalSheet( + tester, + cell, + isScrollControlled: true, + sheetBuilder: (_) => const SellExecutedSheet(), + ); + }, + reason: 'overflow on SellExecutedSheet / ${cell.label}', + ); + + await expectFullyTappable( + tester, + find.byType(AppFilledButton), + within: find.byType(SellExecutedSheet), + reason: 'SellExecutedSheet / ${cell.label}: CTA not tappable', + ); + }); + }); + } + + // Documents why sell_button.dart needs isScrollControlled: true when showing + // SellExecutedSheet: pumps with false (Flutter's default) and asserts the + // 9/16 clamp. Does not instantiate SellButton, so it cannot catch a call-site + // regression there — only the underlying Flutter behavior the flag avoids. + testWidgets( + 'SellExecutedSheet without isScrollControlled=true reproduces the 9/16 clamp bug (iphone_se_3@2.0x, de)', + (tester) async { + final cell = MatrixCell( + kIosDeviceProfiles.firstWhere((d) => d.id == 'iphone_se_3'), + 2.0, + ); + + await withTargetPlatform(cell.device.platform, () async { + await pumpModalSheet( + tester, + cell, + isScrollControlled: false, + sheetBuilder: (_) => const SellExecutedSheet(), + ); + + // Without isScrollControlled: true, Flutter clamps the modal to 9/16 of the + // available height regardless of content, so SellExecutedSheet's content + // genuinely overflows at this small-device / high-text-scale cell. This is + // the exact regression the call-site fix in sell_button.dart guards against. + final exception = tester.takeException(); + expect( + exception, + isNotNull, + reason: 'Expected SellExecutedSheet to overflow / fail to lay out under the ' + '9/16 modal clamp (isScrollControlled: false) at ${cell.label}. If this ' + 'no longer fails, either the clamp reproduction stopped working or the ' + 'widget silently degrades instead of failing loudly — investigate before ' + 'weakening this assertion.', + ); + expect( + exception.toString(), + contains('overflowed'), + reason: 'Expected the captured exception to be the RenderFlex overflow caused ' + 'by the 9/16 clamp, not some unrelated failure.', + ); + }); + }, + ); + }); +} diff --git a/test/screens/settings_user_data/settings_user_data_responsive_matrix_test.dart b/test/screens/settings_user_data/settings_user_data_responsive_matrix_test.dart new file mode 100644 index 000000000..425e2f21c --- /dev/null +++ b/test/screens/settings_user_data/settings_user_data_responsive_matrix_test.dart @@ -0,0 +1,467 @@ +// Responsive matrix gate for the five settings/user-data edit & status pages. +// +// Proves every save/refresh CTA stays tappable and no layout overflows across +// the full device x text-scale matrix (see test/helper/responsive_matrix.dart), +// after migrating these pages from `SingleChildScrollView + Spacer + CTA +// inside the scroll view` (or a bare `Column(Spacer(), ..., Spacer())` with no +// scroll view at all) to `ScrollableActionsLayout`. This is the regression +// lock for: (1) the save CTA dropping below the fold on the three edit-form +// pages, (2) the refresh CTA overflowing off-screen on the two status pages, +// (3) the horizontal RenderFlex overflow inside `FilePickerField`, and (4) the +// sticky action block staying above the soft keyboard on the form pages. +import 'dart:io'; + +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_country_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_kyc_service.dart'; +import 'package:realunit_wallet/screens/settings_user_data/subpages/edit_address/cubit/settings_edit_address_cubit.dart'; +import 'package:realunit_wallet/screens/settings_user_data/subpages/edit_address/settings_edit_address_page.dart'; +import 'package:realunit_wallet/screens/settings_user_data/subpages/edit_name/cubit/settings_edit_name_cubit.dart'; +import 'package:realunit_wallet/screens/settings_user_data/subpages/edit_name/settings_edit_name_page.dart'; +import 'package:realunit_wallet/screens/settings_user_data/subpages/edit_phone_number/cubit/settings_edit_phone_number_cubit.dart'; +import 'package:realunit_wallet/screens/settings_user_data/subpages/edit_phone_number/settings_edit_phone_number_page.dart'; +import 'package:realunit_wallet/screens/settings_user_data/subpages/others/settings_edit_failure_page.dart'; +import 'package:realunit_wallet/screens/settings_user_data/subpages/others/settings_edit_pending_page.dart'; +import 'package:realunit_wallet/styles/themes.dart'; +import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart'; +import 'package:realunit_wallet/widgets/form/file_picker_field.dart'; + +import '../../helper/helper.dart'; + +class MockSettingsEditAddressCubit extends MockCubit + implements SettingsEditAddressCubit {} + +class MockSettingsEditNameCubit extends MockCubit + implements SettingsEditNameCubit {} + +class MockSettingsEditPhoneNumberCubit extends MockCubit + implements SettingsEditPhoneNumberCubit {} + +class MockDfxKycService extends Mock implements DfxKycService {} + +/// A long, realistic file name -- stresses the `selectedFile!.name` row of +/// [FilePickerField] (used by the name and address pages). +const _longFileName = + 'Nachweisdokument_Wohnsitzbestaetigung_Frontseite_und_Rueckseite_gescannt_hochaufloesend.jpg'; + +/// A long, realistic German API error -- stresses the phone page's inline +/// failure text. +const _longApiError = + 'Die Verbindung zum Server wurde unerwartet unterbrochen, bevor die ' + 'eingegebene Telefonnummer verifiziert werden konnte. Bitte ueberpruefen ' + 'Sie Ihre Internetverbindung und versuchen Sie es in wenigen Minuten ' + 'erneut.'; + +/// The short error from the original bug report ("breaks at 3.0 already with +/// a short error"). +const _shortApiError = 'Ungueltige Anfrage.'; + +/// Pumps [child] as a full-page host (real app theme + localizations) under +/// [mediaQuery]. Mirrors `pumpClippedSheet` (test/helper/layout_assertions.dart) +/// for non-sheet, full-screen pages. +Future _pumpPage( + WidgetTester tester, { + required Widget child, + required MediaQueryData mediaQuery, + Locale locale = const Locale('de'), +}) async { + await tester.binding.setSurfaceSize(mediaQuery.size); + addTearDown(() async { + await tester.binding.setSurfaceSize(null); + }); + + await tester.pumpWidget( + MediaQuery( + data: mediaQuery, + child: MaterialApp( + theme: realUnitTheme, + locale: locale, + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + supportedLocales: S.delegate.supportedLocales, + home: child, + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); +} + +void main() { + setUpAll(() { + final getIt = GetIt.instance; + getIt.registerSingleton(MockDfxKycService()); + getIt.registerSingleton(fixtureCountryService()); + }); + + group('$SettingsEditAddressView responsive matrix', () { + late MockSettingsEditAddressCubit cubit; + + setUp(() { + cubit = MockSettingsEditAddressCubit(); + when(() => cubit.state).thenReturn(const SettingsEditAddressReady('url')); + when(() => cubit.refresh()).thenReturn(null); + when( + () => cubit.submitAddress( + street: any(named: 'street'), + houseNumber: any(named: 'houseNumber'), + zip: any(named: 'zip'), + city: any(named: 'city'), + countryId: any(named: 'countryId'), + fileBase64: any(named: 'fileBase64'), + fileName: any(named: 'fileName'), + ), + ).thenAnswer((_) async {}); + }); + + Future pumpPage(WidgetTester tester, MediaQueryData mediaQuery) => _pumpPage( + tester, + child: BlocProvider.value( + value: cubit, + child: const SettingsEditAddressView(), + ), + mediaQuery: mediaQuery, + ); + + for (final cell in kFullResponsiveMatrix) { + testWidgets('Ready . ${cell.id}', (tester) async { + await withTargetPlatform(cell.device.platform, () async { + await expectNoLayoutOverflow( + tester, + () => pumpPage(tester, cell.mediaQuery), + reason: 'overflow on address Ready / ${cell.label}', + ); + + await expectFullyTappable( + tester, + find.byType(AppFilledButton), + within: find.byType(SettingsEditAddressView), + reason: '${cell.label}: save CTA not tappable', + ); + }); + }); + } + + // Keyboard proof: Scaffold.resizeToAvoidBottomInset defaults to true and + // is never overridden in this page, so it already shrinks the bounded + // height ScrollableActionsLayout receives and zeroes descendant + // viewInsets -- no manual keyboard padding is needed. Proven, not + // asserted: pump a real open-keyboard MediaQuery and check the CTA. + testWidgets('keyboard open (viewInsets.bottom: 336) keeps save CTA tappable', (tester) async { + final cell = MatrixCell( + kIosDeviceProfiles.firstWhere((d) => d.id == 'iphone_se_3'), + 1.0, + ); + final keyboardMediaQuery = cell.mediaQuery.copyWith( + viewInsets: const EdgeInsets.only(bottom: 336), + ); + + await withTargetPlatform(cell.device.platform, () async { + await expectNoLayoutOverflow(tester, () => pumpPage(tester, keyboardMediaQuery)); + + await expectFullyTappable( + tester, + find.byType(AppFilledButton), + within: find.byType(SettingsEditAddressView), + reason: 'save CTA hidden or unreachable behind the open keyboard', + ); + }); + }); + }); + + group('$SettingsEditNameView responsive matrix', () { + late MockSettingsEditNameCubit cubit; + + setUp(() { + cubit = MockSettingsEditNameCubit(); + when(() => cubit.state).thenReturn(const SettingsEditNameReady('url')); + when(() => cubit.refresh()).thenReturn(null); + when( + () => cubit.submitName( + firstName: any(named: 'firstName'), + lastName: any(named: 'lastName'), + fileBase64: any(named: 'fileBase64'), + fileName: any(named: 'fileName'), + ), + ).thenAnswer((_) async {}); + }); + + Future pumpPage(WidgetTester tester, MediaQueryData mediaQuery) => _pumpPage( + tester, + child: BlocProvider.value( + value: cubit, + child: const SettingsEditNameView(), + ), + mediaQuery: mediaQuery, + ); + + for (final cell in kFullResponsiveMatrix) { + testWidgets('Ready . ${cell.id}', (tester) async { + await withTargetPlatform(cell.device.platform, () async { + await expectNoLayoutOverflow( + tester, + () => pumpPage(tester, cell.mediaQuery), + reason: 'overflow on name Ready / ${cell.label}', + ); + + await expectFullyTappable( + tester, + find.byType(AppFilledButton), + within: find.byType(SettingsEditNameView), + reason: '${cell.label}: save CTA not tappable', + ); + }); + }); + } + + testWidgets('keyboard open (viewInsets.bottom: 336) keeps save CTA tappable', (tester) async { + final cell = MatrixCell( + kIosDeviceProfiles.firstWhere((d) => d.id == 'iphone_se_3'), + 1.0, + ); + final keyboardMediaQuery = cell.mediaQuery.copyWith( + viewInsets: const EdgeInsets.only(bottom: 336), + ); + + await withTargetPlatform(cell.device.platform, () async { + await expectNoLayoutOverflow(tester, () => pumpPage(tester, keyboardMediaQuery)); + + await expectFullyTappable( + tester, + find.byType(AppFilledButton), + within: find.byType(SettingsEditNameView), + reason: 'save CTA hidden or unreachable behind the open keyboard', + ); + }); + }); + }); + + group('$SettingsEditPhoneNumberView responsive matrix', () { + late MockSettingsEditPhoneNumberCubit cubit; + + void stub(SettingsEditPhoneNumberState state) { + cubit = MockSettingsEditPhoneNumberCubit(); + when(() => cubit.state).thenReturn(state); + when(() => cubit.editPhoneNumber(any())).thenAnswer((_) async {}); + } + + Future pumpPage(WidgetTester tester, MediaQueryData mediaQuery) => _pumpPage( + tester, + child: BlocProvider.value( + value: cubit, + child: const SettingsEditPhoneNumberView(), + ), + mediaQuery: mediaQuery, + ); + + for (final cell in kFullResponsiveMatrix) { + testWidgets('Initial . ${cell.id}', (tester) async { + stub(const SettingsEditPhoneNumberInitial()); + await withTargetPlatform(cell.device.platform, () async { + await expectNoLayoutOverflow( + tester, + () => pumpPage(tester, cell.mediaQuery), + reason: 'overflow on phone Initial / ${cell.label}', + ); + + await expectFullyTappable( + tester, + find.byType(AppFilledButton), + within: find.byType(SettingsEditPhoneNumberView), + reason: '${cell.label}: save CTA not tappable', + ); + }); + }); + + testWidgets('long API error . ${cell.id}', (tester) async { + stub(const SettingsEditPhoneNumberFailure(_longApiError)); + await withTargetPlatform(cell.device.platform, () async { + await expectNoLayoutOverflow( + tester, + () => pumpPage(tester, cell.mediaQuery), + reason: 'overflow on phone long API error / ${cell.label}', + ); + + await expectFullyTappable( + tester, + find.byType(AppFilledButton), + within: find.byType(SettingsEditPhoneNumberView), + reason: '${cell.label}: save CTA not tappable with a long API error shown', + ); + }); + }); + } + + // REGRESSION: exact reported break (iPhone SE, textScale 3.0, SHORT error). + testWidgets( + 'REGRESSION: iPhone SE + textScale 3.0 with short error still tappable', + (tester) async { + stub(const SettingsEditPhoneNumberFailure(_shortApiError)); + final cell = MatrixCell( + kIosDeviceProfiles.firstWhere((d) => d.id == 'iphone_se_3'), + 3.0, + ); + + await withTargetPlatform(cell.device.platform, () async { + await expectNoLayoutOverflow(tester, () => pumpPage(tester, cell.mediaQuery)); + + await expectFullyTappable( + tester, + find.byType(AppFilledButton), + within: find.byType(SettingsEditPhoneNumberView), + ); + }); + }, + ); + + testWidgets('keyboard open (viewInsets.bottom: 336) keeps save CTA tappable', (tester) async { + stub(const SettingsEditPhoneNumberInitial()); + final cell = MatrixCell( + kIosDeviceProfiles.firstWhere((d) => d.id == 'iphone_se_3'), + 1.0, + ); + final keyboardMediaQuery = cell.mediaQuery.copyWith( + viewInsets: const EdgeInsets.only(bottom: 336), + ); + + await withTargetPlatform(cell.device.platform, () async { + await expectNoLayoutOverflow(tester, () => pumpPage(tester, keyboardMediaQuery)); + + await expectFullyTappable( + tester, + find.byType(AppFilledButton), + within: find.byType(SettingsEditPhoneNumberView), + reason: 'save CTA hidden or unreachable behind the open keyboard', + ); + }); + }); + }); + + group('$SettingsEditPendingPage responsive matrix', () { + for (final cell in kFullResponsiveMatrix) { + testWidgets(cell.id, (tester) async { + await withTargetPlatform(cell.device.platform, () async { + await expectNoLayoutOverflow( + tester, + () => _pumpPage( + tester, + child: SettingsEditPendingPage( + title: 'Adresse aendern', + onRefresh: () {}, + ), + mediaQuery: cell.mediaQuery, + ), + reason: 'overflow on pending page / ${cell.label}', + ); + + await expectFullyTappable( + tester, + find.byType(AppFilledButton), + within: find.byType(SettingsEditPendingPage), + reason: '${cell.label}: refresh CTA not tappable', + ); + }); + }); + } + }); + + group('$SettingsEditFailurePage responsive matrix', () { + for (final cell in kFullResponsiveMatrix) { + testWidgets(cell.id, (tester) async { + await withTargetPlatform(cell.device.platform, () async { + await expectNoLayoutOverflow( + tester, + () => _pumpPage( + tester, + child: SettingsEditFailurePage( + _longApiError, + title: 'Adresse aendern', + onRefresh: () {}, + ), + mediaQuery: cell.mediaQuery, + ), + reason: 'overflow on failure page / ${cell.label}', + ); + + await expectFullyTappable( + tester, + find.byType(AppFilledButton), + within: find.byType(SettingsEditFailurePage), + reason: '${cell.label}: refresh CTA not tappable', + ); + }); + }); + } + }); + + group('$FilePickerField long file name (used by name & address pages)', () { + // FilePickerField is one shared widget/class embedded verbatim by both + // pages, so its Row-overflow bug and fix are identical regardless of + // which page hosts it. `_selectedFile` is private State on both pages + // with no external setter, and the real image_picker flow is a platform + // channel -- so the "selected file" row is exercised by mounting + // FilePickerField directly, across a representative narrow-device + // subset (not the full 35-cell matrix -- disproportionate for one + // simple, shared widget). + // + // XFile.name on the io platform (what `flutter test` runs under) is + // ALWAYS the path's basename -- the `name:` constructor parameter is + // silently ignored (see package:cross_file's io implementation). So to + // get a long `.name`, the file on disk must actually be named that; to + // keep `Image.file` decoding real bytes (no decode-error noise), copy an + // existing repo asset's bytes into a long-named file in a fresh temp + // dir at test time -- nothing new is added to the repo. + late Directory tempDir; + late File longNameFile; + + setUpAll(() async { + tempDir = await Directory.systemTemp.createTemp('file_picker_field_test_'); + longNameFile = File('${tempDir.path}${Platform.pathSeparator}$_longFileName'); + final bytes = await File('assets/icons/realunit_wallet_logo_full.png').readAsBytes(); + await longNameFile.writeAsBytes(bytes); + }); + + tearDownAll(() async { + if (await tempDir.exists()) await tempDir.delete(recursive: true); + }); + + final narrowDevices = [ + kIosDeviceProfiles.firstWhere((d) => d.id == 'iphone_se_3'), + kAndroidDeviceProfiles.firstWhere((d) => d.id == 'android_small'), + ]; + + for (final cell in buildResponsiveMatrix(devices: narrowDevices)) { + testWidgets(cell.id, (tester) async { + await withTargetPlatform(cell.device.platform, () async { + await expectNoLayoutOverflow( + tester, + () => _pumpPage( + tester, + child: Scaffold( + body: FilePickerField( + label: 'Nachweis-Dokument', + selectedFile: XFile(longNameFile.path), + onTap: () {}, + ), + ), + mediaQuery: cell.mediaQuery, + ), + reason: 'overflow on FilePickerField long file name / ${cell.label}', + ); + }); + }); + } + }); +} diff --git a/test/screens/support/support_create_ticket_responsive_matrix_test.dart b/test/screens/support/support_create_ticket_responsive_matrix_test.dart new file mode 100644 index 000000000..dde02ef68 --- /dev/null +++ b/test/screens/support/support_create_ticket_responsive_matrix_test.dart @@ -0,0 +1,177 @@ +// Responsive matrix gate for SupportCreateTicketView CTA. +// +// Proves the "Senden" button stays fully tappable across the full device × +// text-scale matrix when a long German message expands the multiline field. +// Regression lock for the iPhone SE + textScale 2.0 bug where the CTA was +// scrolled below the fold inside the legacy Spacer layout. +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/support/support_issue_reason.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/support/support_issue_type.dart'; +import 'package:realunit_wallet/screens/support/cubits/support_create_ticket/support_create_ticket_cubit.dart'; +import 'package:realunit_wallet/screens/support/cubits/support_create_ticket/support_create_ticket_state.dart'; +import 'package:realunit_wallet/screens/support/subpages/support_create_ticket_page.dart'; +import 'package:realunit_wallet/styles/themes.dart'; +import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart'; + +import '../../helper/helper.dart'; + +class _MockSupportCreateTicketCubit extends MockCubit + implements SupportCreateTicketCubit {} + +/// Realistic long German support message that expands the multiline TextField +/// (maxLines: 5) the way a real user report would. +const _longGermanMessage = + 'Guten Tag, ich habe ein Problem mit meiner letzten Transaktion vom 12. März. ' + 'Der Betrag wurde von meinem Bankkonto abgebucht, erscheint aber nicht in der ' + 'Wallet und der Status bleibt seit mehreren Tagen auf „in Bearbeitung“. ' + 'Können Sie bitte prüfen, was mit der Zahlung passiert ist und mir den ' + 'aktuellen Stand mitteilen? Vielen Dank im Voraus.'; + +const _submittableState = SupportCreateTicketState( + selectedType: SupportIssueType.genericIssue, + selectedReason: SupportIssueReason.other, + message: 'Ich habe ein Problem mit meiner Transaktion.', +); + +Future _pumpScreen( + WidgetTester tester, + Widget widget, + MediaQueryData mediaQuery, +) async { + await tester.binding.setSurfaceSize(mediaQuery.size); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget( + MediaQuery( + data: mediaQuery, + child: MaterialApp( + theme: realUnitTheme, + locale: const Locale('de'), + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + supportedLocales: S.delegate.supportedLocales, + home: widget, + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); +} + +void main() { + late _MockSupportCreateTicketCubit cubit; + + setUp(() { + cubit = _MockSupportCreateTicketCubit(); + when(() => cubit.state).thenReturn(_submittableState); + whenListen( + cubit, + const Stream.empty(), + initialState: _submittableState, + ); + when(() => cubit.submit()).thenAnswer((_) async {}); + when(() => cubit.updateMessage(any())).thenReturn(null); + }); + + Widget buildSubject() => BlocProvider.value( + value: cubit, + child: const SupportCreateTicketView(), + ); + + group('SupportCreateTicketView responsive matrix (full device × textScale)', () { + for (final cell in kFullResponsiveMatrix) { + testWidgets(cell.id, (tester) async { + await withTargetPlatform(cell.device.platform, () async { + await expectNoLayoutOverflow( + tester, + () async { + await _pumpScreen(tester, buildSubject(), cell.mediaQuery); + await tester.enterText(find.byType(TextField), _longGermanMessage); + await tester.pump(); + }, + reason: 'overflow on ${cell.label}', + ); + + await expectFullyTappable( + tester, + find.byType(AppFilledButton), + within: find.byType(SupportCreateTicketView), + reason: '${cell.label}: CTA not tappable', + ); + }); + }); + } + }); + + // Focused regression: iPhone SE 375×667 DE + textScale 2.0 — proven fail + // pre-fix when a long message expands the multiline field. + testWidgets( + 'REGRESSION: iPhone SE DE textScale 2.0 long message — CTA tappable and tap submits', + (tester) async { + final cell = MatrixCell( + kIosDeviceProfiles.firstWhere((d) => d.id == 'iphone_se_3'), + 2.0, + ); + await withTargetPlatform(cell.device.platform, () async { + await _pumpScreen(tester, buildSubject(), cell.mediaQuery); + await tester.enterText(find.byType(TextField), _longGermanMessage); + await tester.pump(); + + await expectFullyTappable( + tester, + find.byType(AppFilledButton), + within: find.byType(SupportCreateTicketView), + ); + verify(() => cubit.submit()).called(1); + }); + }, + ); + + // Keyboard: Scaffold.resizeToAvoidBottomInset (default true) shrinks the + // body; ScrollableActionsLayout receives the reduced bounded height. Prove + // the CTA stays fully tappable with a simulated open keyboard. + testWidgets( + 'KEYBOARD: iPhone SE textScale 1.3 — CTA tappable with viewInsets keyboard', + (tester) async { + final cell = MatrixCell( + kIosDeviceProfiles.firstWhere((d) => d.id == 'iphone_se_3'), + 1.3, + ); + final keyboardMediaQuery = cell.mediaQuery.copyWith( + viewInsets: const EdgeInsets.only(bottom: 336), + ); + + await withTargetPlatform(cell.device.platform, () async { + await expectNoLayoutOverflow( + tester, + () async { + await _pumpScreen(tester, buildSubject(), cell.mediaQuery); + await tester.enterText(find.byType(TextField), _longGermanMessage); + await tester.pump(); + // Re-pump same tree with keyboard open (viewInsets). + await _pumpScreen(tester, buildSubject(), keyboardMediaQuery); + await tester.enterText(find.byType(TextField), _longGermanMessage); + await tester.pump(); + }, + reason: 'overflow with keyboard on ${cell.label}', + ); + + await expectFullyTappable( + tester, + find.byType(AppFilledButton), + within: find.byType(SupportCreateTicketView), + reason: '${cell.label} + keyboard: CTA not tappable', + ); + }); + }, + ); +} diff --git a/test/screens/verify_seed/verify_seed_responsive_matrix_test.dart b/test/screens/verify_seed/verify_seed_responsive_matrix_test.dart new file mode 100644 index 000000000..fbc9cfa69 --- /dev/null +++ b/test/screens/verify_seed/verify_seed_responsive_matrix_test.dart @@ -0,0 +1,117 @@ +// Responsive matrix gate for VerifySeedView confirm CTA. +// +// Proves the seed-verify confirm button stays fully tappable across the full +// device × text-scale matrix (see test/helper/responsive_matrix.dart). +// Regression lock for the IntrinsicHeight + Spacer collapse that pushed the +// CTA below the viewport on first frame without a RenderFlex overflow. +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/packages/service/wallet_service.dart'; +import 'package:realunit_wallet/screens/home/bloc/home_bloc.dart'; +import 'package:realunit_wallet/screens/verify_seed/cubit/verify_seed_cubit.dart'; +import 'package:realunit_wallet/screens/verify_seed/verify_seed_page.dart'; +import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart'; + +import '../../helper/helper.dart'; + +class MockVerifySeedCubit extends MockCubit implements VerifySeedCubit {} + +class MockHomeBloc extends MockBloc implements HomeBloc {} + +class MockWalletService extends Mock implements WalletService {} + +/// Realistic long DE recovery words for worst-case field content. +const _enteredWords = ['tapfer', 'gewitter', 'monster', 'sonnenschein']; + +void main() { + late VerifySeedCubit verifySeedCubit; + late HomeBloc homeBloc; + + void setupDependencyInjection() { + final getIt = GetIt.instance; + getIt.registerSingleton(MockWalletService()); + } + + setUpAll(setupDependencyInjection); + + tearDownAll(() async => await GetIt.instance.reset()); + + setUp(() { + verifySeedCubit = MockVerifySeedCubit(); + homeBloc = MockHomeBloc(); + + const state = VerifySeedState( + wordIndices: [1, 3, 5, 7], + enteredWords: _enteredWords, + ); + when(() => verifySeedCubit.state).thenReturn(state); + whenListen( + verifySeedCubit, + const Stream.empty(), + initialState: state, + ); + when(() => verifySeedCubit.verify()).thenAnswer((_) async => true); + }); + + Widget buildSubject() { + return MultiBlocProvider( + providers: [ + BlocProvider.value(value: homeBloc), + BlocProvider.value(value: verifySeedCubit), + ], + child: const VerifySeedView(), + ); + } + + Future pumpScreen(WidgetTester tester, MatrixCell cell, Widget child) async { + await tester.binding.setSurfaceSize(cell.mediaQuery.size); + addTearDown(() async => await tester.binding.setSurfaceSize(null)); + await tester.pumpWidget( + MediaQuery( + data: cell.mediaQuery, + child: MaterialApp( + locale: const Locale('de'), + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + supportedLocales: S.delegate.supportedLocales, + home: child, + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + } + + group('VerifySeedView responsive matrix (full device × textScale)', () { + for (final cell in kFullResponsiveMatrix) { + testWidgets(cell.id, (tester) async { + await withTargetPlatform(cell.device.platform, () async { + await expectNoLayoutOverflow( + tester, + () async { + await pumpScreen(tester, cell, buildSubject()); + }, + reason: 'overflow on ${cell.label}', + ); + + await expectFullyTappable( + tester, + find.byType(AppFilledButton), + within: find.byType(VerifySeedView), + reason: '${cell.label}: verify-seed CTA not tappable', + ); + }); + }); + } + }); +} diff --git a/test/setup/di_test.dart b/test/setup/di_test.dart new file mode 100644 index 000000000..e122fab35 --- /dev/null +++ b/test/setup/di_test.dart @@ -0,0 +1,139 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/packages/storage/secure_storage.dart'; +import 'package:realunit_wallet/setup/di.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _MockFlutterSecureStorage extends Mock implements FlutterSecureStorage {} + +// The SecureStorage keys migrateSecurityFlags moves the flags into. These mirror +// the private constants in SecureStorage; asserting the literal keys pins the +// migration contract — a rename here is a storage-migration event, not a +// refactor, so the test is meant to break if the target key ever changes. +const _biometricEnabledKey = 'biometric.enabled'; +const _pinFailedAttemptsKey = 'pin.failedAttempts'; +const _pinLockedUntilKey = 'pin.lockedUntil'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late _MockFlutterSecureStorage mockStorage; + late SecureStorage secureStorage; + + setUp(() { + mockStorage = _MockFlutterSecureStorage(); + secureStorage = SecureStorage.withStorage(mockStorage); + when(() => mockStorage.write(key: any(named: 'key'), value: any(named: 'value'))) + .thenAnswer((_) async {}); + }); + + group('migrateSecurityFlags', () { + test('moves a stored biometric flag into secure storage and clears the pref', () async { + SharedPreferences.setMockInitialValues({'isBiometricEnabled': true}); + final prefs = await SharedPreferences.getInstance(); + + await migrateSecurityFlags(prefs, secureStorage); + + verify(() => mockStorage.write(key: _biometricEnabledKey, value: 'true')).called(1); + expect(prefs.getBool('isBiometricEnabled'), isNull); + }); + + test('moves the PIN lockout attempt counter and clears the pref', () async { + SharedPreferences.setMockInitialValues({'pinFailedAttempts': 3}); + final prefs = await SharedPreferences.getInstance(); + + await migrateSecurityFlags(prefs, secureStorage); + + verify(() => mockStorage.write(key: _pinFailedAttemptsKey, value: '3')).called(1); + expect(prefs.getInt('pinFailedAttempts'), isNull); + }); + + test('moves a valid lockout timestamp and clears the pref', () async { + final until = DateTime.utc(2030, 1, 2, 3, 4, 5); + SharedPreferences.setMockInitialValues({'pinLockedUntil': until.toIso8601String()}); + final prefs = await SharedPreferences.getInstance(); + + await migrateSecurityFlags(prefs, secureStorage); + + verify(() => mockStorage.write(key: _pinLockedUntilKey, value: until.toIso8601String())) + .called(1); + expect(prefs.getString('pinLockedUntil'), isNull); + }); + + test('drops an unparseable lockout timestamp without writing it but still clears the pref', + () async { + SharedPreferences.setMockInitialValues({'pinLockedUntil': 'not-a-date'}); + final prefs = await SharedPreferences.getInstance(); + + await migrateSecurityFlags(prefs, secureStorage); + + verifyNever(() => mockStorage.write(key: _pinLockedUntilKey, value: any(named: 'value'))); + expect(prefs.getString('pinLockedUntil'), isNull); + }); + + test('always clears the legacy isPinEnabled flag', () async { + SharedPreferences.setMockInitialValues({'isPinEnabled': true}); + final prefs = await SharedPreferences.getInstance(); + + await migrateSecurityFlags(prefs, secureStorage); + + expect(prefs.getBool('isPinEnabled'), isNull); + }); + + test('is a no-op on a clean install: nothing migrated, no secure writes', () async { + SharedPreferences.setMockInitialValues(const {}); + final prefs = await SharedPreferences.getInstance(); + + await migrateSecurityFlags(prefs, secureStorage); + + verifyNever(() => mockStorage.write(key: any(named: 'key'), value: any(named: 'value'))); + }); + + test('migrates a zero attempt counter (0 is a stored value, not "absent")', () async { + // getInt returns 0 for a stored 0; the migration must treat that as a real + // value to move, not skip it as if the key were missing. + SharedPreferences.setMockInitialValues({'pinFailedAttempts': 0}); + final prefs = await SharedPreferences.getInstance(); + + await migrateSecurityFlags(prefs, secureStorage); + + verify(() => mockStorage.write(key: _pinFailedAttemptsKey, value: '0')).called(1); + expect(prefs.getInt('pinFailedAttempts'), isNull); + }); + + test('migrates every flag in one pass without interference', () async { + final until = DateTime.utc(2031, 6, 7, 8, 9, 10); + SharedPreferences.setMockInitialValues({ + 'isBiometricEnabled': false, + 'pinFailedAttempts': 5, + 'pinLockedUntil': until.toIso8601String(), + 'isPinEnabled': true, + }); + final prefs = await SharedPreferences.getInstance(); + + await migrateSecurityFlags(prefs, secureStorage); + + verify(() => mockStorage.write(key: _biometricEnabledKey, value: 'false')).called(1); + verify(() => mockStorage.write(key: _pinFailedAttemptsKey, value: '5')).called(1); + verify(() => mockStorage.write(key: _pinLockedUntilKey, value: until.toIso8601String())) + .called(1); + expect(prefs.getBool('isBiometricEnabled'), isNull); + expect(prefs.getInt('pinFailedAttempts'), isNull); + expect(prefs.getString('pinLockedUntil'), isNull); + expect(prefs.getBool('isPinEnabled'), isNull); + }); + + test('is idempotent: a second pass migrates nothing (prefs already cleared)', () async { + SharedPreferences.setMockInitialValues({'pinFailedAttempts': 2}); + final prefs = await SharedPreferences.getInstance(); + + await migrateSecurityFlags(prefs, secureStorage); + await migrateSecurityFlags(prefs, secureStorage); + + // Written exactly once: the second pass sees the cleared pref and is a + // no-op, matching the idempotency the dartdoc promises. + verify(() => mockStorage.write(key: _pinFailedAttemptsKey, value: '2')).called(1); + }); + }); +} diff --git a/test/setup/lifecycle_initializer_test.dart b/test/setup/lifecycle_initializer_test.dart index a8f7b3e06..e63a59f39 100644 --- a/test/setup/lifecycle_initializer_test.dart +++ b/test/setup/lifecycle_initializer_test.dart @@ -39,12 +39,11 @@ void main() { tearDown(() => GetIt.instance.reset()); - Future pumpLifecycle(WidgetTester tester) => - tester.pumpWidget( - const LifecycleInitializer( - child: SizedBox.shrink(), - ), - ); + Future pumpLifecycle(WidgetTester tester) => tester.pumpWidget( + const LifecycleInitializer( + child: SizedBox.shrink(), + ), + ); testWidgets( 'AppLifecycleState.hidden drops the mnemonic via WalletService.lockCurrentWallet', @@ -80,7 +79,7 @@ void main() { // paused must now drop the mnemonic and arm the PIN gate too, so a // platform that never emits `hidden` still locks. verify(() => walletService.lockCurrentWallet()).called(1); - verify(() => pinAuthCubit.onAppHidden()).called(1); + verify(() => pinAuthCubit.onAppHidden(any())).called(1); // and it still cancels the balance sync it always did. verify(() => balanceService.cancelSync()).called(1); }, @@ -96,7 +95,7 @@ void main() { await tester.pump(); verify(() => walletService.lockCurrentWallet()).called(1); - verify(() => pinAuthCubit.onAppHidden()).called(1); + verify(() => pinAuthCubit.onAppHidden(any())).called(1); }, ); @@ -110,7 +109,7 @@ void main() { await tester.pump(); verify(() => walletService.lockCurrentWallet()).called(1); - verify(() => pinAuthCubit.onAppHidden()).called(1); + verify(() => pinAuthCubit.onAppHidden(any())).called(1); }, ); @@ -123,7 +122,7 @@ void main() { await tester.pump(); verifyNever(() => walletService.lockCurrentWallet()); - verifyNever(() => pinAuthCubit.onAppHidden()); + verifyNever(() => pinAuthCubit.onAppHidden(any())); }, ); diff --git a/test/setup/routing/app_link_entry_test.dart b/test/setup/routing/app_link_entry_test.dart index 0786f4cbf..f61c5bb89 100644 --- a/test/setup/routing/app_link_entry_test.dart +++ b/test/setup/routing/app_link_entry_test.dart @@ -1,21 +1,26 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; +import 'package:realunit_wallet/setup/routing/boot_navigation.dart'; import 'package:realunit_wallet/setup/routing/routes/app_link_entry.dart'; void main() { - // A minimal router wired to the *production* [appLinkSchemeRedirect], exactly - // as router_config.dart wires it (current location read from the router's own - // delegate). `/home` is the normal cold-start entry; `/dashboard` and - // `/settings` stand in for arbitrary in-app routes a warm resume must preserve. + // A minimal router wired to the *production* scheme handling — the + // [appLinkSchemeRedirect] + [appLinkOnException] pair with the push-aware + // [effectiveLocation], exactly as router_config.dart wires it. `/home` is the + // normal cold-start entry; `/dashboard` and `/settings` stand in for + // arbitrary in-app routes a warm resume must preserve. GoRouter buildRouter({String initialLocation = '/home'}) { late final GoRouter router; router = GoRouter( initialLocation: initialLocation, redirect: (context, state) => appLinkSchemeRedirect( state, - router.routerDelegate.currentConfiguration.uri.toString(), + effectiveLocation(router.routerDelegate.currentConfiguration), ), + onException: appLinkOnException, routes: [ GoRoute( path: '/home', @@ -29,6 +34,13 @@ void main() { path: '/settings', builder: (_, _) => const Scaffold(body: Text('SET', key: Key('settings'))), ), + GoRoute( + path: '/details', + // Mirrors the real extra-required builders (/buyPaymentDetails, + // /webView, …): rebuilding this route from a bare path throws. + builder: (_, state) => + Scaffold(body: Text(state.extra! as String, key: const Key('details'))), + ), ], ); return router; @@ -82,6 +94,42 @@ void main() { expect(find.byKey(const Key('settings')), findsOneWidget); }); + testWidgets( + 'warm resume: a scheme open keeps the user on a PUSHED route ' + '(how /kyc is reached from Buy/Sell)', + (tester) async { + final router = await pump(tester); + router.go('/dashboard'); + await tester.pumpAndSettle(); + + // The KYC flow is entered imperatively — context.pushNamed(AppRoutes.kyc) + // from the Buy/Sell buttons — so the flow route sits ON TOP of the base + // route instead of replacing it. /settings stands in for the pushed flow. + unawaited(router.push('/settings')); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('settings')), findsOneWidget); + + // Same promise as for go-routes above: the scheme open must NOT force + // any navigation. The pushed route (and with it any page-scoped state, + // e.g. the KycCubit) must survive the open. + router.go(appLinkUrl); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('settings')), findsOneWidget); + expect(find.byKey(const Key('dashboard')), findsNothing); + + // A true no-op also preserves the imperative stack itself — the pushed + // route must still pop back to the base route it was pushed from. This + // guards against "fixes" that rebuild the page as a new base route + // (which would drop page-scoped state, `state.extra`, and the back + // stack while leaving the same page visible). + expect(router.canPop(), isTrue); + router.pop(); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('dashboard')), findsOneWidget); + }, + ); + testWidgets('cold start on the scheme URL boots to the normal entry', ( tester, ) async { @@ -112,6 +160,73 @@ void main() { }, ); + testWidgets( + 'warm resume: a crafted scheme URL to an auth path is a no-op ' + '(no navigation, PIN gate not bypassed)', + (tester) async { + final router = await pump(tester); + router.go('/settings'); + await tester.pumpAndSettle(); + + // `realunit-wallet://dashboard` must not navigate anywhere on a warm + // resume either — same no-op as the canonical open. + router.go('realunit-wallet://dashboard'); + await tester.pumpAndSettle(); + + expect(currentPath(router), '/settings'); + expect(find.byKey(const Key('settings')), findsOneWidget); + expect(find.byKey(const Key('dashboard')), findsNothing); + }, + ); + + testWidgets( + 'warm resume: a crafted scheme URL carrying a real path does not navigate ' + '(flow-level gates not bypassed)', + (tester) async { + final router = await pump(tester); + router.go('/dashboard'); + await tester.pumpAndSettle(); + + // go_router matches on uri.path alone, so this WOULD match /settings if + // the redirect let it fall through like the canonical path-less open. + router.go('realunit-wallet://open/settings'); + await tester.pumpAndSettle(); + + expect(currentPath(router), '/dashboard'); + expect(find.byKey(const Key('dashboard')), findsOneWidget); + expect(find.byKey(const Key('settings')), findsNothing); + }, + ); + + testWidgets( + 'warm resume: a crafted path-carrying scheme URL over a pushed ' + 'extra-required route neither navigates nor crashes', + (tester) async { + final router = await pump(tester); + router.go('/dashboard'); + await tester.pumpAndSettle(); + + // Backgrounding on a pushed extra-required route is the everyday case + // (e.g. /buyPaymentDetails while paying in the banking app). + unawaited(router.push('/details', extra: 'payment')); + await tester.pumpAndSettle(); + expect(find.text('payment'), findsOneWidget); + + // The crafted URL is rewritten to the canonical path-less open and ends + // in the onException no-op. A currentLocation pin instead would rebuild + // /details via `go` with a null extra and crash the builder cast. + router.go('realunit-wallet://open/settings'); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.text('payment'), findsOneWidget); + expect(router.canPop(), isTrue); + router.pop(); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('dashboard')), findsOneWidget); + }, + ); + testWidgets( 'cold start on a crafted scheme URL to an auth path still boots to /home ' '(PIN gate not bypassed)', diff --git a/test/setup/routing/boot_navigation_apply_test.dart b/test/setup/routing/boot_navigation_apply_test.dart new file mode 100644 index 000000000..53be5a027 --- /dev/null +++ b/test/setup/routing/boot_navigation_apply_test.dart @@ -0,0 +1,204 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:realunit_wallet/setup/routing/boot_navigation.dart'; +import 'package:realunit_wallet/setup/routing/routes/app_routes.dart'; +import 'package:realunit_wallet/setup/routing/routes/pin_routes.dart'; + +// Drives the real `_navigate` wiring — `resolveBootNavigation` + the go_router +// side effect `applyBootNavAction` — against a real [GoRouter]. This is the seam +// the pure-function table and the cubit tests can't reach: it proves the +// decision is actually enacted on a router, and that a resume of an +// `extra`-required route lands on the dashboard WITHOUT building (and crashing +// on) that route. Pumping the full `WalletApp` is impractical (it depends on the +// whole DI graph + the global router building real, service-backed pages), so we +// exercise the smallest faithful seam instead. +void main() { + // Only the routes this seam touches. `/buyPaymentDetails` mirrors the real + // builder's non-nullable `extra` cast, so building it from a bare path throws + // — exactly the crash the restore allowlist must prevent. + GoRouter buildRouter() => GoRouter( + initialLocation: '/verifyPin', + routes: [ + GoRoute( + name: PinRoutes.verify, + path: '/verifyPin', + builder: (_, _) => const Text('verify'), + ), + GoRoute( + name: AppRoutes.dashboard, + path: '/dashboard', + builder: (_, _) => const Text('dashboard'), + ), + GoRoute( + name: AppRoutes.kyc, + path: '/kyc', + builder: (_, _) => const Text('kyc'), + ), + GoRoute( + name: AppRoutes.buyPaymentDetails, + path: '/buyPaymentDetails', + builder: (_, state) => Text('buy ${state.extra as Object}'), + ), + ], + ); + + // Fully-passed post-gate input: the router is on the PIN gate right after a + // re-lock, so a captured non-gate route can be restored. + BootNavAction resolveAfterRelock(String? resumeLocation) => resolveBootNavigation( + isLoadingWallet: false, + softwareTermsAccepted: true, + hasWallet: true, + onboardingCompleted: true, + isPinSetup: true, + isPinVerified: true, + bitboxAddressRecoveryNeeded: false, + walletLoaded: true, + currentLocation: '/verifyPin', + resumeLocation: resumeLocation, + ); + + String locationOf(GoRouter router) => router.routerDelegate.currentConfiguration.uri.toString(); + + testWidgets( + 're-lock -> PIN -> restore lands on the captured /kyc route with the ' + 'dashboard underneath (pop-based exits keep working)', + (tester) async { + final router = buildRouter(); + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + + var cleared = false; + applyBootNavAction( + resolveAfterRelock('/kyc'), + router, + onLoadWallet: () {}, + onClearResume: () => cleared = true, + ); + await tester.pumpAndSettle(); + + expect(find.text('kyc'), findsOneWidget); + expect(effectiveLocation(router.routerDelegate.currentConfiguration), '/kyc'); + expect(cleared, isTrue); + + // The restore rebuilds the real entry shape (dashboard base + pushed + // flow), so the flow's pop-based exits (Close buttons, AppBar auto-back) + // still have somewhere to go — a bare `go` restore would strand them. + expect(router.canPop(), isTrue); + router.pop(); + await tester.pumpAndSettle(); + expect(find.text('dashboard'), findsOneWidget); + expect(locationOf(router), '/dashboard'); + }, + ); + + testWidgets('restoring /dashboard itself stays a plain go (nothing to pop)', ( + tester, + ) async { + final router = buildRouter(); + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + + applyBootNavAction( + resolveAfterRelock('/dashboard'), + router, + onLoadWallet: () {}, + onClearResume: () {}, + ); + await tester.pumpAndSettle(); + + expect(find.text('dashboard'), findsOneWidget); + expect(locationOf(router), '/dashboard'); + expect(router.canPop(), isFalse); + }); + + testWidgets( + 'resuming an extra-required route lands on /dashboard without throwing', + (tester) async { + final router = buildRouter(); + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + + final action = resolveAfterRelock('/buyPaymentDetails'); + // Fail-closed: not on the allowlist -> dashboard, never the crashing route. + expect((action as BootNavGoNamed).routeName, AppRoutes.dashboard); + + var cleared = false; + applyBootNavAction( + action, + router, + onLoadWallet: () {}, + onClearResume: () => cleared = true, + ); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.text('dashboard'), findsOneWidget); + expect(locationOf(router), '/dashboard'); + // The dashboard is a final landing — the stale capture must be dropped. + expect(cleared, isTrue); + }, + ); + + testWidgets('navigating to a gate keeps the capture for the pending unlock', ( + tester, + ) async { + final router = buildRouter(); + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + + // A re-lock diverts to the PIN gate. Clearing here would silently degrade + // the post-unlock restore to the dashboard fallback. + var cleared = false; + applyBootNavAction( + const BootNavGoNamed(PinRoutes.verify), + router, + onLoadWallet: () {}, + onClearResume: () => cleared = true, + ); + await tester.pumpAndSettle(); + + expect(find.text('verify'), findsOneWidget); + expect(cleared, isFalse); + }); + + testWidgets( + 'premise guard: restoring /buyPaymentDetails from a bare path really throws', + (tester) async { + final router = buildRouter(); + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + + // Proves the crash the allowlist prevents is real: navigating to the + // extra-required route without `extra` surfaces an exception. + router.go('/buyPaymentDetails'); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNotNull); + }, + ); + + testWidgets( + 'effectiveLocation reports a pushed route where the raw uri stays on the base', + (tester) async { + final router = buildRouter(); + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + + router.go('/dashboard'); + await tester.pumpAndSettle(); + expect(effectiveLocation(router.routerDelegate.currentConfiguration), '/dashboard'); + + // The KYC flow is entered exactly like this: an imperative push on top + // of the dashboard. + unawaited(router.push('/kyc')); + await tester.pumpAndSettle(); + + // Premise guard for every effectiveLocation call site: the raw uri is + // blind to the push — the helper is not. + expect(locationOf(router), '/dashboard'); + expect(effectiveLocation(router.routerDelegate.currentConfiguration), '/kyc'); + }, + ); +} diff --git a/test/setup/routing/boot_navigation_test.dart b/test/setup/routing/boot_navigation_test.dart new file mode 100644 index 000000000..23046b384 --- /dev/null +++ b/test/setup/routing/boot_navigation_test.dart @@ -0,0 +1,261 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:realunit_wallet/setup/routing/boot_navigation.dart'; +import 'package:realunit_wallet/setup/routing/router_config.dart'; +import 'package:realunit_wallet/setup/routing/routes/app_routes.dart'; +import 'package:realunit_wallet/setup/routing/routes/onboarding_routes.dart'; +import 'package:realunit_wallet/setup/routing/routes/pin_routes.dart'; + +void main() { + // The drift-pin group below constructs the real global GoRouter; initialize + // the test binding up front, matching the repo convention for plain-test + // files that touch framework globals. + TestWidgetsFlutterBinding.ensureInitialized(); + + // A fully-passed, steady-state input where the user sits on a non-gate route + // (/kyc). Individual tests flip exactly one field to exercise one branch. + BootNavAction resolve({ + bool isLoadingWallet = false, + bool softwareTermsAccepted = true, + bool hasWallet = true, + bool onboardingCompleted = true, + bool isPinSetup = true, + bool isPinVerified = true, + bool bitboxAddressRecoveryNeeded = false, + bool walletLoaded = true, + String currentLocation = '/kyc', + String? resumeLocation, + }) => resolveBootNavigation( + isLoadingWallet: isLoadingWallet, + softwareTermsAccepted: softwareTermsAccepted, + hasWallet: hasWallet, + onboardingCompleted: onboardingCompleted, + isPinSetup: isPinSetup, + isPinVerified: isPinVerified, + bitboxAddressRecoveryNeeded: bitboxAddressRecoveryNeeded, + walletLoaded: walletLoaded, + currentLocation: currentLocation, + resumeLocation: resumeLocation, + ); + + group('resolveBootNavigation gate ladder', () { + test('isLoadingWallet short-circuits to wait, ignoring everything else', () { + expect( + resolve( + isLoadingWallet: true, + softwareTermsAccepted: false, + resumeLocation: '/kyc', + ), + isA(), + ); + }); + + test('missing software terms -> home', () { + final action = resolve(softwareTermsAccepted: false); + expect((action as BootNavGoNamed).routeName, AppRoutes.home); + }); + + test('no wallet -> welcome', () { + final action = resolve(hasWallet: false); + expect((action as BootNavGoNamed).routeName, OnboardingRoutes.welcome); + }); + + test('onboarding not completed -> onboarding completed screen', () { + final action = resolve(onboardingCompleted: false); + expect((action as BootNavGoNamed).routeName, OnboardingRoutes.completed); + }); + + test('pin not set up -> setup pin', () { + final action = resolve(isPinSetup: false); + expect((action as BootNavGoNamed).routeName, PinRoutes.setup); + }); + + test('pin not verified -> verify pin', () { + final action = resolve(isPinVerified: false); + expect((action as BootNavGoNamed).routeName, PinRoutes.verify); + }); + + test('bitbox address recovery needed -> recovery flow', () { + final action = resolve(bitboxAddressRecoveryNeeded: true); + expect( + (action as BootNavGoNamed).routeName, + AppRoutes.bitboxAddressRecovery, + ); + }); + + test('wallet not loaded -> load wallet', () { + expect(resolve(walletLoaded: false), isA()); + }); + }); + + group('resolveBootNavigation gate precedence over restore (security)', () { + test( + 'an un-verified PIN wins over a pending restore: never bypasses the gate', + () { + // The router is on the PIN gate and a restorable /kyc is captured, but + // isPinVerified is false -> we MUST go to the PIN gate, never restore. + final action = resolve( + isPinVerified: false, + currentLocation: '/verifyPin', + resumeLocation: '/kyc', + ); + expect((action as BootNavGoNamed).routeName, PinRoutes.verify); + }, + ); + }); + + group('resolveBootNavigation post-gate landing', () { + test('already on a valid non-gate route -> stay put (never yank)', () { + // Even with a stale capture present, an active non-gate route is kept. + expect( + resolve(currentLocation: '/kyc', resumeLocation: '/settings'), + isA(), + ); + }); + + test('on the dashboard steady state -> stay put', () { + expect(resolve(currentLocation: '/dashboard'), isA()); + }); + + test('core scenario: relocked on the PIN gate, restore the captured /kyc', () { + final action = resolve( + currentLocation: '/verifyPin', + resumeLocation: '/kyc', + ); + expect((action as BootNavRestore).location, '/kyc'); + }); + + test('on a gate route with no capture -> dashboard fallback', () { + final action = resolve(currentLocation: '/verifyPin', resumeLocation: null); + expect((action as BootNavGoNamed).routeName, AppRoutes.dashboard); + }); + + test('a gate resume location is NOT restored -> dashboard fallback', () { + // The captured route is itself a gate (e.g. /setupPin): restoring it + // would be pointless / unsafe, so fall back to the dashboard. + final action = resolve( + currentLocation: '/verifyPin', + resumeLocation: '/setupPin', + ); + expect((action as BootNavGoNamed).routeName, AppRoutes.dashboard); + }); + + test('restore preserves a resume path that carries a query string', () { + final action = resolve( + currentLocation: '/verifyPin', + resumeLocation: '/kyc?context=buy', + ); + expect((action as BootNavRestore).location, '/kyc?context=buy'); + }); + + test('an extra-required resume route is NOT restored -> dashboard', () { + // `/buyPaymentDetails` rebuilds via `state.extra as BuyPaymentDetailsParams` + // (non-nullable): restoring it from a bare path would crash, so it is not + // on the allowlist and falls back to the dashboard (fail-closed). + final action = resolve( + currentLocation: '/verifyPin', + resumeLocation: '/buyPaymentDetails', + ); + expect((action as BootNavGoNamed).routeName, AppRoutes.dashboard); + }); + + test('a PIN-gated settings subroute is NOT restored -> dashboard', () { + // `/settings/seed` sits behind a secondary PIN gate; exact-path matching + // (`/settings/seed` != `/settings`) keeps it off the allowlist. + final action = resolve( + currentLocation: '/verifyPin', + resumeLocation: '/settings/seed', + ); + expect((action as BootNavGoNamed).routeName, AppRoutes.dashboard); + }); + }); + + group('isGateLocation', () { + for (final loc in gateLocations) { + test('$loc is a gate', () => expect(isGateLocation(loc), isTrue)); + } + + test('a non-gate app route is not a gate', () { + expect(isGateLocation('/kyc'), isFalse); + expect(isGateLocation('/dashboard'), isFalse); + expect(isGateLocation('/settings/security'), isFalse); + }); + + test('a query string does not defeat the gate check', () { + expect(isGateLocation('/verifyPin?x=1'), isTrue); + }); + + test('resumeCaptureFor maps gates to null and keeps in-flight routes', () { + expect(resumeCaptureFor('/verifyPin'), isNull); + expect(resumeCaptureFor('/pinGate'), isNull); + expect(resumeCaptureFor('/kyc'), '/kyc'); + expect(resumeCaptureFor('/kyc?context=buy'), '/kyc?context=buy'); + expect(resumeCaptureFor(''), ''); + }); + + test('a query string does not turn a non-gate into a gate', () { + expect(isGateLocation('/kyc?context=buy'), isFalse); + }); + }); + + group('isRestorableLocation', () { + for (final loc in restorableLocations) { + test( + '$loc is restorable', + () => expect(isRestorableLocation(loc), isTrue), + ); + } + + test('an extra-required route is not restorable', () { + expect(isRestorableLocation('/buyPaymentDetails'), isFalse); + expect(isRestorableLocation('/sellBitbox'), isFalse); + expect(isRestorableLocation('/legalDocument'), isFalse); + expect(isRestorableLocation('/webView'), isFalse); + }); + + test('a PIN-gated / sensitive subroute is not restorable', () { + expect(isRestorableLocation('/settings/seed'), isFalse); + expect(isRestorableLocation('/settings/security'), isFalse); + expect(isRestorableLocation('/settings/security/changePin'), isFalse); + }); + + test('a gate route is not restorable', () { + expect(isRestorableLocation('/verifyPin'), isFalse); + expect(isRestorableLocation('/home'), isFalse); + }); + + test('a query string does not defeat the allowlist match', () { + expect(isRestorableLocation('/kyc?context=buy'), isTrue); + }); + }); + + group('drift pin against the real route table', () { + // Both sets duplicate path literals from router_config.dart. A path rename + // there would otherwise drift silently: an unrecognized gate strands the + // user on the gate screen (BootNavStay instead of the dashboard fallback), + // an unrecognized restorable route silently degrades to the dashboard. + Set collectPaths(List routes, String prefix) { + final paths = {}; + for (final route in routes) { + var next = prefix; + if (route is GoRoute) { + next = route.path.startsWith('/') + ? route.path + : '${prefix == '/' ? '' : prefix}/${route.path}'; + paths.add(next); + } + paths.addAll(collectPaths(route.routes, next)); + } + return paths; + } + + test('every gate and restorable location is a real route path', () { + // Constructing GoRouter never invokes page builders, so walking the real + // routerConfig needs neither DI nor pumpWidget. + final realPaths = collectPaths(routerConfig.configuration.routes, ''); + + expect(gateLocations.difference(realPaths), isEmpty); + expect(restorableLocations.difference(realPaths), isEmpty); + }); + }); +} diff --git a/test/widgets/form/country_field_test.dart b/test/widgets/form/country_field_test.dart index 78c97f13a..c50377760 100644 --- a/test/widgets/form/country_field_test.dart +++ b/test/widgets/form/country_field_test.dart @@ -254,4 +254,91 @@ void main() { expect(find.byType(DropdownButtonFormField), findsOneWidget); }); }); + + group('$CountryField stale-error clearing', () { + testWidgets('an empty field reports an error once the Form is validated', (tester) async { + registerCountryService(fixtureCountryService()); + final formKey = GlobalKey(); + + await tester.pumpApp( + host( + const CountryField(label: 'Citizenship', purpose: CountryFieldPurpose.nationality), + formKey: formKey, + ), + ); + await tester.pumpAndSettle(); + + expect(formKey.currentState!.validate(), isFalse); + await tester.pump(); + + final state = tester.state>( + find.byType(DropdownButtonFormField), + ); + expect(state.hasError, isTrue); + }); + + testWidgets('selecting a country clears the stale error without re-validating the Form', ( + tester, + ) async { + registerCountryService(fixtureCountryService()); + final formKey = GlobalKey(); + + await tester.pumpApp( + host( + CountryField( + label: 'Citizenship', + purpose: CountryFieldPurpose.nationality, + // A non-null onChanged is what enables the dropdown so it can open. + onChanged: (_) {}, + ), + formKey: formKey, + ), + ); + await tester.pumpAndSettle(); + + // The user hits "Next" on an empty field: the Form validates and the + // country field turns red. + expect(formKey.currentState!.validate(), isFalse); + await tester.pump(); + + final state = tester.state>( + find.byType(DropdownButtonFormField), + ); + expect(state.hasError, isTrue); + + // Open the dropdown and pick Switzerland. More than one 'Switzerland' + // Text can be laid out while the menu is open, so target the last. + await tester.tap(find.byType(DropdownButton)); + await tester.pumpAndSettle(); + expect(find.byType(DropdownMenuItem), findsWidgets); + await tester.tap(find.text('Switzerland').last); + await tester.pumpAndSettle(); + + // autovalidateMode.onUserInteraction re-validates on didChange, so the + // red border clears immediately without a second Form.validate() — the + // regression was that the stale error persisted until the next validate(). + expect(state.value?.symbol, 'CH'); + expect(state.hasError, isFalse); + }); + }); + + group('$CountryField hint', () { + testWidgets('shows the neutral placeholder hint, not a country name, when empty', ( + tester, + ) async { + registerCountryService(fixtureCountryService()); + + await tester.pumpApp( + host( + const CountryField(label: 'Citizenship', purpose: CountryFieldPurpose.nationality), + ), + ); + await tester.pumpAndSettle(); + + // pumpApp resolves to the en locale in tests, so the hint is the English + // ARB value. This asserts the hint by text, guarding against a silent ARB + // regression that the pixel-only goldens cannot catch by value. + expect(find.text('Select country'), findsOneWidget); + }); + }); } diff --git a/test/widgets/scrollable_actions_layout_test.dart b/test/widgets/scrollable_actions_layout_test.dart new file mode 100644 index 000000000..f3e9ad7e2 --- /dev/null +++ b/test/widgets/scrollable_actions_layout_test.dart @@ -0,0 +1,796 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:realunit_wallet/widgets/scrollable_actions_layout.dart'; + +import '../helper/helper.dart'; + +/// Global axis-aligned rect of the [finder]'s [RenderBox]. +Rect _globalRect(WidgetTester tester, Finder finder) { + final box = tester.renderObject(finder); + return box.localToGlobal(Offset.zero) & box.size; +} + +void main() { + group('$ScrollableActionsLayout', () { + testWidgets('keeps actions tappable when body is taller than viewport', (tester) async { + var taps = 0; + const viewport = Size(375, 500); + + await tester.binding.setSurfaceSize(viewport); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await expectNoLayoutOverflow(tester, () async { + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: const MediaQueryData(size: viewport), + child: Scaffold( + body: SizedBox( + height: 500, + child: ScrollableActionsLayout( + body: Column( + children: List.generate( + 40, + (i) => SizedBox(height: 40, child: Text('row $i')), + ), + ), + actions: [ + FilledButton( + onPressed: () => taps++, + child: const Text('Do it'), + ), + ], + ), + ), + ), + ), + ), + ); + await tester.pump(); + }); + + await expectFullyTappable( + tester, + find.text('Do it'), + within: find.byType(ScrollableActionsLayout), + ); + expect(taps, 1); + }); + + testWidgets('throws when height is unbounded (no silent degradation)', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: ScrollableActionsLayout( + body: Text('body'), + actions: [Text('action')], + ), + ), + ), + ), + ); + await tester.pump(); + + final exception = tester.takeException(); + expect(exception, isA()); + expect( + (exception as FlutterError).toString(), + contains('requires a bounded height'), + ); + }); + + testWidgets( + 'centerBody: true centers a short body within the scroll viewport', + (tester) async { + const hostSize = Size(375, 500); + // Short body clearly smaller than the leftover scroll viewport. + const bodyHeight = 80.0; + // Key the inner marker (visible content), not the outer body wrapper: + // ConstrainedBox(minHeight: viewport) makes the outer body fill the + // viewport by design, so its center always coincides with the viewport. + final markerKey = GlobalKey(); + + await tester.binding.setSurfaceSize(hostSize); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: const MediaQueryData(size: hostSize), + child: Scaffold( + body: SizedBox( + height: hostSize.height, + child: ScrollableActionsLayout( + centerBody: true, + body: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + key: markerKey, + height: bodyHeight, + child: const Text('short body'), + ), + ], + ), + actions: [ + FilledButton( + onPressed: () {}, + child: const Text('Confirm'), + ), + ], + ), + ), + ), + ), + ), + ); + await tester.pump(); + + final markerRect = _globalRect(tester, find.byKey(markerKey)); + // SingleChildScrollView's render box is the visible scroll viewport + // above the sticky action block. + final viewportRect = _globalRect( + tester, + find.byKey(const Key('scrollable_actions_layout.body_scroll_view')), + ); + + // ±4px: small float/subpixel tolerance for RenderBox→global conversion; + // true centering is exact, so a few logical pixels still proves intent + // without flaking on platform rounding. + expect( + markerRect.center.dy, + closeTo(viewportRect.center.dy, 4), + reason: + 'short body marker should be vertically centered in the scroll ' + 'viewport (marker.center.dy=${markerRect.center.dy}, ' + 'viewport.center.dy=${viewportRect.center.dy})', + ); + }, + ); + + testWidgets( + 'centerBody: true keeps a tall body from clipping or centering off-screen', + (tester) async { + const hostSize = Size(375, 500); + final bodyKey = GlobalKey(); + + await tester.binding.setSurfaceSize(hostSize); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await expectNoLayoutOverflow(tester, () async { + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: const MediaQueryData(size: hostSize), + child: Scaffold( + body: SizedBox( + height: hostSize.height, + child: ScrollableActionsLayout( + centerBody: true, + body: Column( + key: bodyKey, + children: List.generate( + 40, + (i) => SizedBox(height: 40, child: Text('row $i')), + ), + ), + actions: [ + FilledButton( + onPressed: () {}, + child: const Text('Confirm tall'), + ), + ], + ), + ), + ), + ), + ), + ); + await tester.pump(); + }); + + final bodyRect = _globalRect(tester, find.byKey(bodyKey)); + final viewportRect = _globalRect( + tester, + find.byKey(const Key('scrollable_actions_layout.body_scroll_view')), + ); + + // Tall body must start at the top of the scroll viewport (not centered + // off-screen). ±4px covers subpixel/rounding noise only. + expect( + bodyRect.top, + closeTo(viewportRect.top, 4), + reason: + 'tall body must top-align in the viewport when it outgrows it ' + '(body.top=${bodyRect.top}, viewport.top=${viewportRect.top})', + ); + + // Content must be scrollable: scroll position starts at 0 and advances + // after a drag. Scope to the body scroll view — actions also embed a + // Scrollable/SingleChildScrollView when the action list is non-empty. + final scrollable = tester.state( + find.descendant( + of: find.byKey( + const Key('scrollable_actions_layout.body_scroll_view'), + ), + matching: find.byType(Scrollable), + ), + ); + expect(scrollable.position.pixels, 0); + await tester.drag( + find.byKey(const Key('scrollable_actions_layout.body_scroll_view')), + const Offset(0, -200), + ); + await tester.pump(); + expect(scrollable.position.pixels, greaterThan(0)); + + await expectFullyTappable( + tester, + find.text('Confirm tall'), + within: find.byType(ScrollableActionsLayout), + ); + }, + ); + + testWidgets( + 'centerBody: false (default) keeps a short body top-aligned', + (tester) async { + const hostSize = Size(375, 500); + const bodyHeight = 80.0; + // Key the inner marker (visible content), not the outer body wrapper: + // ConstrainedBox(minHeight: viewport) makes the outer body fill the + // viewport by design, so its top/center always match the viewport. + final markerKey = GlobalKey(); + + await tester.binding.setSurfaceSize(hostSize); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: const MediaQueryData(size: hostSize), + child: Scaffold( + body: SizedBox( + height: hostSize.height, + child: ScrollableActionsLayout( + // centerBody intentionally omitted — default false. + body: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + key: markerKey, + height: bodyHeight, + child: const Text('short body default'), + ), + ], + ), + actions: [ + FilledButton( + onPressed: () {}, + child: const Text('Confirm default'), + ), + ], + ), + ), + ), + ), + ), + ); + await tester.pump(); + + final markerRect = _globalRect(tester, find.byKey(markerKey)); + final viewportRect = _globalRect( + tester, + find.byKey(const Key('scrollable_actions_layout.body_scroll_view')), + ); + + // Default must stay top-aligned (regression for the 15 non-centering + // screens). ±4px is only for float/subpixel noise. + expect( + markerRect.top, + closeTo(viewportRect.top, 4), + reason: + 'default centerBody=false must top-align short body marker ' + '(marker.top=${markerRect.top}, viewport.top=${viewportRect.top})', + ); + // Explicitly not centered: marker center should be well above viewport + // center when the content is much shorter than the viewport. + expect( + (viewportRect.center.dy - markerRect.center.dy).abs(), + greaterThan(20), + reason: 'short body marker must not be vertically centered by default', + ); + }, + ); + + testWidgets( + 'caps tall actionBlock so Column does not overflow and CTA stays tappable', + (tester) async { + // Host short enough that 4×56px actions + 3×12px spacing (260px) alone + // exceed the host height (140px) — the pre-fix overflow scenario + // (plain Column of actions with no ConstrainedBox/SingleChildScrollView). + const hostHeight = 140.0; + const hostSize = Size(375, hostHeight); + const actionHeight = 56.0; + // 4×56 + 3×12 spacing = 260px intrinsic (must overflow host=140). + const intrinsicActionsHeight = 4 * actionHeight + 3 * 12.0; + var taps = 0; + + await tester.binding.setSurfaceSize(hostSize); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await expectNoLayoutOverflow(tester, () async { + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: const MediaQueryData(size: hostSize), + child: Scaffold( + body: SizedBox( + height: hostHeight, + child: ScrollableActionsLayout( + body: const Text('body'), + actions: [ + SizedBox( + height: actionHeight, + width: double.infinity, + child: FilledButton( + onPressed: () => taps++, + child: const Text('Primary CTA'), + ), + ), + const SizedBox( + height: actionHeight, + width: double.infinity, + child: ColoredBox( + color: Color(0xFFCCCCCC), + child: Center(child: Text('Action 2')), + ), + ), + const SizedBox( + height: actionHeight, + width: double.infinity, + child: ColoredBox( + color: Color(0xFFBBBBBB), + child: Center(child: Text('Action 3')), + ), + ), + const SizedBox( + height: actionHeight, + width: double.infinity, + child: ColoredBox( + color: Color(0xFFAAAAAA), + child: Center(child: Text('Action 4')), + ), + ), + ], + ), + ), + ), + ), + ), + ); + await tester.pump(); + }); + + await expectFullyTappable( + tester, + find.text('Primary CTA'), + within: find.byType(ScrollableActionsLayout), + ); + expect(taps, 1); + + // Cap binds only at the full available height — no fraction budget. + // Intrinsic 260 > host 140, so the action block must be clamped to host. + expect(intrinsicActionsHeight, greaterThan(hostHeight)); + final actionsScrollBox = tester.renderObject( + find.byKey(const Key('scrollable_actions_layout.actions_scroll_view')), + ); + expect( + actionsScrollBox.size.height, + lessThanOrEqualTo(hostHeight), + reason: + 'actionBlock height ${actionsScrollBox.size.height} must not exceed ' + 'full available host height ($hostHeight)', + ); + }, + ); + + testWidgets( + 'normal-case actionBlock shrink-wraps to exact intrinsic height; body keeps leftover', + (tester) async { + const hostSize = Size(375, 500); + const hostHeight = 500.0; + // Fixed-height action so intrinsic height is known exactly (no theme + // button sizing). Cap maxHeight = host (500) >> 48, so it must not bind. + const intrinsicActionHeight = 48.0; + final actionKey = GlobalKey(); + + await tester.binding.setSurfaceSize(hostSize); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: const MediaQueryData(size: hostSize), + child: Scaffold( + body: SizedBox( + height: hostHeight, + child: ScrollableActionsLayout( + body: const SizedBox( + height: 80, + child: Text('short body normal'), + ), + actions: [ + SizedBox( + key: actionKey, + height: intrinsicActionHeight, + width: double.infinity, + child: FilledButton( + onPressed: () {}, + child: const Text('Confirm normal'), + ), + ), + ], + ), + ), + ), + ), + ), + ); + await tester.pump(); + + final actionsScrollBox = tester.renderObject( + find.byKey(const Key('scrollable_actions_layout.actions_scroll_view')), + ); + final actionsHeight = actionsScrollBox.size.height; + + // Cap must NEVER bind when content fits: rendered height equals + // intrinsic height exactly (the field regression was a fraction cap + // that clamped content which still fit the real leftover space). + expect( + actionsHeight, + closeTo(intrinsicActionHeight, 1), + reason: + 'when actions fit the viewport, actionBlock must shrink-wrap to ' + 'exact intrinsic height $intrinsicActionHeight, got $actionsHeight ' + '(cap must not bind)', + ); + + // Body SingleChildScrollView viewport height must be the host leftover + // after the shrink-wrapped actionBlock (same geometry as before any cap + // existed). Keyed so it is never confused with the actions scroll view. + final bodyScrollBox = tester.renderObject( + find.byKey(const Key('scrollable_actions_layout.body_scroll_view')), + ); + expect( + bodyScrollBox.size.height, + closeTo(hostHeight - actionsHeight, 1), + reason: + 'Expanded body viewport should receive hostHeight - actionBlock ' + '(${hostHeight - actionsHeight}), got ${bodyScrollBox.size.height}', + ); + }, + ); + + testWidgets( + 'does not clip actions that fit the real viewport even when they exceed an arbitrary fraction of it', + (tester) async { + // Field regression (DashboardView, empty balance, iPhone 13 mini @ 3x + // text scale): inner available height ~308px, action content ~220px — + // fits the real budget, but a 0.6 fraction cap would clamp to ~184.8px + // and clip a CTA that was previously fine. + // + // Host total 356 with widget padding vertical 24×2 → inner 308. + const hostHeight = 356.0; + const hostSize = Size(375, hostHeight); + const padding = EdgeInsets.symmetric(horizontal: 20, vertical: 24); + const availableHeight = hostHeight - 48; // 308 + // ~220px action that fits in 308 but exceeds 0.6*308 = 184.8. + const actionIntrinsicHeight = 220.0; + const fractionWouldCapAt = availableHeight * 0.6; // 184.8 + var taps = 0; + + // Sanity: this scenario only pins the regression if the action fits + // the real viewport yet would have been clipped by a fraction cap. + expect(actionIntrinsicHeight, lessThan(availableHeight)); + expect(actionIntrinsicHeight, greaterThan(fractionWouldCapAt)); + + await tester.binding.setSurfaceSize(hostSize); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await expectNoLayoutOverflow(tester, () async { + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: const MediaQueryData(size: hostSize), + child: Scaffold( + body: SizedBox( + height: hostHeight, + child: ScrollableActionsLayout( + padding: padding, + body: const Text('dashboard body'), + actions: [ + SizedBox( + height: actionIntrinsicHeight, + width: double.infinity, + child: FilledButton( + onPressed: () => taps++, + child: const Text('Dashboard CTA'), + ), + ), + ], + ), + ), + ), + ), + ), + ); + await tester.pump(); + }); + + final actionsScrollBox = tester.renderObject( + find.byKey(const Key('scrollable_actions_layout.actions_scroll_view')), + ); + // Cap must NOT bind: rendered height equals intrinsic ~220, not 184.8. + expect( + actionsScrollBox.size.height, + closeTo(actionIntrinsicHeight, 1), + reason: + 'action that fits real available height ($availableHeight) must ' + 'render at full intrinsic $actionIntrinsicHeight, not be clamped ' + 'to a fraction budget ($fractionWouldCapAt); got ' + '${actionsScrollBox.size.height}', + ); + expect( + actionsScrollBox.size.height, + greaterThan(fractionWouldCapAt), + reason: + 'explicitly larger than the deleted 0.6-fraction cap so nobody ' + 'reintroduces a fraction-style budget', + ); + + await expectFullyTappable( + tester, + find.text('Dashboard CTA'), + within: find.byType(ScrollableActionsLayout), + ); + expect(taps, 1); + }, + ); + + testWidgets( + 'shrinkWrap: true sizes to short content under a generous cap', + (tester) async { + // Cap is a loose maxHeight (bottom-sheet style). Align loosens the + // SizedBox's tight height so the layout can genuinely shrink-wrap. + const hostSize = Size(375, 600); + const cap = 600.0; + const bodyHeight = 80.0; + const actionHeight = 48.0; + + await tester.binding.setSurfaceSize(hostSize); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await expectNoLayoutOverflow(tester, () async { + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: const MediaQueryData(size: hostSize), + child: Scaffold( + body: SizedBox( + height: cap, + child: Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: cap), + child: ScrollableActionsLayout( + shrinkWrap: true, + body: const SizedBox( + height: bodyHeight, + child: Text('shrink wrap short body'), + ), + actions: [ + SizedBox( + height: actionHeight, + width: double.infinity, + child: FilledButton( + onPressed: () {}, + child: const Text('Shrink wrap CTA'), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ); + await tester.pump(); + }); + + final layoutBox = tester.renderObject( + find.byType(ScrollableActionsLayout), + ); + // Content-sized: body + actions, not expanded to the 600px cap. + // Tolerance covers spacing/theme chrome only — not a near-full fill. + expect( + layoutBox.size.height, + closeTo(bodyHeight + actionHeight, 20), + reason: + 'shrinkWrap short layout must size to content ' + '(~${bodyHeight + actionHeight}), not fill $cap; ' + 'got ${layoutBox.size.height}', + ); + expect( + layoutBox.size.height, + lessThan(cap / 2), + reason: 'must not expand toward the generous cap', + ); + + await expectFullyTappable( + tester, + find.text('Shrink wrap CTA'), + within: find.byType(ScrollableActionsLayout), + ); + }, + ); + + testWidgets( + 'shrinkWrap: true caps tall body, scrolls, and pins actions below', + (tester) async { + const hostSize = Size(375, 500); + const cap = 500.0; + const actionHeight = 48.0; + var taps = 0; + + await tester.binding.setSurfaceSize(hostSize); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await expectNoLayoutOverflow(tester, () async { + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: const MediaQueryData(size: hostSize), + child: Scaffold( + body: SizedBox( + height: cap, + child: Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: cap), + child: ScrollableActionsLayout( + shrinkWrap: true, + body: Column( + children: List.generate( + 40, + (i) => SizedBox(height: 40, child: Text('row $i')), + ), + ), + actions: [ + SizedBox( + height: actionHeight, + width: double.infinity, + child: FilledButton( + onPressed: () => taps++, + child: const Text('Shrink wrap tall CTA'), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ); + await tester.pump(); + }); + + final layoutBox = tester.renderObject( + find.byType(ScrollableActionsLayout), + ); + // Tall content must hit the cap, not overflow past it. + expect( + layoutBox.size.height, + closeTo(cap, 1), + reason: + 'shrinkWrap tall layout must equal the cap ($cap), ' + 'got ${layoutBox.size.height}', + ); + + final bodyScrollKey = const Key('scrollable_actions_layout.body_scroll_view'); + final scrollable = tester.state( + find.descendant( + of: find.byKey(bodyScrollKey), + matching: find.byType(Scrollable), + ), + ); + expect( + scrollable.position.maxScrollExtent, + greaterThan(0), + reason: 'body must be scrollable when taller than the remaining cap', + ); + + final bodyScrollRect = _globalRect(tester, find.byKey(bodyScrollKey)); + final actionRect = _globalRect(tester, find.text('Shrink wrap tall CTA')); + expect( + actionRect.top, + greaterThanOrEqualTo(bodyScrollRect.bottom - 0.5), + reason: + 'actions must stay pinned below the body scroll view ' + '(action.top=${actionRect.top}, body.bottom=${bodyScrollRect.bottom})', + ); + + await expectFullyTappable( + tester, + find.text('Shrink wrap tall CTA'), + within: find.byType(ScrollableActionsLayout), + ); + expect(taps, 1); + }, + ); + + testWidgets( + 'shrinkWrap: false (default) still expands short body to fill host', + (tester) async { + const hostSize = Size(375, 600); + const hostHeight = 600.0; + const bodyHeight = 80.0; + const actionHeight = 48.0; + + await tester.binding.setSurfaceSize(hostSize); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: const MediaQueryData(size: hostSize), + child: Scaffold( + body: SizedBox( + height: hostHeight, + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: hostHeight), + child: ScrollableActionsLayout( + // shrinkWrap intentionally omitted — default false. + body: const SizedBox( + height: bodyHeight, + child: Text('default short body'), + ), + actions: [ + SizedBox( + height: actionHeight, + width: double.infinity, + child: FilledButton( + onPressed: () {}, + child: const Text('Default expand CTA'), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + await tester.pump(); + + final layoutBox = tester.renderObject( + find.byType(ScrollableActionsLayout), + ); + // Existing screens rely on Expanded body filling the bounded host. + expect( + layoutBox.size.height, + closeTo(hostHeight, 1), + reason: + 'default shrinkWrap=false must expand to full host height ' + '($hostHeight), got ${layoutBox.size.height}', + ); + }, + ); + }); +}