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 @@

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

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}', + ); + }, + ); }); }