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 bffcb8cb7..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,16 +221,16 @@ 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@v4
+ uses: actions/download-artifact@v7
with:
name: coverage-lcov
path: coverage
@@ -324,7 +317,7 @@ jobs:
# 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"
@@ -344,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/**
@@ -361,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
@@ -392,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/README.md b/README.md
index 472556422..2575e765d 100644
--- a/README.md
+++ b/README.md
@@ -163,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/docs/handbook/de/index.html b/docs/handbook/de/index.html
index eaaf6831b..b9aaa3a28 100644
--- a/docs/handbook/de/index.html
+++ b/docs/handbook/de/index.html
@@ -8183,6 +8183,76 @@
diff --git a/docs/responsive-layout.md b/docs/responsive-layout.md
index a2c60034b..16ff8aeae 100644
--- a/docs/responsive-layout.md
+++ b/docs/responsive-layout.md
@@ -24,6 +24,7 @@ Source: [`lib/widgets/scrollable_actions_layout.dart`](../lib/widgets/scrollable
- **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 |
@@ -32,6 +33,7 @@ Source: [`lib/widgets/scrollable_actions_layout.dart`](../lib/widgets/scrollable
| 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 |
@@ -66,13 +68,15 @@ Helpers:
## Rollout
-The migration is repo-wide. **25 surfaces** are on `ScrollableActionsLayout` and
+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).
+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
@@ -85,22 +89,20 @@ migrated `verify_seed_page`, `restore_wallet_view`, `buy_page`, and `sell_page`
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 branch, `grep -rl "Spacer()" lib/` finds hits only in
+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. At least three more surfaces are still plausible,
-un-re-audited candidates because they use a bounded, non-scrolling
-`Column(mainAxisSize: .min, ...)` with no `Spacer()` and no scroll view: the sell
-confirm/executed sheets (`sell_confirm_sheet.dart`, `sell_executed_sheet.dart`) and the two
-PIN bottom sheets (`forgot_pin_bottom_sheet.dart`, `enable_biometric_bottom_sheet.dart`).
-`welcome_page.dart` looks structurally safe (its entire body, including all interactive
-cards, already lives inside one `SingleChildScrollView` with no separate sticky CTA) but
-was not verified with a matrix test. None of these are confirmed either way — that is an
-open item for the next review pass, not a claim of completeness.
+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)
diff --git a/docs/testing.md b/docs/testing.md
index fd03fc45a..cbc931239 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -140,7 +140,7 @@ Rules for PRs:
- 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). 25 surfaces are catalogued in [`responsive_surface_catalog.dart`](../test/helper/responsive_surface_catalog.dart) (living list — not a completeness proof; see above).
+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
@@ -416,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`. 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).
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/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/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/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_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/widgets/scrollable_actions_layout.dart b/lib/widgets/scrollable_actions_layout.dart
index 4ac114111..3c4063cf3 100644
--- a/lib/widgets/scrollable_actions_layout.dart
+++ b/lib/widgets/scrollable_actions_layout.dart
@@ -36,6 +36,7 @@ class ScrollableActionsLayout extends StatelessWidget {
this.actionsSpacing = 12,
this.scrollPhysics,
this.centerBody = false,
+ this.shrinkWrap = false,
});
/// Scrollable main content (illustration, titles, forms, hints).
@@ -57,6 +58,14 @@ class ScrollableActionsLayout extends StatelessWidget {
/// `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(
@@ -103,27 +112,42 @@ class ScrollableActionsLayout extends StatelessWidget {
return Column(
crossAxisAlignment: .stretch,
+ mainAxisSize: shrinkWrap ? MainAxisSize.min : MainAxisSize.max,
children: [
- Expanded(
- child: LayoutBuilder(
- builder: (context, viewport) => SingleChildScrollView(
+ if (shrinkWrap)
+ Flexible(
+ child: 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,
+ 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/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_states_golden_test.dart b/test/goldens/screens/buy/buy_states_golden_test.dart
index cca3835b7..dcb51c035 100644
--- a/test/goldens/screens/buy/buy_states_golden_test.dart
+++ b/test/goldens/screens/buy/buy_states_golden_test.dart
@@ -127,13 +127,15 @@ void main() {
);
// Emitting `BuyConfirmFailure` drives the BlocConsumer listener
- // (`buy_confirm_button.dart:64-73`) to show the failure SnackBar. The three
- // error codes map to three distinct user-facing texts, so each is captured.
+ // (`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(
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_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/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/helper/responsive_surface_catalog.dart b/test/helper/responsive_surface_catalog.dart
index 3394fbc05..a81eb8439 100644
--- a/test/helper/responsive_surface_catalog.dart
+++ b/test/helper/responsive_surface_catalog.dart
@@ -204,10 +204,33 @@ const kResponsiveSurfaceCatalog = [
matrixTestPath: 'test/screens/kyc/kyc_static_pages_responsive_matrix_test.dart',
productionPath: 'lib/screens/kyc/steps/signature_unsupported/kyc_signature_unsupported_page.dart',
),
- // Migration covers 25 surfaces total (bitbox_connect_sheet + 24 above). Known
- // candidates NOT yet catalogued or confirmed either way (bounded, non-scrolling
- // Column with no Spacer() and no scroll view — a Spacer() grep would not catch
- // them): welcome_page, sell_confirm_sheet / sell_executed_sheet,
- // forgot_pin_bottom_sheet / enable_biometric_bottom_sheet. Not exhaustive — review
- // responsibility for every new sticky-CTA surface.
+ 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/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/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/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/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/widgets/scrollable_actions_layout_test.dart b/test/widgets/scrollable_actions_layout_test.dart
index 0104c8cb2..f3e9ad7e2 100644
--- a/test/widgets/scrollable_actions_layout_test.dart
+++ b/test/widgets/scrollable_actions_layout_test.dart
@@ -558,5 +558,239 @@ void main() {
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}',
+ );
+ },
+ );
});
}