diff --git a/.github/workflows/handbook-build-check.yaml b/.github/workflows/handbook-build-check.yaml index e797fa309..1c6e35331 100644 --- a/.github/workflows/handbook-build-check.yaml +++ b/.github/workflows/handbook-build-check.yaml @@ -92,8 +92,8 @@ jobs: set -euo pipefail bash scripts/assemble-handbook-screenshots.sh /tmp/handbook-shots count=$(ls -1 /tmp/handbook-shots/*.png | wc -l | tr -d ' ') - if [ "$count" != "279" ]; then - echo "expected 279 screenshots, got $count" >&2 + if [ "$count" != "282" ]; then + echo "expected 282 screenshots, got $count" >&2 exit 1 fi @@ -173,11 +173,11 @@ jobs: exit 1 fi - # Screenshots dir must contain all 279 PNGs assembled from Goldens. + # Screenshots dir must contain all 282 PNGs assembled from Goldens. # Hit one of them through the auth gate to verify wiring end-to-end. - # Mix of the original 01-61 range and the 62-269 batch (every Golden + # Mix of the original 01-61 range and the 62-272 batch (every Golden # baseline) so a regression in either half surfaces here. - for name in 01-welcome 11-dashboard 26-terms 35-dashboard-with-balance 46-buy-kyc-required 52-sell-unknown-error 53-buy-payment-details 61-kyc-registration-tax-tin-error 62-welcome-page-android 219-settings-security-page-default 268-phone-number-field-default 269-dashboard-insider-unlocked; do + for name in 01-welcome 11-dashboard 26-terms 35-dashboard-with-balance 46-buy-kyc-required 52-sell-unknown-error 53-buy-payment-details 61-kyc-registration-tax-tin-error 62-welcome-page-android 219-settings-security-page-default 268-phone-number-field-default 269-dashboard-insider-unlocked 270-pending-transaction-detail-buy-waiting 272-pending-transaction-detail-not-cancellable; do code=$(curl -s -o /dev/null -w '%{http_code}' -u "${HANDBOOK_USER:-x}:${HANDBOOK_PASS:-x}" "http://127.0.0.1:8080/screenshots/${name}.png") # 200 (auth happens to match) or 401 (auth fails but file exists) # both prove the file is on disk. 404 means it was not assembled. diff --git a/.github/workflows/pull-request.yaml b/.github/workflows/pull-request.yaml index d596f6ce8..d57bbbbef 100644 --- a/.github/workflows/pull-request.yaml +++ b/.github/workflows/pull-request.yaml @@ -73,15 +73,51 @@ jobs: # baselines. - run: flutter test --coverage --exclude-tags golden + # Per-file 100% line-coverage gate for every UI file this PR touches + # under lib/screens/** or lib/widgets/** (pages, views, widgets — not + # cubits/blocs, not *.g.dart). Runs against the FULL lcov.info before + # the packages/cubits/blocs extract below, so widget coverage is not + # stripped away first. The floor on packages/cubits/blocs is unchanged. + - name: Enforce 100% line coverage on touched UI files + run: | + set -euo pipefail + if [ ! -f coverage/lcov.info ]; then + echo "::error::coverage/lcov.info not found — cannot enforce touched UI coverage" + exit 1 + fi + # PRs use github.base_ref (e.g. staging/develop). Push/dispatch have + # no base_ref — fall back to develop so the step stays defined. + # Prefer the PR base SHA (always present on pull_request). Fetch that + # single commit so a shallow checkout can still diff touched files. + BASE="${{ github.event.pull_request.base.sha }}" + if [ -z "$BASE" ]; then + BASE_REF="${{ github.base_ref }}" + if [ -z "${BASE_REF}" ]; then + BASE_REF="develop" + fi + git fetch --no-tags --depth=50 origin "$BASE_REF" + BASE="origin/${BASE_REF}" + else + git fetch --no-tags --depth=1 origin "$BASE" + fi + # actions/checkout is shallow. A depth-1 fetch of the base SHA + # does not connect histories, so `git diff BASE...HEAD` dies + # with "no merge base". Deepen until the two tips meet. + if ! git merge-base "$BASE" HEAD >/dev/null 2>&1; then + git fetch --no-tags --deepen=200 origin + fi + if ! git merge-base "$BASE" HEAD >/dev/null 2>&1; then + git fetch --unshallow --no-tags origin || git fetch --no-tags --deepen=1000 origin + fi + bash scripts/check-touched-ui-coverage.sh coverage/lcov.info "$BASE" + # Narrow the coverage report to the README-defined activated surface: # lib/packages/** — services, repositories, signers, utils # lib/screens/**/cubit(s)/** — Cubit logic per feature # lib/screens/**/bloc/** — Bloc logic per feature - # Widget files (lib/screens/**/*_page.dart, lib/widgets/**) are covered - # by `testWidgets` specs and excluded from the line-coverage scope by - # design — see README "Coverage scope". Threshold enforcement is the - # next step (see README "Coverage infrastructure roadmap"); this step - # only produces the scoped baseline + a human-readable summary. + # Widget files (lib/screens/**/*_page.dart, lib/widgets/**) stay outside + # this floor extract; when a PR touches them they are gated by the + # touched-UI check above instead — see README "Coverage scope". # # The extract patterns use lcov's fnmatch matcher, where `*` is greedy # across `/` — so `lib/packages/*` matches arbitrarily deep paths under diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index da7f04aac..c8f7fa2fb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -215,6 +215,7 @@ The app supports three wallet modes (`software`, `bitbox`, `debug`) with differe - Uses `flutter_test`, `bloc_test`, and `mocktail` (NOT mockito). - Test structure mirrors `lib/` structure. - Test helper at `test/helper/` (provides `pumpApp`). +- **Touched UI files require 100% line coverage.** Every file under `lib/screens/**` or `lib/widgets/**` that a PR changes (pages, views, widgets — not only cubits) must have 100% line coverage, measured per file against the full `lcov.info` before the packages/cubits/blocs scope extract. Generated `*.g.dart` is excluded. Cubit/bloc folders under screens stay under the existing 100% packages/cubits/blocs floor and are not part of this UI gate. CI enforces the rule via [`scripts/check-touched-ui-coverage.sh`](scripts/check-touched-ui-coverage.sh). A neglected file that is touched must be brought fully to 100% in the same PR. - For BitBox-related code, the layered test strategy (Tier 0–4) is documented in [`docs/testing.md`](docs/testing.md), with concrete patterns for cubit tests, widget tests, service + HTTP tests, and `FakeBitboxCredentials`-backed integration tests. - [`docs/testing.md`](docs/testing.md) also lists the surface that needs an infra PR first (Drift repositories, `getIt`-coupled pages, `path_provider`-coupled cubits, the Sumsub SDK, plugin-coupled widgets). Don't try to mock around those without changing the injection point. - Service-lifecycle tests are mandatory for any service with a `Timer`, observer/subscription loop, or platform/MethodChannel dependency: instantiate the real class (no mock of the service itself), swap `BitboxUsbPlatform.instance` in `setUp` and restore in `tearDown`. Tests with periodic-timer or observer behaviour MUST drive time via `package:fake_async` (`fakeAsync` zone + `async.elapse(...)`). Wall-clock `Future.delayed` is not acceptable for time-bound assertions. diff --git a/README.md b/README.md index dd0c2b957..0a8e467cd 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ A Flutter wallet for Real Unit investors. Multi-chain, BitBox-ready, KYC-aware. - Defensive code that genuinely cannot be reached in `flutter_test` (platform channels without a test override, native plugin entry points, BLE callbacks) is exempted by an inline `// coverage:ignore-line` annotation with a one-line reason. - The branch is protected on GitHub: a PR cannot be merged while CI is red. -**Coverage scope:** `lib/packages/**` (services, repositories, signers, utils) and the `cubits/` + `bloc/` directories under each `lib/screens//`. Widget files (`lib/screens//_page.dart` and `lib/widgets/**`) are exercised via `testWidgets` specs and excluded from the line-coverage gate — widget tests count as `widget` coverage in the feature matrix, not as line %. Generated files (`*.g.dart` from `build_runner` / Drift) are also stripped after the scope extract — they are tool output, not developer code, and would otherwise drag the scoped line % down for free. +**Coverage scope:** `lib/packages/**` (services, repositories, signers, utils) and the `cubits/` + `bloc/` directories under each `lib/screens//` stay under the repo-wide floor gate (100% lines on that surface). **Touched UI files are no longer exempt from the line-coverage gate:** every PR-touched file under `lib/screens/**` or `lib/widgets/**` (pages, views, widgets — excluding cubit/bloc folders and `*.g.dart`) must reach 100% line coverage per file. CI runs [`scripts/check-touched-ui-coverage.sh`](scripts/check-touched-ui-coverage.sh) against the full `lcov.info` **before** the packages/cubits/blocs extract, so widget lines are still present when the check runs. The floor on packages/cubits/blocs is **not** widened to the whole widget tree — only files the PR actually touches are gated. Generated files (`*.g.dart` from `build_runner` / Drift) are stripped after the scope extract — they are tool output, not developer code. The five-tier testing model (Tier 0 Cubit unit · Tier 1 FakeBitbox integration · Tier 2 firmware simulator · Tier 3 Maestro flows (handbook simulator + deferred BitBox02 hardware) · Tier 4 BLE VCR/replay stretch) is tracked in [#314](https://github.com/RealUnitCH/app/issues/314). See [`docs/testing.md`](docs/testing.md) for the full tier picker. New BitBox-touching PRs are expected to add tests at the appropriate tier(s). @@ -130,9 +130,9 @@ The transport is USB on Android and Bluetooth on iOS; the original BitBox 02 has The activated surface (see "Coverage scope" above) is at **100 % scoped line coverage**. Every file under `lib/packages/**`, `lib/screens/**/cubit(s)/**`, and `lib/screens/**/bloc/**` either ships with tests or carries an `// coverage:ignore-*` annotation with a documented reason. The previous bullet list of partially-covered services, KYC cubits, biometric unlock, and DFX backend services has been retired — those gaps are closed. -Out of scope of the gate and tracked elsewhere: +Out of scope of the floor gate and tracked elsewhere: -- **Widget render paths** — measured separately via `testWidgets` specs, not in the line-coverage gate (deliberate; see `docs/testing.md` "Tier 0" rationale). +- **Untouched widget render paths** — widget files the PR does not change are still outside the packages/cubits/blocs floor extract. **Touched** UI files under `lib/screens/**` / `lib/widgets/**` are gated to 100% line coverage per file by `scripts/check-touched-ui-coverage.sh` (see Coverage scope above). - **Visual regression (goldens)** — every `lib/screens/**/*_page.dart` has a `test/goldens/**/*_golden_test.dart` companion, validated pixel-exact on the self-hosted runner by the `Visual Regression` CI job. Not folded into the line-coverage gate. The one exception is `lib/screens/web_view/web_view_page.dart` — `InAppWebView` is a platform-view that has no headless render in `flutter_test`, the spec is committed with `skip: true`. See [`docs/visual-regression-tests.md`](docs/visual-regression-tests.md). - **Tier 2 (firmware simulator)** — runs in `bitbox-simulator.yml`, not folded into the scoped coverage number. - **Tier 3 (Maestro handbook flows)** — runs in `tier3-handbook.yaml`, not folded in. diff --git a/assets/languages/strings_de.arb b/assets/languages/strings_de.arb index 6587b7800..3c912be3e 100644 --- a/assets/languages/strings_de.arb +++ b/assets/languages/strings_de.arb @@ -217,6 +217,10 @@ "paySwapping": "REALU wird in ZCHF getauscht", "payWaitingForEth": "Netzwerkgebühren werden angefordert", "pdf": "PDF", + "pendingTransactionDeactivate": "Angebot stornieren", + "pendingTransactionDeactivateConfirm": "Dieses ausstehende Kaufangebot stornieren?", + "pendingTransactionDeactivateFailed": "Das Angebot konnte nicht storniert werden. Bitte versuchen Sie es erneut.", + "pendingTransactionDetailTitle": "Ausstehende Transaktion", "pendingTransactions": "Ausstehende Transaktionen", "personalData": "Persönliche Daten", "phone": "Telefon", diff --git a/assets/languages/strings_en.arb b/assets/languages/strings_en.arb index 24998fbfb..1e99cfe16 100644 --- a/assets/languages/strings_en.arb +++ b/assets/languages/strings_en.arb @@ -217,6 +217,10 @@ "paySwapping": "Swapping REALU to ZCHF", "payWaitingForEth": "Requesting network fees", "pdf": "PDF", + "pendingTransactionDeactivate": "Cancel quote", + "pendingTransactionDeactivateConfirm": "Cancel this pending buy quote?", + "pendingTransactionDeactivateFailed": "The quote could not be cancelled. Please try again.", + "pendingTransactionDetailTitle": "Pending transaction", "pendingTransactions": "Pending transactions", "personalData": "Personal data", "phone": "Phone", diff --git a/docs/handbook/README.md b/docs/handbook/README.md index e929bad84..eb57bd945 100644 --- a/docs/handbook/README.md +++ b/docs/handbook/README.md @@ -21,7 +21,7 @@ deployten Image (`handbook.realunit.app`). ## Screenshots regenerieren -Es gibt keinen separaten Regeneration-Schritt: Die 279 Handbook-Screenshots +Es gibt keinen separaten Regeneration-Schritt: Die 282 Handbook-Screenshots sind direkt die Golden-Baselines unter `test/goldens/` (gemappt in `scripts/assemble-handbook-screenshots.sh`). Eine UI-Änderung an einer der gemappten Pages produziert beim `flutter test test/goldens` einen Diff — diff --git a/docs/handbook/de/index.html b/docs/handbook/de/index.html index f8e42e4c8..deda713f8 100644 --- a/docs/handbook/de/index.html +++ b/docs/handbook/de/index.html @@ -3833,7 +3833,55 @@

31Dashboard — Transaktionen

/>
- Unter dem Kurschart erscheint die Karte Ausstehende Transaktionen mit noch laufenden DFX-Aufträgen: Kauf / In Bearbeitung / 500 CHF und Verkauf / Warte auf Zahlung / 30 REALU, jeweils mit Datum und drehendem Ladeindikator. Der Nutzer sieht dies, solange ein Kauf oder Verkauf beim Zahlungsdienst noch nicht abgeschlossen ist; da hier noch kein Guthaben vorliegt, folgen darunter Token-Illustration und Kaufen-Button. + Unter dem Kurschart erscheint die Karte Ausstehende Transaktionen mit noch laufenden DFX-Aufträgen: Kauf / Warte auf Zahlung / 500 CHF (verbindlicher Kauf) und Verkauf / Warte auf Zahlung / 30 REALU, jeweils mit Datum und drehendem Ladeindikator. Die Zeilen sind tippbar und öffnen die Detailseite der jeweiligen Transaktion; es gibt kein X-Icon zum Stornieren auf der Zeile. Der Nutzer sieht dies, solange ein Kauf oder Verkauf beim Zahlungsdienst noch nicht abgeschlossen ist; da hier noch kein Guthaben vorliegt, folgen darunter Token-Illustration und Kaufen-Button. +
+ +
+
+ + + screens/dashboard/goldens/macos/pending_transaction_detail_buy_waiting.png +
+
+ Detailseite eines ausstehenden Kaufs mit Storno-Button +
+
+ Detailseite eines verbindlichen Kaufs im Status Warte auf Zahlung: Typ, Status, Betrag, Asset und ID der Transaktion. Unten sticky der sekundäre Button Angebot stornieren — nur hier, und nur nach dem verbindlichen Kauf, ist Storno erlaubt. +
+
+
+
+ + + screens/dashboard/goldens/macos/pending_transaction_detail_deactivate_overlay.png +
+
+ Bestätigungsdialog zum Stornieren des Kaufangebots +
+
+ Nach Tippen auf Angebot stornieren erscheint der Bestätigungsdialog mit Abbrechen und erneuter Storno-Aktion. Erst nach Bestätigung wird der Deactivate-Request gesendet. +
+
+
+
+ + + screens/dashboard/goldens/macos/pending_transaction_detail_not_cancellable.png +
+
+ Detailseite ohne Storno-Button (Verkauf oder in Bearbeitung) +
+
+ Detailseite einer ausstehenden Transaktion, die nicht stornierbar ist (z. B. Verkauf oder Kauf in Bearbeitung): dieselben Felder wie beim Kauf, aber ohne den Button Angebot stornieren.
diff --git a/docs/screens.md b/docs/screens.md index aceea585c..60e0012ea 100644 --- a/docs/screens.md +++ b/docs/screens.md @@ -16,7 +16,7 @@ Column meaning: screen, or `—` if the screen has no Golden baseline. Each slot is a Visual-Regression Golden under `test/goldens/`, mapped to its handbook position by `scripts/assemble-handbook-screenshots.sh`. The handbook now - covers **all 279 Golden baselines** — every screen **plus every tested + covers **all 282 Golden baselines** — every screen **plus every tested state variant** (Default / Loading / Error / Snackbar / Dropdown / Validation / Confirm / Success / Failure …), including the areas that were previously absent: Support (email capture, tickets, chat), Settings @@ -50,6 +50,7 @@ Column meaning: | PIN & lock | `VerifyPinPage` | `verifyPin` | `/verifyPin` | `86`, `87` | | Dashboard & trading | `DashboardPage` | `dashboard` | `/dashboard` | `35`, `89`, `90`, `91`, `92`, `93`, `94` | | Dashboard & trading | `TransactionHistoryPage` | `transactionHistory` | `/dashboard/transactionHistory` | `36`, `95`, `96`, `97`, `98`, `99`, `100` | +| Dashboard & trading | `PendingTransactionDetailPage` | `pendingTransaction` | `/dashboard/pendingTransaction` | `270`, `271`, `272` | | Dashboard & trading | `BuyPage` | `buy` | `/buy` | `44`, `45`, `46`, `47`, `48`, `103`, `104`, `105`, `106`, `107`, `108`, `109`, `110`, `111`, `112`, `113` | | Dashboard & trading | `BuyPaymentDetailsPage` | `buyPaymentDetails` | `/buyPaymentDetails` | `53`, `114`, `115`, `116`, `117` | | Dashboard & trading | `SellPage` | `sell` | `/sell` | `49`, `50`, `51`, `52`, `118`, `119`, `120`, `123`, `124`, `125` | @@ -116,7 +117,7 @@ Column meaning: | KYC | `KycMergeProcessingPage` | — | — | `210` | | Shared widgets | `PhoneNumberField` | — | — | `268` | -76 screens — 44 routed (`GoRoute`) + 32 non-routed. The table also carries +77 screens — 45 routed (`GoRoute`) + 32 non-routed. The table also carries one shared form-widget baseline (`PhoneNumberField`), which is not a screen. ## Notes @@ -136,7 +137,7 @@ one shared form-widget baseline (`PhoneNumberField`), which is not a screen. `243`–`245`. `SetupPinPage` also backs the `settingsChangePin` route (`/settings/security/changePin`) via a second constructor; that reuse has no separate Golden and is not given its own row. -- **Handbook numbering.** Each of the 279 handbook slots is a Visual-Regression +- **Handbook numbering.** Each of the 282 handbook slots is a Visual-Regression Golden under `test/goldens/`, mapped to its handbook position by `scripts/assemble-handbook-screenshots.sh`. A parallel Tier-3 Maestro flow (`.maestro/handbook/NN-*.yaml`) covers navigation/tap-routing smoke for the diff --git a/docs/testing.md b/docs/testing.md index aebb7ae14..aa2eca073 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -418,7 +418,7 @@ flutter test --coverage 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. +- **`Analyze & Test`** — the block above, then `scripts/check-touched-ui-coverage.sh` against the full `lcov.info` (100 % line coverage on every PR-touched UI file under `lib/screens/**` / `lib/widgets/**`), then 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`. @@ -429,6 +429,8 @@ Tier 3 runs separately under `tier3-handbook.yaml` (push to `develop`, manual, o 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). +**Touched UI 100 % rule.** Every PR-touched file under `lib/screens/**` or `lib/widgets/**` (pages, views, widgets — not cubit/bloc folders, not `*.g.dart`) must reach 100 % line coverage, measured per file. CI runs `scripts/check-touched-ui-coverage.sh coverage/lcov.info origin/` **after** `flutter test --coverage` and **before** `lcov --extract`, against the full tracefile so widget lines are still present. The packages/cubits/blocs floor is **not** widened to the whole widget tree — only files the PR actually changes are gated. A neglected UI file that is touched must be brought fully to 100 % in the same PR. + ## Surface that needs infra work before it can be unit-tested Some files are deliberately uncovered today because exercising them would change project architecture, not just add a test. Don't waste time stubbing around these without a focused infra PR first: diff --git a/lib/packages/service/dfx/models/transactions/dto/transactions_dto.dart b/lib/packages/service/dfx/models/transactions/dto/transactions_dto.dart index 2052e4e21..5bb2d487b 100644 --- a/lib/packages/service/dfx/models/transactions/dto/transactions_dto.dart +++ b/lib/packages/service/dfx/models/transactions/dto/transactions_dto.dart @@ -2,7 +2,8 @@ enum TransactionType { buy('Buy'), sell('Sell'), swap('Swap'), - referral('Referral'); + referral('Referral') + ; final String value; const TransactionType(this.value); @@ -10,9 +11,9 @@ enum TransactionType { static TransactionType? fromString(String? value) { if (value == null) return null; return TransactionType.values.cast().firstWhere( - (e) => e?.value == value, - orElse: () => null, - ); + (e) => e?.value == value, + orElse: () => null, + ); } } @@ -31,7 +32,8 @@ enum TransactionState { returnPending('ReturnPending'), returned('Returned'), unassigned('Unassigned'), - waitingForPayment('WaitingForPayment'); + waitingForPayment('WaitingForPayment') + ; final String value; const TransactionState(this.value); @@ -39,9 +41,9 @@ enum TransactionState { static TransactionState? fromString(String? value) { if (value == null) return null; return TransactionState.values.cast().firstWhere( - (e) => e?.value == value, - orElse: () => null, - ); + (e) => e?.value == value, + orElse: () => null, + ); } bool get isPending => this != completed && this != failed && this != returned; @@ -49,6 +51,7 @@ enum TransactionState { class TransactionDto { final int? id; + final String? uid; final TransactionType? type; final TransactionState? state; final double? rate; @@ -64,6 +67,7 @@ class TransactionDto { const TransactionDto({ this.id, + this.uid, this.type, this.state, this.rate, @@ -81,6 +85,7 @@ class TransactionDto { factory TransactionDto.fromJson(Map json) { return TransactionDto( id: json['id'] as int?, + uid: json['uid'] as String?, type: TransactionType.fromString(json['type'] as String?), state: TransactionState.fromString(json['state'] as String?), rate: (json['rate'] as num?)?.toDouble(), @@ -100,7 +105,6 @@ class TransactionDto { bool belongsToWallet(String walletAddress) { final address = walletAddress.toLowerCase(); - return sourceAccount?.toLowerCase() == address || - targetAccount?.toLowerCase() == address; + return sourceAccount?.toLowerCase() == address || targetAccount?.toLowerCase() == address; } } diff --git a/lib/packages/service/dfx/real_unit_buy_payment_info_service.dart b/lib/packages/service/dfx/real_unit_buy_payment_info_service.dart index 3c67cb137..385e50b8f 100644 --- a/lib/packages/service/dfx/real_unit_buy_payment_info_service.dart +++ b/lib/packages/service/dfx/real_unit_buy_payment_info_service.dart @@ -12,6 +12,7 @@ import 'package:realunit_wallet/styles/currency.dart'; class RealUnitBuyPaymentInfoService extends DFXAuthService { static const _buyPaymentInfoPath = '/v1/realunit/buy'; static String _confirmPaymentPath(int id) => '/v1/realunit/buy/$id/confirm'; + static String _deactivatePaymentPath(String idOrUid) => '/v1/realunit/buy/$idOrUid/deactivate'; RealUnitBuyPaymentInfoService(super.appStore, super.walletService); @@ -75,4 +76,13 @@ class RealUnitBuyPaymentInfoService extends DFXAuthService { final responseDto = RealUnitBuyConfirmDto.fromJson(json); return responseDto; } + + Future deactivateQuote(String idOrUid) async { + final uri = buildUri(host, _deactivatePaymentPath(idOrUid)); + final response = await authenticatedPut(uri); + if (response.statusCode != 200) { + final errorJson = jsonDecode(response.body) as Map; + throw ApiException.fromJson(errorJson, httpStatusCode: response.statusCode); + } + } } diff --git a/lib/packages/service/transaction_history_service.dart b/lib/packages/service/transaction_history_service.dart index 2212fa5d9..288eefbce 100644 --- a/lib/packages/service/transaction_history_service.dart +++ b/lib/packages/service/transaction_history_service.dart @@ -6,6 +6,7 @@ import 'package:realunit_wallet/models/transaction.dart'; import 'package:realunit_wallet/packages/config/api_config.dart'; import 'package:realunit_wallet/packages/repository/transaction_repository.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_auth_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart'; import 'package:realunit_wallet/packages/service/dfx/models/history/dto/account_history_dto.dart'; import 'package:realunit_wallet/packages/service/dfx/models/transactions/dto/transactions_dto.dart'; import 'package:web3dart/credentials.dart'; @@ -113,7 +114,13 @@ class TransactionHistoryService extends DFXAuthService { final uri = buildUri(host, '$_transactionsPath/detail'); final response = await authenticatedGet(uri); - if (response.statusCode != 200) return []; + if (response.statusCode != 200) { + throw ApiException( + statusCode: response.statusCode, + code: 'UNKNOWN', + message: 'Failed to load pending transactions', + ); + } final List json = jsonDecode(response.body); final transactions = json 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 8f1c51d15..8a1ddb6b5 100644 --- a/lib/screens/buy/cubits/buy_confirm/buy_confirm_cubit.dart +++ b/lib/screens/buy/cubits/buy_confirm/buy_confirm_cubit.dart @@ -25,6 +25,7 @@ class BuyConfirmCubit extends Cubit { super(const BuyConfirmInitial()); Future confirmPayment(int paymentInfoId) async { + if (state is BuyConfirmLoading) return; try { emit(const BuyConfirmLoading()); final dto = await _buyPaymentInfoService.confirmPayment(paymentInfoId); diff --git a/lib/screens/buy/widgets/buy_confirm_button.dart b/lib/screens/buy/widgets/buy_confirm_button.dart index f3702ff13..3dd508755 100644 --- a/lib/screens/buy/widgets/buy_confirm_button.dart +++ b/lib/screens/buy/widgets/buy_confirm_button.dart @@ -14,6 +14,9 @@ import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart'; /// confirms the purchase (binding) via [BuyConfirmCubit]; on success it opens /// the `Zahlungsdetails` page with the bank-transfer instructions, on failure /// it surfaces the typed error as a snackbar. +/// +/// Cancelling a quote is only available after a binding purchase, on the +/// pending-transaction detail page — not on this confirm screen. class BuyConfirmButton extends StatelessWidget { final BuyPaymentInfo buyPaymentInfo; @@ -65,7 +68,8 @@ 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.primaryEmailRequired => + S.of(context).buyPaymentConfirmFailedAktionariat, BuyConfirmError.unknown => S.of(context).buyPaymentConfirmFailed, }; ScaffoldMessenger.of(context).showSnackBar( @@ -77,9 +81,11 @@ class BuyConfirmButtonView extends StatelessWidget { return Padding( padding: const EdgeInsets.symmetric(vertical: 20), child: AppFilledButton( - onPressed: () => context.read().confirmPayment( - buyPaymentInfo.id, - ), + onPressed: state is BuyConfirmLoading + ? null + : () => context.read().confirmPayment( + buyPaymentInfo.id, + ), state: state is BuyConfirmLoading ? .loading : .idle, label: S.of(context).buyPaymentConfirm, ), diff --git a/lib/screens/dashboard/bloc/pending_transactions_cubit.dart b/lib/screens/dashboard/bloc/pending_transactions_cubit.dart index 7db47c801..47322f05f 100644 --- a/lib/screens/dashboard/bloc/pending_transactions_cubit.dart +++ b/lib/screens/dashboard/bloc/pending_transactions_cubit.dart @@ -10,14 +10,37 @@ class PendingTransactionsCubit extends Cubit> { } final TransactionHistoryService _transactionHistoryService; + int _loadGeneration = 0; + + Future reload() => _loadPendingTransactions(); + + /// Drops a quote from the local list after a successful cancel on the + /// detail page, so a failed reload cannot bring the cancelled row back. + void drop(String idOrUid) { + if (idOrUid.isEmpty) return; + emit(state.where((t) => (t.id?.toString() ?? t.uid) != idOrUid).toList()); + } + + /// Applies the detail-page pop result. Safe after the list view unmounts + /// because the cubit lives on [DashboardPage]. + Future applyDetailReturn(String? idOrUid) async { + if (isClosed) return; + if (idOrUid != null && idOrUid.isNotEmpty) { + drop(idOrUid); + } + await reload(); + } Future _loadPendingTransactions() async { + final generation = ++_loadGeneration; try { final transactions = await _transactionHistoryService.fetchPendingTransactions(); + if (isClosed || generation != _loadGeneration) return; emit(transactions); } catch (e) { developer.log('Failed to load pending transactions: $e', name: '$PendingTransactionsCubit'); - emit([]); + if (isClosed || generation != _loadGeneration) return; + if (state.isEmpty) emit([]); } } } diff --git a/lib/screens/dashboard/cubits/pending_transaction_detail/pending_transaction_detail_cubit.dart b/lib/screens/dashboard/cubits/pending_transaction_detail/pending_transaction_detail_cubit.dart new file mode 100644 index 000000000..280b23aab --- /dev/null +++ b/lib/screens/dashboard/cubits/pending_transaction_detail/pending_transaction_detail_cubit.dart @@ -0,0 +1,41 @@ +import 'dart:developer' as developer; + +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/transactions/dto/transactions_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_buy_payment_info_service.dart'; + +part 'pending_transaction_detail_state.dart'; + +class PendingTransactionDetailCubit extends Cubit { + final RealUnitBuyPaymentInfoService _buyPaymentInfoService; + + PendingTransactionDetailCubit(RealUnitBuyPaymentInfoService buyPaymentInfoService) + : _buyPaymentInfoService = buyPaymentInfoService, + super(const PendingTransactionDetailInitial()); + + /// Cancels a binding buy quote that is still waiting for payment. + /// + /// No-ops when the transaction is not a buy in `waitingForPayment`, or when + /// both `id` and `uid` are empty. Uses `id ?? uid` as the deactivate path id. + Future deactivate(TransactionDto transaction) async { + if (transaction.type != TransactionType.buy || + transaction.state != TransactionState.waitingForPayment) { + return; + } + final idOrUid = transaction.id?.toString() ?? transaction.uid; + if (idOrUid == null || idOrUid.isEmpty) return; + if (state is PendingTransactionDetailLoading) return; + + try { + emit(const PendingTransactionDetailLoading()); + await _buyPaymentInfoService.deactivateQuote(idOrUid); + if (isClosed) return; + emit(const PendingTransactionDetailSuccess()); + } catch (e) { + developer.log(e.toString()); + if (isClosed) return; + emit(const PendingTransactionDetailFailure()); + } + } +} diff --git a/lib/screens/dashboard/cubits/pending_transaction_detail/pending_transaction_detail_state.dart b/lib/screens/dashboard/cubits/pending_transaction_detail/pending_transaction_detail_state.dart new file mode 100644 index 000000000..14bc7ec08 --- /dev/null +++ b/lib/screens/dashboard/cubits/pending_transaction_detail/pending_transaction_detail_state.dart @@ -0,0 +1,24 @@ +part of 'pending_transaction_detail_cubit.dart'; + +abstract class PendingTransactionDetailState extends Equatable { + const PendingTransactionDetailState(); + + @override + List get props => []; +} + +class PendingTransactionDetailInitial extends PendingTransactionDetailState { + const PendingTransactionDetailInitial(); +} + +class PendingTransactionDetailLoading extends PendingTransactionDetailState { + const PendingTransactionDetailLoading(); +} + +class PendingTransactionDetailSuccess extends PendingTransactionDetailState { + const PendingTransactionDetailSuccess(); +} + +class PendingTransactionDetailFailure extends PendingTransactionDetailState { + const PendingTransactionDetailFailure(); +} diff --git a/lib/screens/dashboard/pending_transaction_detail_page.dart b/lib/screens/dashboard/pending_transaction_detail_page.dart new file mode 100644 index 000000000..bf534d550 --- /dev/null +++ b/lib/screens/dashboard/pending_transaction_detail_page.dart @@ -0,0 +1,259 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/transactions/dto/transactions_dto.dart'; +import 'package:realunit_wallet/screens/dashboard/cubits/pending_transaction_detail/pending_transaction_detail_cubit.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'; + +/// Detail page for a single pending transaction from the dashboard list. +/// +/// Quote cancellation is available only for binding buys that are still +/// waiting for payment (`type == buy` and `state == waitingForPayment`). +class PendingTransactionDetailPage extends StatelessWidget { + final TransactionDto transaction; + + const PendingTransactionDetailPage({ + super.key, + required this.transaction, + }); + + @override + Widget build(BuildContext context) { + return PendingTransactionDetailView(transaction: transaction); + } +} + +class PendingTransactionDetailView extends StatelessWidget { + final TransactionDto transaction; + + const PendingTransactionDetailView({ + super.key, + required this.transaction, + }); + + bool get _isCancellable => + transaction.type == TransactionType.buy && + transaction.state == TransactionState.waitingForPayment; + + @override + Widget build(BuildContext context) { + return BlocConsumer( + listener: (context, state) { + if (state is PendingTransactionDetailSuccess) { + context.pop(transaction.id?.toString() ?? transaction.uid); + } + if (state is PendingTransactionDetailFailure) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(S.of(context).pendingTransactionDeactivateFailed)), + ); + } + }, + builder: (context, state) { + final loading = state is PendingTransactionDetailLoading; + final idOrUid = transaction.id?.toString() ?? transaction.uid; + return PopScope( + canPop: !loading, + child: Scaffold( + appBar: AppBar( + title: Text( + S.of(context).pendingTransactionDetailTitle, + key: const ValueKey('pendingTxDetailTitle'), + ), + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + child: ScrollableActionsLayout( + body: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 16, + children: [ + Text( + _typeLabel(context), + key: const ValueKey('pendingTxDetailType'), + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + Text( + _statusLabel(context), + key: const ValueKey('pendingTxDetailStatus'), + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: RealUnitColors.neutral500, + ), + ), + if (transaction.inputAmount != null && transaction.inputAsset != null) + Text( + '${_formatAmount(transaction.inputAmount!)} ${transaction.inputAsset}', + key: const ValueKey('pendingTxDetailAmount'), + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ) + else ...[ + if (transaction.inputAmount != null) + Text( + _formatAmount(transaction.inputAmount!), + key: const ValueKey('pendingTxDetailAmount'), + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + if (transaction.inputAsset != null) + Text( + transaction.inputAsset!, + key: const ValueKey('pendingTxDetailAsset'), + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ], + if (transaction.date != null) + Text( + DateFormat('MMM dd, yyyy').format(transaction.date!.toLocal()), + key: const ValueKey('pendingTxDetailDate'), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: RealUnitColors.neutral500, + ), + ), + if (idOrUid != null && idOrUid.isNotEmpty) + Text( + idOrUid, + key: const ValueKey('pendingTxDetailId'), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: RealUnitColors.neutral500, + ), + ), + ], + ), + actions: _isCancellable + ? [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: AppFilledButton( + variant: FilledButtonVariant.secondary, + label: S.of(context).pendingTransactionDeactivate, + state: loading ? .loading : .idle, + onPressed: loading ? null : () => _confirmAndDeactivate(context), + ), + ), + ] + : const [], + ), + ), + ), + ), + ); + }, + ); + } + + String _typeLabel(BuildContext context) { + return switch (transaction.type) { + TransactionType.buy => S.of(context).transactionBuy, + TransactionType.sell => S.of(context).transactionSell, + _ => transaction.type?.value ?? '—', + }; + } + + String _statusLabel(BuildContext context) { + if (transaction.state == TransactionState.waitingForPayment) { + return S.of(context).transactionWaitingForPayment; + } + return S.of(context).transactionPending; + } + + String _formatAmount(double amount) { + if (amount == amount.roundToDouble()) { + return amount.toInt().toString(); + } + return amount.toStringAsFixed(2); + } + + Future _confirmAndDeactivate(BuildContext context) async { + final confirmed = await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (sheetContext) => CancelQuoteConfirmSheet( + // Page context, not sheetContext: Alchemist overlay routes do not inherit + // localizations. + strings: S.of(context), + ), + ); + if (confirmed != true || !context.mounted) return; + await context.read().deactivate(transaction); + } +} + +/// Confirm sheet for cancelling a waiting buy quote. +/// +/// Takes [S] from the page context so Alchemist overlay routes (which do not +/// inherit localizations) can still golden the same widget. +class CancelQuoteConfirmSheet extends StatelessWidget { + final S strings; + + const CancelQuoteConfirmSheet({ + super.key, + required this.strings, + }); + + @override + Widget build(BuildContext context) { + return SafeArea( + 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, + ), + child: ScrollableActionsLayout( + shrinkWrap: true, + padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 20), + body: Text( + strings.pendingTransactionDeactivateConfirm, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: RealUnitColors.neutral500, + ), + ), + actions: [ + Padding( + padding: const EdgeInsets.only(top: 24), + child: Row( + spacing: 12, + children: [ + Expanded( + child: AppFilledButton( + variant: FilledButtonVariant.secondary, + onPressed: () => Navigator.of(context).pop(false), + label: strings.cancel, + ), + ), + Expanded( + child: AppFilledButton( + onPressed: () => Navigator.of(context).pop(true), + label: strings.pendingTransactionDeactivate, + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/dashboard/widgets/pending_transaction_row.dart b/lib/screens/dashboard/widgets/pending_transaction_row.dart index 6cc57035f..cc3726e92 100644 --- a/lib/screens/dashboard/widgets/pending_transaction_row.dart +++ b/lib/screens/dashboard/widgets/pending_transaction_row.dart @@ -7,17 +7,19 @@ import 'package:realunit_wallet/styles/colors.dart'; class PendingTransactionRow extends StatelessWidget { final TransactionDto transaction; + final VoidCallback? onTap; const PendingTransactionRow({ super.key, required this.transaction, + this.onTap, }); bool get _isBuy => transaction.type == TransactionType.buy; @override Widget build(BuildContext context) { - return Container( + final content = Container( decoration: BoxDecoration( borderRadius: .circular(20), color: RealUnitColors.basic.white, @@ -85,6 +87,17 @@ class PendingTransactionRow extends StatelessWidget { ], ), ); + + if (onTap == null) return content; + + return Material( + color: Colors.transparent, + child: InkWell( + borderRadius: .circular(20), + onTap: onTap, + child: content, + ), + ); } String _formatAmount(double amount) { diff --git a/lib/screens/dashboard/widgets/sections/dashboard_pending_transactions.dart b/lib/screens/dashboard/widgets/sections/dashboard_pending_transactions.dart index d8955e8c7..6f733fcba 100644 --- a/lib/screens/dashboard/widgets/sections/dashboard_pending_transactions.dart +++ b/lib/screens/dashboard/widgets/sections/dashboard_pending_transactions.dart @@ -1,27 +1,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; import 'package:realunit_wallet/generated/i18n.dart'; import 'package:realunit_wallet/packages/service/dfx/models/transactions/dto/transactions_dto.dart'; -import 'package:realunit_wallet/packages/service/transaction_history_service.dart'; import 'package:realunit_wallet/screens/dashboard/bloc/pending_transactions_cubit.dart'; import 'package:realunit_wallet/screens/dashboard/widgets/pending_transaction_row.dart'; -import 'package:realunit_wallet/setup/di.dart'; +import 'package:realunit_wallet/setup/routing/routes/app_routes.dart'; import 'package:realunit_wallet/styles/colors.dart'; -class DashboardPendingTransactions extends StatelessWidget { - const DashboardPendingTransactions({super.key}); - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (context) => PendingTransactionsCubit( - getIt(), - ), - child: const DashboardPendingTransactionsView(), - ); - } -} - class DashboardPendingTransactionsView extends StatelessWidget { const DashboardPendingTransactionsView({super.key}); @@ -50,7 +36,22 @@ class DashboardPendingTransactionsView extends StatelessWidget { ), child: Column( spacing: 12.0, - children: transactions.map((t) => PendingTransactionRow(transaction: t)).toList(), + children: transactions + .map( + (t) => PendingTransactionRow( + key: ValueKey('pendingTx-${t.id?.toString() ?? t.uid}'), + transaction: t, + onTap: () async { + final pending = context.read(); + final removed = await context.pushNamed( + AppRoutes.pendingTransaction, + extra: t, + ); + await pending.applyDetailReturn(removed); + }, + ), + ) + .toList(), ), ), ], diff --git a/lib/setup/routing/router_config.dart b/lib/setup/routing/router_config.dart index 72c9d050f..052ba4c10 100644 --- a/lib/setup/routing/router_config.dart +++ b/lib/setup/routing/router_config.dart @@ -1,12 +1,17 @@ import 'package:flutter/foundation.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:realunit_wallet/generated/i18n.dart'; import 'package:realunit_wallet/packages/service/dfx/models/payment/sell/sell_payment_info.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/transactions/dto/transactions_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_buy_payment_info_service.dart'; import 'package:realunit_wallet/packages/wallet/wallet.dart'; import 'package:realunit_wallet/screens/buy/buy_page.dart'; import 'package:realunit_wallet/screens/buy/buy_payment_details_page.dart'; import 'package:realunit_wallet/screens/create_wallet/create_wallet_page.dart'; +import 'package:realunit_wallet/screens/dashboard/cubits/pending_transaction_detail/pending_transaction_detail_cubit.dart'; import 'package:realunit_wallet/screens/dashboard/dashboard_page.dart'; +import 'package:realunit_wallet/screens/dashboard/pending_transaction_detail_page.dart'; import 'package:realunit_wallet/screens/debug_auth/debug_auth_page.dart'; import 'package:realunit_wallet/screens/hardware_connect_bitbox/bitbox_address_recovery_page.dart'; import 'package:realunit_wallet/screens/home/home_page.dart'; @@ -47,6 +52,7 @@ import 'package:realunit_wallet/screens/transaction_history/transaction_history_ import 'package:realunit_wallet/screens/verify_seed/verify_seed_page.dart'; import 'package:realunit_wallet/screens/web_view/web_view_page.dart'; import 'package:realunit_wallet/screens/welcome/welcome_page.dart'; +import 'package:realunit_wallet/setup/di.dart'; import 'package:realunit_wallet/setup/routing/boot_navigation.dart'; import 'package:realunit_wallet/setup/routing/routes/app_link_entry.dart'; import 'package:realunit_wallet/setup/routing/routes/app_routes.dart'; @@ -148,6 +154,18 @@ final GoRouter routerConfig = GoRouter( path: 'transactionHistory', builder: (_, _) => const TransactionHistoryPage(), ), + GoRoute( + name: AppRoutes.pendingTransaction, + path: 'pendingTransaction', + builder: (_, state) => BlocProvider( + create: (_) => PendingTransactionDetailCubit( + getIt(), + ), + child: PendingTransactionDetailPage( + transaction: state.extra as TransactionDto, + ), + ), + ), ], ), diff --git a/lib/setup/routing/routes/app_routes.dart b/lib/setup/routing/routes/app_routes.dart index d0ebf58ff..08ce6756f 100644 --- a/lib/setup/routing/routes/app_routes.dart +++ b/lib/setup/routing/routes/app_routes.dart @@ -2,6 +2,7 @@ abstract final class AppRoutes { static const home = 'home'; static const dashboard = 'dashboard'; static const transactionHistory = 'transactionHistory'; + static const pendingTransaction = 'pendingTransaction'; static const buy = 'buy'; static const buyPaymentDetails = 'buyPaymentDetails'; static const sell = 'sell'; diff --git a/scripts/assemble-handbook-screenshots.sh b/scripts/assemble-handbook-screenshots.sh index 33e716baa..276294dc9 100755 --- a/scripts/assemble-handbook-screenshots.sh +++ b/scripts/assemble-handbook-screenshots.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # -# Assemble the 279 handbook screenshots from the visual-regression Golden +# Assemble the 282 handbook screenshots from the visual-regression Golden # baselines. The flat `NN-name.png` output layout matches what # docs/handbook/de/index.html links to (`` # — the relative path resolves to `docs/handbook/screenshots/NN-name.png`). @@ -317,6 +317,9 @@ MAPPING=( "267-debug-auth-page-authenticating=screens/debug_auth/goldens/macos/debug_auth_page_authenticating.png" "268-phone-number-field-default=widgets/form/goldens/macos/phone_number_field_default.png" "269-dashboard-insider-unlocked=screens/dashboard/goldens/macos/dashboard_insider_unlocked.png" + "270-pending-transaction-detail-buy-waiting=screens/dashboard/goldens/macos/pending_transaction_detail_buy_waiting.png" + "271-pending-transaction-detail-deactivate-overlay=screens/dashboard/goldens/macos/pending_transaction_detail_deactivate_overlay.png" + "272-pending-transaction-detail-not-cancellable=screens/dashboard/goldens/macos/pending_transaction_detail_not_cancellable.png" ) missing=() diff --git a/scripts/check-touched-ui-coverage.sh b/scripts/check-touched-ui-coverage.sh new file mode 100644 index 000000000..78eb43d6e --- /dev/null +++ b/scripts/check-touched-ui-coverage.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +# +# Enforce 100% line coverage on every UI file touched by the PR. +# +# Scope: paths under lib/screens/** or lib/widgets/** that are not cubit/bloc +# folders and not generated *.g.dart. Measured against the FULL lcov.info +# (before the packages/cubits/blocs scope extract). The existing floor gate on +# packages/cubits/blocs is unchanged and is NOT widened to the whole widget tree. +# +# Usage: check-touched-ui-coverage.sh +# +# Exit codes: +# 0 — no touched UI files, or every touched UI file has LH==LF and LF>0 +# 1 — a touched UI file is missing from lcov or has uncovered lines +set -euo pipefail + +TRACEFILE="${1:?usage: check-touched-ui-coverage.sh }" +BASE="${2:?usage: check-touched-ui-coverage.sh }" + +if [ ! -f "$TRACEFILE" ]; then + echo "error: lcov file '$TRACEFILE' not found" >&2 + exit 1 +fi + +export LC_ALL=C + +# Collect touched UI files relative to the merge base. Triple-dot uses the +# merge-base of BASE and HEAD so the set matches the PR diff. +if ! git merge-base "$BASE" HEAD >/dev/null 2>&1; then + echo "error: no merge base between $BASE and HEAD — fetch more history" >&2 + exit 1 +fi + +touched="$(mktemp)" +trap 'rm -f "$touched"' EXIT + +# ACMR: Added/Copied/Modified/Renamed — exclude Deletes so a removed UI file +# is not required to appear in lcov. +git diff --diff-filter=ACMR --name-only "${BASE}...HEAD" | while IFS= read -r f; do + case "$f" in + lib/screens/*|lib/widgets/*) ;; + *) continue ;; + esac + case "$f" in + *.g.dart) continue ;; + */cubit/*|*/cubits/*|*/bloc/*) continue ;; + esac + case "$f" in + *.dart) printf '%s\n' "$f" ;; + esac +done | sort -u > "$touched" + +if [ ! -s "$touched" ]; then + echo "No touched UI files under lib/screens/** or lib/widgets/** — OK" + exit 0 +fi + +failed=0 + +while IFS= read -r file; do + # Skip paths that no longer exist on disk (e.g. renamed away mid-diff). + if [ ! -f "$file" ]; then + continue + fi + + # Normalise SF: paths the same way as check-coverage-visibility.sh: + # absolute .../lib/foo → lib/foo; relative lib/foo passes through. + section="$( + awk -v target="$file" ' + BEGIN { capturing = 0; block = "" } + /^SF:/ { + path = substr($0, 4) + sub(/^\/.*\/lib\//, "lib/", path) + if (path == target) { + capturing = 1 + block = $0 "\n" + } else { + capturing = 0 + } + next + } + capturing { + block = block $0 "\n" + if ($0 == "end_of_record") { + printf "%s", block + exit 0 + } + } + ' "$TRACEFILE" + )" + + if [ -z "$section" ]; then + echo "error: touched UI file missing from lcov: $file" >&2 + failed=1 + continue + fi + + lh="$(printf '%s' "$section" | sed -nE 's/^LH:([0-9]+).*/\1/p' | head -n1)" + lf="$(printf '%s' "$section" | sed -nE 's/^LF:([0-9]+).*/\1/p' | head -n1)" + + if [ -z "${lh:-}" ] || [ -z "${lf:-}" ]; then + echo "error: could not parse LH/LF for $file" >&2 + failed=1 + continue + fi + + if [ "$lf" -eq 0 ]; then + echo "error: $file has LF=0 (no instrumented lines) in lcov" >&2 + failed=1 + continue + fi + + if [ "$lh" -ne "$lf" ]; then + echo "error: $file line coverage ${lh}/${lf} is below 100%" >&2 + printf '%s' "$section" | grep -E '^DA:[0-9]+,0$' >&2 || true + failed=1 + continue + fi + + echo "OK: $file ${lh}/${lf}" +done < "$touched" + +if [ "$failed" -ne 0 ]; then + echo "error: one or more touched UI files lack 100% line coverage" >&2 + exit 1 +fi + +echo "All touched UI files have 100% line coverage" +exit 0 diff --git a/test/goldens/screens/dashboard/dashboard_states_golden_test.dart b/test/goldens/screens/dashboard/dashboard_states_golden_test.dart index eabea2a80..d10adfcee 100644 --- a/test/goldens/screens/dashboard/dashboard_states_golden_test.dart +++ b/test/goldens/screens/dashboard/dashboard_states_golden_test.dart @@ -149,11 +149,13 @@ void main() { buy('0xrecent3', 100, DateTime.utc(2026, 5, 15, 9, 15)), ]; + // First row is a binding buy still waiting for payment (cancellable only + // from the detail page). Rows are tappable; there is no close IconButton. final pendingTransactions = [ TransactionDto( id: 1, type: TransactionType.buy, - state: TransactionState.processing, + state: TransactionState.waitingForPayment, inputAmount: 500, inputAsset: 'CHF', date: DateTime.utc(2026, 5, 21, 8), diff --git a/test/goldens/screens/dashboard/goldens/macos/dashboard_pending_transactions.png b/test/goldens/screens/dashboard/goldens/macos/dashboard_pending_transactions.png index a902fce68..c3a0814eb 100644 Binary files a/test/goldens/screens/dashboard/goldens/macos/dashboard_pending_transactions.png and b/test/goldens/screens/dashboard/goldens/macos/dashboard_pending_transactions.png differ diff --git a/test/goldens/screens/dashboard/goldens/macos/pending_transaction_detail_buy_waiting.png b/test/goldens/screens/dashboard/goldens/macos/pending_transaction_detail_buy_waiting.png new file mode 100644 index 000000000..16a722172 Binary files /dev/null and b/test/goldens/screens/dashboard/goldens/macos/pending_transaction_detail_buy_waiting.png differ diff --git a/test/goldens/screens/dashboard/goldens/macos/pending_transaction_detail_deactivate_overlay.png b/test/goldens/screens/dashboard/goldens/macos/pending_transaction_detail_deactivate_overlay.png new file mode 100644 index 000000000..865bf7a9d Binary files /dev/null and b/test/goldens/screens/dashboard/goldens/macos/pending_transaction_detail_deactivate_overlay.png differ diff --git a/test/goldens/screens/dashboard/goldens/macos/pending_transaction_detail_not_cancellable.png b/test/goldens/screens/dashboard/goldens/macos/pending_transaction_detail_not_cancellable.png new file mode 100644 index 000000000..5447711bd Binary files /dev/null and b/test/goldens/screens/dashboard/goldens/macos/pending_transaction_detail_not_cancellable.png differ diff --git a/test/goldens/screens/dashboard/pending_transaction_detail_golden_test.dart b/test/goldens/screens/dashboard/pending_transaction_detail_golden_test.dart new file mode 100644 index 000000000..0e655df56 --- /dev/null +++ b/test/goldens/screens/dashboard/pending_transaction_detail_golden_test.dart @@ -0,0 +1,76 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/transactions/dto/transactions_dto.dart'; +import 'package:realunit_wallet/screens/dashboard/cubits/pending_transaction_detail/pending_transaction_detail_cubit.dart'; +import 'package:realunit_wallet/screens/dashboard/pending_transaction_detail_page.dart'; + +import '../../../helper/helper.dart'; + +class _MockDetailCubit extends MockCubit + implements PendingTransactionDetailCubit {} + +void main() { + late _MockDetailCubit cubit; + + const buyWaiting = TransactionDto( + id: 1, + type: TransactionType.buy, + state: TransactionState.waitingForPayment, + inputAmount: 500, + inputAsset: 'CHF', + date: null, + ); + + const sellProcessing = TransactionDto( + id: 2, + type: TransactionType.sell, + state: TransactionState.processing, + inputAmount: 30, + inputAsset: 'REALU', + date: null, + ); + + setUp(() { + cubit = _MockDetailCubit(); + when(() => cubit.state).thenReturn(const PendingTransactionDetailInitial()); + when(() => cubit.deactivate(any())).thenAnswer((_) async {}); + }); + + setUpAll(() { + registerFallbackValue(buyWaiting); + }); + + Widget buildSubject(TransactionDto tx) => BlocProvider.value( + value: cubit, + child: PendingTransactionDetailView(transaction: tx), + ); + + group('$PendingTransactionDetailView', () { + goldenTest( + 'buy waiting for payment — fields + cancel CTA', + fileName: 'pending_transaction_detail_buy_waiting', + constraints: phoneConstraints, + builder: () => wrapForGolden(buildSubject(buyWaiting)), + ); + + goldenTest( + 'deactivate confirm sheet', + fileName: 'pending_transaction_detail_deactivate_overlay', + constraints: phoneConstraints, + builder: () => wrapForGolden( + CancelQuoteConfirmSheet(strings: S.current), + ), + ); + + goldenTest( + 'sell / not cancellable — no cancel CTA', + fileName: 'pending_transaction_detail_not_cancellable', + constraints: phoneConstraints, + builder: () => wrapForGolden(buildSubject(sellProcessing)), + ); + }); +} diff --git a/test/helper/responsive_surface_catalog.dart b/test/helper/responsive_surface_catalog.dart index 8c35aa01c..34f8f4796 100644 --- a/test/helper/responsive_surface_catalog.dart +++ b/test/helper/responsive_surface_catalog.dart @@ -64,36 +64,31 @@ const kResponsiveSurfaceCatalog = [ ResponsiveSurface( id: 'kyc_completed_page', description: 'KYC completed status page', - matrixTestPath: - 'test/screens/kyc/subpages/kyc_status_pages_responsive_matrix_test.dart', + matrixTestPath: 'test/screens/kyc/subpages/kyc_status_pages_responsive_matrix_test.dart', productionPath: 'lib/screens/kyc/subpages/kyc_completed_page.dart', ), ResponsiveSurface( id: 'kyc_manual_review_page', description: 'KYC manual review status page', - matrixTestPath: - 'test/screens/kyc/subpages/kyc_status_pages_responsive_matrix_test.dart', + matrixTestPath: 'test/screens/kyc/subpages/kyc_status_pages_responsive_matrix_test.dart', productionPath: 'lib/screens/kyc/subpages/kyc_manual_review_page.dart', ), ResponsiveSurface( id: 'kyc_pending_page', description: 'KYC pending status page', - matrixTestPath: - 'test/screens/kyc/subpages/kyc_status_pages_responsive_matrix_test.dart', + matrixTestPath: 'test/screens/kyc/subpages/kyc_status_pages_responsive_matrix_test.dart', productionPath: 'lib/screens/kyc/subpages/kyc_pending_page.dart', ), ResponsiveSurface( id: 'kyc_unsupported_step_page', description: 'KYC unsupported-step handoff page', - matrixTestPath: - 'test/screens/kyc/subpages/kyc_status_pages_responsive_matrix_test.dart', + matrixTestPath: 'test/screens/kyc/subpages/kyc_status_pages_responsive_matrix_test.dart', productionPath: 'lib/screens/kyc/subpages/kyc_unsupported_step_page.dart', ), ResponsiveSurface( id: 'kyc_personal_data_missing_user_data_page', description: 'KYC personal-data missing-payload refresh page', - matrixTestPath: - 'test/screens/kyc/subpages/kyc_status_pages_responsive_matrix_test.dart', + matrixTestPath: 'test/screens/kyc/subpages/kyc_status_pages_responsive_matrix_test.dart', productionPath: 'lib/screens/kyc/steps/personal_data/kyc_personal_data_page.dart', ), ResponsiveSurface( @@ -125,15 +120,13 @@ const kResponsiveSurfaceCatalog = [ ResponsiveSurface( id: 'onboarding_completed_page', description: 'Onboarding completed page', - matrixTestPath: - 'test/screens/onboarding/onboarding_completed_responsive_matrix_test.dart', + matrixTestPath: 'test/screens/onboarding/onboarding_completed_responsive_matrix_test.dart', productionPath: 'lib/screens/onboarding/onboarding_completed_page.dart', ), ResponsiveSurface( id: 'support_create_ticket_page', description: 'Support create ticket page', - matrixTestPath: - 'test/screens/support/support_create_ticket_responsive_matrix_test.dart', + matrixTestPath: 'test/screens/support/support_create_ticket_responsive_matrix_test.dart', productionPath: 'lib/screens/support/subpages/support_create_ticket_page.dart', ), ResponsiveSurface( @@ -194,6 +187,18 @@ const kResponsiveSurfaceCatalog = [ matrixTestPath: 'test/screens/buy/buy_responsive_matrix_test.dart', productionPath: 'lib/screens/buy/buy_page.dart', ), + ResponsiveSurface( + id: 'pending_transaction_detail', + description: 'Pending transaction detail page (cancel-quote CTA)', + matrixTestPath: 'test/screens/dashboard/pending_transaction_detail_responsive_matrix_test.dart', + productionPath: 'lib/screens/dashboard/pending_transaction_detail_page.dart', + ), + ResponsiveSurface( + id: 'cancel_quote_confirm_sheet', + description: 'Cancel-quote confirm bottom sheet (shrinkWrap mode)', + matrixTestPath: 'test/screens/dashboard/cancel_quote_confirm_sheet_responsive_matrix_test.dart', + productionPath: 'lib/screens/dashboard/pending_transaction_detail_page.dart', + ), ResponsiveSurface( id: 'pay_quote_page', description: 'Pay quote page (confirm CTA)', @@ -221,8 +226,7 @@ const kResponsiveSurfaceCatalog = [ ResponsiveSurface( id: 'send_process_result_sheet', description: 'Send process terminal result sheet (shrinkWrap mode)', - matrixTestPath: - 'test/screens/send/send_process_result_sheet_responsive_matrix_test.dart', + matrixTestPath: 'test/screens/send/send_process_result_sheet_responsive_matrix_test.dart', productionPath: 'lib/screens/send/send_process_page.dart', ), ResponsiveSurface( @@ -245,9 +249,11 @@ const kResponsiveSurfaceCatalog = [ ), ResponsiveSurface( id: 'kyc_signature_unsupported_page', - description: 'KYC signature-unsupported status page (no CTA — gates overflow + message reachability only)', + description: + 'KYC signature-unsupported status page (no CTA — gates overflow + message reachability only)', matrixTestPath: 'test/screens/kyc/kyc_static_pages_responsive_matrix_test.dart', - productionPath: 'lib/screens/kyc/steps/signature_unsupported/kyc_signature_unsupported_page.dart', + productionPath: + 'lib/screens/kyc/steps/signature_unsupported/kyc_signature_unsupported_page.dart', ), ResponsiveSurface( id: 'sell_confirm_sheet', diff --git a/test/packages/service/dfx/models/transactions/transactions_dto_test.dart b/test/packages/service/dfx/models/transactions/transactions_dto_test.dart index d9ce95e75..5c58e3089 100644 --- a/test/packages/service/dfx/models/transactions/transactions_dto_test.dart +++ b/test/packages/service/dfx/models/transactions/transactions_dto_test.dart @@ -45,7 +45,8 @@ void main() { test('all other states ARE pending', () { for (final s in TransactionState.values) { - final shouldBePending = s != TransactionState.completed && + final shouldBePending = + s != TransactionState.completed && s != TransactionState.failed && s != TransactionState.returned; expect(s.isPending, shouldBePending, reason: 'state=$s'); @@ -58,6 +59,7 @@ void main() { test('parses a complete row with all fields populated', () { final dto = TransactionDto.fromJson({ 'id': 42, + 'uid': 'quote-uid-42', 'type': 'Buy', 'state': 'Processing', 'rate': 1.05, @@ -73,6 +75,7 @@ void main() { }); expect(dto.id, 42); + expect(dto.uid, 'quote-uid-42'); expect(dto.type, TransactionType.buy); expect(dto.state, TransactionState.processing); expect(dto.rate, 1.05); @@ -88,6 +91,7 @@ void main() { test('every field is optional (all nulls produce all-null dto)', () { final dto = TransactionDto.fromJson({ 'id': null, + 'uid': null, 'type': null, 'state': null, 'rate': null, @@ -103,12 +107,27 @@ void main() { }); expect(dto.id, isNull); + expect(dto.uid, isNull); expect(dto.type, isNull); expect(dto.state, isNull); expect(dto.rate, isNull); expect(dto.date, isNull); }); + test('waiting quote has null id and a uid', () { + final dto = TransactionDto.fromJson({ + 'id': null, + 'uid': 'waiting-uid', + 'type': 'Buy', + 'state': 'WaitingForPayment', + }); + + expect(dto.id, isNull); + expect(dto.uid, 'waiting-uid'); + expect(dto.type, TransactionType.buy); + expect(dto.state, TransactionState.waitingForPayment); + }); + test('integer numeric fields are widened to double', () { final dto = TransactionDto.fromJson({ 'rate': 1, // integer on the wire diff --git a/test/packages/service/dfx/real_unit_buy_payment_info_service_test.dart b/test/packages/service/dfx/real_unit_buy_payment_info_service_test.dart index 32daf97d9..c34fbb54e 100644 --- a/test/packages/service/dfx/real_unit_buy_payment_info_service_test.dart +++ b/test/packages/service/dfx/real_unit_buy_payment_info_service_test.dart @@ -348,6 +348,61 @@ void main() { }); }); + group('deactivateQuote', () { + test('PUTs /v1/realunit/buy/{id}/deactivate with Authorization and empty 200', () async { + String? capturedMethod; + String? capturedPath; + Map? capturedHeaders; + String? capturedBody; + final appStore = buildAppStore((request) async { + capturedMethod = request.method; + capturedPath = request.url.path; + capturedHeaders = request.headers; + capturedBody = request.body; + return http.Response('', 200); + }); + + service = RealUnitBuyPaymentInfoService(appStore, walletService); + await service.deactivateQuote('123'); + + expect(capturedMethod, 'PUT'); + expect(capturedPath, '/v1/realunit/buy/123/deactivate'); + expect(capturedHeaders!['Authorization'], 'Bearer test-auth-token'); + expect(capturedBody, isEmpty); + }); + + test('works with a uid path', () async { + String? capturedPath; + final appStore = buildAppStore((request) async { + capturedPath = request.url.path; + return http.Response('', 200); + }); + + service = RealUnitBuyPaymentInfoService(appStore, walletService); + await service.deactivateQuote('abc-uid-1'); + + expect(capturedPath, '/v1/realunit/buy/abc-uid-1/deactivate'); + }); + + test('throws ApiException on non-200 status code', () async { + final appStore = buildAppStore( + (request) async => http.Response( + '{"statusCode": 404, "message": "Not found"}', + 404, + ), + ); + + service = RealUnitBuyPaymentInfoService(appStore, walletService); + + await expectLater( + service.deactivateQuote('999'), + throwsA( + isA().having((e) => e.statusCode, 'statusCode', 404), + ), + ); + }); + }); + group('malformed JSON responses', () { test('confirmPayment with non-JSON 200 throws FormatException', () async { final appStore = buildAppStore( diff --git a/test/packages/service/transaction_history_service_test.dart b/test/packages/service/transaction_history_service_test.dart index ada4cebb4..73d188d96 100644 --- a/test/packages/service/transaction_history_service_test.dart +++ b/test/packages/service/transaction_history_service_test.dart @@ -9,6 +9,7 @@ import 'package:realunit_wallet/packages/config/network_mode.dart'; import 'package:realunit_wallet/packages/repository/cache_repository.dart'; import 'package:realunit_wallet/packages/repository/transaction_repository.dart'; import 'package:realunit_wallet/packages/service/app_store.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart'; import 'package:realunit_wallet/packages/service/session_cache.dart'; import 'package:realunit_wallet/packages/service/transaction_history_service.dart'; import 'package:realunit_wallet/packages/service/wallet_service.dart'; @@ -91,13 +92,14 @@ void main() { expect(path, '/v1/transaction/detail'); }); - test('returns [] on non-200 (does not throw)', () async { + test('throws ApiException on non-200', () async { sessionCache.setAuthToken('jwt-1'); final client = MockClient((_) async => http.Response('boom', 500)); - final list = await build(client).fetchPendingTransactions(); - - expect(list, isEmpty); + await expectLater( + build(client).fetchPendingTransactions(), + throwsA(isA()), + ); }); test('filters out completed transactions (isPending=false)', () async { diff --git a/test/screens/buy/buy_responsive_matrix_test.dart b/test/screens/buy/buy_responsive_matrix_test.dart index e89f7aaaf..1dbea47df 100644 --- a/test/screens/buy/buy_responsive_matrix_test.dart +++ b/test/screens/buy/buy_responsive_matrix_test.dart @@ -13,6 +13,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:get_it/get_it.dart'; +import 'package:go_router/go_router.dart'; import 'package:mocktail/mocktail.dart'; import 'package:realunit_wallet/generated/i18n.dart'; import 'package:realunit_wallet/packages/config/api_config.dart'; @@ -21,12 +22,15 @@ import 'package:realunit_wallet/packages/repository/supported_fiat_repository.da import 'package:realunit_wallet/packages/service/app_store.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_brokerbot_service.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_price_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/buy/buy_payment_info.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/buy/dto/real_unit_buy_confirm_dto.dart'; import 'package:realunit_wallet/packages/service/dfx/models/payment/payment_info_error.dart'; import 'package:realunit_wallet/packages/service/dfx/real_unit_buy_payment_info_service.dart'; import 'package:realunit_wallet/packages/service/session_cache.dart'; import 'package:realunit_wallet/screens/buy/buy_page.dart'; import 'package:realunit_wallet/screens/buy/cubits/buy_converter/buy_converter_cubit.dart'; import 'package:realunit_wallet/screens/buy/cubits/buy_payment_info/buy_payment_info_cubit.dart'; +import 'package:realunit_wallet/setup/routing/routes/app_routes.dart'; import 'package:realunit_wallet/styles/currency.dart'; import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart'; @@ -104,6 +108,32 @@ void main() { ); } + /// Success CTA: real BuyConfirmCubit via GetIt service + GoRouter for + /// confirm navigation. Cancellation is not on this screen. + void stubSuccessCtaState() { + when(() => buyPaymentInfoCubit.state).thenReturn( + const BuyPaymentInfoSuccess( + BuyPaymentInfo( + amount: 300, + id: 1, + iban: 'iban', + bic: 'bic', + name: 'name', + street: 'street', + number: 'number', + zip: 'zip', + city: 'city', + country: 'country', + currency: Currency.chf, + ), + ), + ); + final buyService = GetIt.instance(); + when(() => buyService.confirmPayment(any())).thenAnswer( + (_) async => const RealUnitBuyConfirmDto(reference: 'REF-456'), + ); + } + Widget buildSubject() { return MultiBlocProvider( providers: [ @@ -143,6 +173,63 @@ void main() { await tester.pump(const Duration(milliseconds: 50)); } + /// Success host needs GoRouter: confirm tap pushes buyPaymentDetails. + Future pumpSuccessScreen( + WidgetTester tester, + MatrixCell cell, { + MediaQueryData? mediaQueryOverride, + }) async { + final mediaQuery = mediaQueryOverride ?? cell.mediaQuery; + await tester.binding.setSurfaceSize(mediaQuery.size); + addTearDown(() async => await tester.binding.setSurfaceSize(null)); + + final router = GoRouter( + initialLocation: '/parent/child', + routes: [ + GoRoute( + path: '/parent', + builder: (_, _) => const Scaffold(body: Text('parent-marker')), + routes: [ + GoRoute( + path: 'child', + builder: (_, _) => MultiBlocProvider( + providers: [ + BlocProvider.value(value: converterCubit), + BlocProvider.value(value: buyPaymentInfoCubit), + ], + child: const BuyView(), + ), + ), + ], + ), + GoRoute( + name: AppRoutes.buyPaymentDetails, + path: '/buyPaymentDetails', + builder: (_, _) => const Scaffold(body: Text('details-marker')), + ), + ], + ); + + await tester.pumpWidget( + MediaQuery( + data: mediaQuery, + child: MaterialApp.router( + routerConfig: router, + locale: const Locale('de'), + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + supportedLocales: S.delegate.supportedLocales, + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + } + group('BuyView responsive matrix (full device × textScale)', () { for (final cell in kFullResponsiveMatrix) { testWidgets(cell.id, (tester) async { @@ -168,6 +255,33 @@ void main() { } }); + // Success confirm CTA: overflow + one real tap (confirm navigates). + // Cancellation is not on this screen — it lives on the pending-tx detail page. + group('BuyView Success confirm matrix (full device × textScale)', () { + for (final cell in kFullResponsiveMatrix) { + testWidgets('${cell.id}_success_confirm', (tester) async { + await withTargetPlatform(cell.device.platform, () async { + stubSuccessCtaState(); + + await expectNoLayoutOverflow( + tester, + () async { + await pumpSuccessScreen(tester, cell); + }, + reason: 'overflow on ${cell.label} (Success confirm)', + ); + + await expectFullyTappable( + tester, + find.text(S.current.buyPaymentConfirm), + within: find.byType(BuyView), + reason: '${cell.label}: Success confirm CTA not tappable', + ); + }); + }); + } + }); + // Keyboard: Scaffold.resizeToAvoidBottomInset (default true) shrinks the // body; ScrollableActionsLayout receives the reduced bounded height. Prove // the CTA stays fully tappable with a simulated open keyboard. diff --git a/test/screens/buy/cubits/buy_confirm_cubit_test.dart b/test/screens/buy/cubits/buy_confirm_cubit_test.dart index b5b51654f..6fd43197d 100644 --- a/test/screens/buy/cubits/buy_confirm_cubit_test.dart +++ b/test/screens/buy/cubits/buy_confirm_cubit_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart'; @@ -5,8 +7,7 @@ import 'package:realunit_wallet/packages/service/dfx/models/payment/buy/dto/real import 'package:realunit_wallet/packages/service/dfx/real_unit_buy_payment_info_service.dart'; import 'package:realunit_wallet/screens/buy/cubits/buy_confirm/buy_confirm_cubit.dart'; -class _MockBuyPaymentInfoService extends Mock - implements RealUnitBuyPaymentInfoService {} +class _MockBuyPaymentInfoService extends Mock implements RealUnitBuyPaymentInfoService {} void main() { late _MockBuyPaymentInfoService service; @@ -20,8 +21,7 @@ void main() { expect(BuyConfirmCubit(service).state, isA()); }); - test('confirmPayment emits Success with the confirm remittance info and QR', - () async { + test('confirmPayment emits Success with the confirm remittance info and QR', () async { when(() => service.confirmPayment(any())).thenAnswer( (_) async => const RealUnitBuyConfirmDto( reference: 'REF-123', @@ -176,8 +176,7 @@ void main() { }); test('confirmPayment emits Failure(unknown) on generic exception', () async { - when(() => service.confirmPayment(any())) - .thenAnswer((_) async => throw Exception('network')); + when(() => service.confirmPayment(any())).thenAnswer((_) async => throw Exception('network')); final cubit = BuyConfirmCubit(service); final done = cubit.stream.firstWhere((s) => s is BuyConfirmFailure); @@ -186,5 +185,19 @@ void main() { expect((cubit.state as BuyConfirmFailure).error, BuyConfirmError.unknown); }); + + test('confirmPayment while loading does not call the service again', () async { + final gate = Completer(); + when(() => service.confirmPayment(any())).thenAnswer((_) => gate.future); + + final cubit = BuyConfirmCubit(service); + final first = cubit.confirmPayment(42); + expect(cubit.state, isA()); + await cubit.confirmPayment(42); + verify(() => service.confirmPayment(42)).called(1); + + gate.complete(const RealUnitBuyConfirmDto(reference: 'RU')); + await first; + }); }); } diff --git a/test/screens/buy/widgets/buy_confirm_button_test.dart b/test/screens/buy/widgets/buy_confirm_button_test.dart index f97ff8b23..247f734b1 100644 --- a/test/screens/buy/widgets/buy_confirm_button_test.dart +++ b/test/screens/buy/widgets/buy_confirm_button_test.dart @@ -4,20 +4,24 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; import 'package:mocktail/mocktail.dart'; import 'package:realunit_wallet/generated/i18n.dart'; import 'package:realunit_wallet/packages/service/dfx/models/payment/buy/buy_payment_info.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_buy_payment_info_service.dart'; import 'package:realunit_wallet/screens/buy/buy_payment_details_page.dart'; import 'package:realunit_wallet/screens/buy/cubits/buy_confirm/buy_confirm_cubit.dart'; import 'package:realunit_wallet/screens/buy/widgets/buy_confirm_button.dart'; import 'package:realunit_wallet/screens/buy/widgets/payment_details_card.dart'; import 'package:realunit_wallet/setup/routing/routes/app_routes.dart'; import 'package:realunit_wallet/styles/currency.dart'; +import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart'; import 'package:realunit_wallet/widgets/tab_selector.dart'; -class _MockBuyConfirmCubit extends MockCubit - implements BuyConfirmCubit {} +class _MockBuyConfirmCubit extends MockCubit implements BuyConfirmCubit {} + +class _MockBuyPaymentInfoService extends Mock implements RealUnitBuyPaymentInfoService {} const _info = BuyPaymentInfo( amount: 300, @@ -88,6 +92,36 @@ void main() { ); } + group('$BuyConfirmButton', () { + testWidgets('wires a real cubit from getIt and renders the confirm label', (tester) async { + final getIt = GetIt.instance; + await getIt.reset(); + getIt.registerSingleton(_MockBuyPaymentInfoService()); + addTearDown(() async => getIt.reset()); + + await tester.pumpWidget( + MaterialApp( + locale: const Locale('de'), + localizationsDelegates: [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + supportedLocales: S.delegate.supportedLocales, + // Non-const so BuyConfirmButton's constructor lines are covered. + // ignore: prefer_const_constructors + home: Scaffold(body: BuyConfirmButton(buyPaymentInfo: _info)), + ), + ); + await tester.pump(); + + expect(find.byType(BuyConfirmButtonView), findsOneWidget); + expect(find.text(S.current.buyPaymentConfirm), findsOneWidget); + expect(find.text(S.current.pendingTransactionDeactivate), findsNothing); + }); + }); + group('$BuyConfirmButtonView', () { testWidgets('renders the binding-buy label', (tester) async { await tester.pumpWidget(host()); @@ -95,6 +129,13 @@ void main() { expect(find.text(S.current.buyPaymentConfirm), findsOneWidget); }); + testWidgets('does not render a secondary deactivate button', (tester) async { + await tester.pumpWidget(host()); + + expect(find.text(S.current.pendingTransactionDeactivate), findsNothing); + expect(find.byType(AppFilledButton), findsOneWidget); + }); + testWidgets('tapping confirms the payment for the quote id', (tester) async { await tester.pumpWidget(host()); @@ -112,8 +153,7 @@ void main() { expect(find.byType(CupertinoActivityIndicator), findsOneWidget); }); - testWidgets('shows a snackbar with the generic error on failure', - (tester) async { + testWidgets('shows a snackbar with the generic error on failure', (tester) async { whenListen( cubit, Stream.fromIterable([ @@ -128,8 +168,7 @@ void main() { expect(find.text(S.current.buyPaymentConfirmFailed), findsOneWidget); }); - testWidgets('shows the aktionariat-specific error on a 503 failure', - (tester) async { + testWidgets('shows the aktionariat-specific error on a 503 failure', (tester) async { whenListen( cubit, Stream.fromIterable([ @@ -144,8 +183,7 @@ void main() { expect(find.text(S.current.buyPaymentConfirmFailedAktionariat), findsOneWidget); }); - testWidgets('shows the minimum-purchase error on an amount-too-low failure', - (tester) async { + testWidgets('shows the minimum-purchase error on an amount-too-low failure', (tester) async { whenListen( cubit, Stream.fromIterable([ @@ -160,9 +198,9 @@ void main() { expect(find.text(S.current.buyPaymentConfirmFailedAmountTooLow), findsOneWidget); }); - testWidgets( - 'shows the aktionariat-specific error on a primary-email-required failure', - (tester) async { + testWidgets('shows the aktionariat-specific error on a primary-email-required failure', ( + tester, + ) async { whenListen( cubit, Stream.fromIterable([ @@ -178,33 +216,32 @@ void main() { }); GoRouter detailsRouter({BuyPaymentInfo info = _info}) => GoRouter( - initialLocation: '/buy', - routes: [ - GoRoute( - name: AppRoutes.buy, - path: '/buy', - builder: (_, _) => Scaffold( - body: BlocProvider.value( - value: cubit, - child: BuyConfirmButtonView( - buyPaymentInfo: info, - ), - ), - ), - ), - GoRoute( - name: AppRoutes.buyPaymentDetails, - path: '/buyPaymentDetails', - builder: (_, state) => BuyPaymentDetailsPage( - params: state.extra as BuyPaymentDetailsParams, + initialLocation: '/buy', + routes: [ + GoRoute( + name: AppRoutes.buy, + path: '/buy', + builder: (_, _) => Scaffold( + body: BlocProvider.value( + value: cubit, + child: BuyConfirmButtonView( + buyPaymentInfo: info, ), ), - ], - ); + ), + ), + GoRoute( + name: AppRoutes.buyPaymentDetails, + path: '/buyPaymentDetails', + builder: (_, state) => BuyPaymentDetailsPage( + params: state.extra as BuyPaymentDetailsParams, + ), + ), + ], + ); testWidgets('backward compatible: a reference-only success navigates to the ' - 'details page, shows the reference as Verwendungszweck and no QR tab', - (tester) async { + 'details page, shows the reference as Verwendungszweck and no QR tab', (tester) async { whenListen( cubit, Stream.fromIterable([ diff --git a/test/screens/dashboard/cancel_quote_confirm_sheet_responsive_matrix_test.dart b/test/screens/dashboard/cancel_quote_confirm_sheet_responsive_matrix_test.dart new file mode 100644 index 000000000..de7ae5cb8 --- /dev/null +++ b/test/screens/dashboard/cancel_quote_confirm_sheet_responsive_matrix_test.dart @@ -0,0 +1,102 @@ +// Responsive matrix gate for CancelQuoteConfirmSheet. +// +// Proves both sheet CTAs stay fully tappable across the full device × +// text-scale matrix when presented via showModalBottomSheet( +// isScrollControlled: true). The page matrix only taps the page CTA; +// this file is the catalog entry for the sheet itself. +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/screens/dashboard/pending_transaction_detail_page.dart'; +import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart'; + +import '../../helper/helper.dart'; + +const _openSheetKey = Key('cancel_quote_sheet_matrix.open'); + +void main() { + Future pumpAndOpenSheet(WidgetTester tester, MatrixCell cell) async { + await tester.binding.setSurfaceSize(cell.mediaQuery.size); + addTearDown(() async => await tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + MediaQuery( + data: cell.mediaQuery, + child: MaterialApp( + locale: const Locale('de'), + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + supportedLocales: S.delegate.supportedLocales, + home: Scaffold( + body: Builder( + builder: (context) => Center( + child: ElevatedButton( + key: _openSheetKey, + onPressed: () { + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => CancelQuoteConfirmSheet( + strings: S.of(context), + ), + ); + }, + child: const Text('open'), + ), + ), + ), + ), + ), + ), + ); + await tester.pump(); + await tester.tap(find.byKey(_openSheetKey)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + } + + group('CancelQuoteConfirmSheet responsive matrix', () { + for (final cell in kFullResponsiveMatrix) { + testWidgets('cancelQuote · ${cell.id}', (tester) async { + await withTargetPlatform(cell.device.platform, () async { + await expectNoLayoutOverflow( + tester, + () => pumpAndOpenSheet(tester, cell), + reason: 'CancelQuoteConfirmSheet overflow / ${cell.label}', + ); + + expect( + find.byType(AppFilledButton), + findsNWidgets(2), + reason: 'CancelQuoteConfirmSheet / ${cell.label}: expected 2 CTAs', + ); + + await expectFullyTappable( + tester, + find.byType(AppFilledButton).first, + within: find.byType(CancelQuoteConfirmSheet), + reason: 'CancelQuoteConfirmSheet / ${cell.label}: cancel CTA not tappable', + ); + + await expectNoLayoutOverflow( + tester, + () => pumpAndOpenSheet(tester, cell), + reason: 'CancelQuoteConfirmSheet re-open overflow / ${cell.label}', + ); + + await expectFullyTappable( + tester, + find.byType(AppFilledButton).last, + within: find.byType(CancelQuoteConfirmSheet), + reason: 'CancelQuoteConfirmSheet / ${cell.label}: confirm CTA not tappable', + ); + }); + }); + } + }); +} diff --git a/test/screens/dashboard/cubits/pending_transaction_detail_cubit_test.dart b/test/screens/dashboard/cubits/pending_transaction_detail_cubit_test.dart new file mode 100644 index 000000000..1b1e4476b --- /dev/null +++ b/test/screens/dashboard/cubits/pending_transaction_detail_cubit_test.dart @@ -0,0 +1,209 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/transactions/dto/transactions_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_buy_payment_info_service.dart'; +import 'package:realunit_wallet/screens/dashboard/cubits/pending_transaction_detail/pending_transaction_detail_cubit.dart'; + +class _MockBuyPaymentInfoService extends Mock implements RealUnitBuyPaymentInfoService {} + +TransactionDto _tx({ + int? id = 1, + String? uid, + TransactionType type = TransactionType.buy, + TransactionState state = TransactionState.waitingForPayment, +}) => + TransactionDto(id: id, uid: uid, type: type, state: state); + +void main() { + late _MockBuyPaymentInfoService service; + + setUp(() { + service = _MockBuyPaymentInfoService(); + }); + + group('$PendingTransactionDetailCubit', () { + test('initial state is PendingTransactionDetailInitial', () { + expect( + PendingTransactionDetailCubit(service).state, + isA(), + ); + }); + + test('deactivate calls service with id and emits Success', () async { + when(() => service.deactivateQuote(any())).thenAnswer((_) async {}); + + final cubit = PendingTransactionDetailCubit(service); + final done = cubit.stream.firstWhere((s) => s is PendingTransactionDetailSuccess); + await cubit.deactivate(_tx(id: 42)); + await done; + + expect(cubit.state, isA()); + verify(() => service.deactivateQuote('42')).called(1); + }); + + test('deactivate prefers id over uid', () async { + when(() => service.deactivateQuote(any())).thenAnswer((_) async {}); + + final cubit = PendingTransactionDetailCubit(service); + final done = cubit.stream.firstWhere((s) => s is PendingTransactionDetailSuccess); + await cubit.deactivate(_tx(id: 7, uid: 'uid-only')); + await done; + + verify(() => service.deactivateQuote('7')).called(1); + }); + + test('deactivate falls back to uid when id is null', () async { + when(() => service.deactivateQuote(any())).thenAnswer((_) async {}); + + final cubit = PendingTransactionDetailCubit(service); + final done = cubit.stream.firstWhere((s) => s is PendingTransactionDetailSuccess); + await cubit.deactivate(_tx(id: null, uid: 'quote-uid')); + await done; + + verify(() => service.deactivateQuote('quote-uid')).called(1); + }); + + test('deactivate no-ops for sell transactions', () async { + final cubit = PendingTransactionDetailCubit(service); + await cubit.deactivate( + _tx(type: TransactionType.sell, state: TransactionState.waitingForPayment), + ); + + expect(cubit.state, isA()); + verifyNever(() => service.deactivateQuote(any())); + }); + + test('deactivate no-ops for processing buy', () async { + final cubit = PendingTransactionDetailCubit(service); + await cubit.deactivate( + _tx(type: TransactionType.buy, state: TransactionState.processing), + ); + + expect(cubit.state, isA()); + verifyNever(() => service.deactivateQuote(any())); + }); + + test('deactivate no-ops when both id and uid are empty', () async { + final cubit = PendingTransactionDetailCubit(service); + await cubit.deactivate( + const TransactionDto( + id: null, + uid: null, + type: TransactionType.buy, + state: TransactionState.waitingForPayment, + ), + ); + + expect(cubit.state, isA()); + verifyNever(() => service.deactivateQuote(any())); + }); + + test('deactivate no-ops when uid is empty string and id is null', () async { + final cubit = PendingTransactionDetailCubit(service); + await cubit.deactivate( + const TransactionDto( + id: null, + uid: '', + type: TransactionType.buy, + state: TransactionState.waitingForPayment, + ), + ); + + expect(cubit.state, isA()); + verifyNever(() => service.deactivateQuote(any())); + }); + + test('deactivate emits Failure when service throws', () async { + when(() => service.deactivateQuote(any())).thenAnswer( + (_) async => throw Exception('network'), + ); + + final cubit = PendingTransactionDetailCubit(service); + final done = cubit.stream.firstWhere((s) => s is PendingTransactionDetailFailure); + await cubit.deactivate(_tx(id: 42)); + await done; + + expect(cubit.state, isA()); + }); + + test('deactivate emits Failure on ApiException', () async { + when(() => service.deactivateQuote(any())).thenAnswer( + (_) async => throw const ApiException( + statusCode: 500, + code: 'INTERNAL', + message: 'oops', + ), + ); + + final cubit = PendingTransactionDetailCubit(service); + final done = cubit.stream.firstWhere((s) => s is PendingTransactionDetailFailure); + await cubit.deactivate(_tx(id: 42)); + await done; + + expect(cubit.state, isA()); + }); + + test('deactivate while loading does not call the service again', () async { + final gate = Completer(); + when(() => service.deactivateQuote(any())).thenAnswer((_) => gate.future); + + final cubit = PendingTransactionDetailCubit(service); + final first = cubit.deactivate(_tx(id: 42)); + expect(cubit.state, isA()); + await cubit.deactivate(_tx(id: 42)); + verify(() => service.deactivateQuote('42')).called(1); + + gate.complete(); + await first; + }); + + test('deactivate completes without throwing when cubit is closed mid-flight', + () async { + final gate = Completer(); + when(() => service.deactivateQuote(any())).thenAnswer((_) => gate.future); + + final cubit = PendingTransactionDetailCubit(service); + final future = cubit.deactivate(_tx(id: 42)); + expect(cubit.state, isA()); + await cubit.close(); + gate.complete(); + await expectLater(future, completes); + }); + + test('deactivate emits nothing after close when service fails mid-flight', () async { + final gate = Completer(); + when(() => service.deactivateQuote(any())).thenAnswer((_) => gate.future); + + final cubit = PendingTransactionDetailCubit(service); + final future = cubit.deactivate(_tx(id: 42)); + await cubit.close(); + gate.completeError(Exception('gone')); + await expectLater(future, completes); + }); + + test('state subclasses are equal to themselves and unequal across types', () { + // Non-const so constructors are instrumented for line coverage. + // ignore: prefer_const_constructors + final initialA = PendingTransactionDetailInitial(); + // ignore: prefer_const_constructors + final initialB = PendingTransactionDetailInitial(); + // ignore: prefer_const_constructors + final loading = PendingTransactionDetailLoading(); + // ignore: prefer_const_constructors + final success = PendingTransactionDetailSuccess(); + // ignore: prefer_const_constructors + final failure = PendingTransactionDetailFailure(); + + expect(initialA, equals(initialB)); + expect(initialA.props, isEmpty); + expect(loading.props, isEmpty); + expect(success.props, isEmpty); + expect(failure.props, isEmpty); + expect(initialA, isNot(equals(loading))); + expect(success, isNot(equals(failure))); + }); + }); +} diff --git a/test/screens/dashboard/pending_transaction_detail_page_test.dart b/test/screens/dashboard/pending_transaction_detail_page_test.dart new file mode 100644 index 000000000..6d57e7024 --- /dev/null +++ b/test/screens/dashboard/pending_transaction_detail_page_test.dart @@ -0,0 +1,478 @@ +import 'dart:async'; + +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/cupertino.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/transactions/dto/transactions_dto.dart'; +import 'package:realunit_wallet/screens/dashboard/cubits/pending_transaction_detail/pending_transaction_detail_cubit.dart'; +import 'package:realunit_wallet/screens/dashboard/pending_transaction_detail_page.dart'; +import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart'; + +class _MockDetailCubit extends MockCubit + implements PendingTransactionDetailCubit {} + +TransactionDto _tx({ + int? id = 1, + String? uid, + TransactionType type = TransactionType.buy, + TransactionState state = TransactionState.waitingForPayment, + double? inputAmount = 500, + String? inputAsset = 'CHF', + DateTime? date, +}) => TransactionDto( + id: id, + uid: uid, + type: type, + state: state, + inputAmount: inputAmount, + inputAsset: inputAsset, + date: date ?? DateTime.utc(2026, 5, 21, 8), +); + +void main() { + late _MockDetailCubit cubit; + + setUpAll(() { + registerFallbackValue(_tx()); + }); + + setUp(() { + cubit = _MockDetailCubit(); + when(() => cubit.state).thenReturn(const PendingTransactionDetailInitial()); + when(() => cubit.deactivate(any())).thenAnswer((_) async {}); + }); + + Widget host(TransactionDto tx, {GoRouter? router}) { + final view = BlocProvider.value( + value: cubit, + child: PendingTransactionDetailView(transaction: tx), + ); + if (router != null) { + return MaterialApp.router( + routerConfig: router, + locale: const Locale('de'), + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + supportedLocales: S.delegate.supportedLocales, + ); + } + return MaterialApp( + locale: const Locale('de'), + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + supportedLocales: S.delegate.supportedLocales, + home: view, + ); + } + + group('$PendingTransactionDetailView', () { + testWidgets('shows deactivate CTA only for buy + waitingForPayment', (tester) async { + await tester.pumpWidget(host(_tx())); + await tester.pump(); + + expect(find.text(S.current.pendingTransactionDeactivate), findsOneWidget); + expect(find.byType(AppFilledButton), findsOneWidget); + expect(find.byKey(const ValueKey('pendingTxDetailTitle')), findsOneWidget); + expect(find.byKey(const ValueKey('pendingTxDetailType')), findsOneWidget); + expect(find.byKey(const ValueKey('pendingTxDetailStatus')), findsOneWidget); + expect(find.byKey(const ValueKey('pendingTxDetailAmount')), findsOneWidget); + expect(find.byKey(const ValueKey('pendingTxDetailDate')), findsOneWidget); + expect(find.byKey(const ValueKey('pendingTxDetailId')), findsOneWidget); + }); + + testWidgets('sell pending has no deactivate button', (tester) async { + await tester.pumpWidget( + host(_tx(type: TransactionType.sell, state: TransactionState.processing)), + ); + await tester.pump(); + + expect(find.text(S.current.pendingTransactionDeactivate), findsNothing); + expect(find.byType(AppFilledButton), findsNothing); + }); + + testWidgets('sell detail title is type-neutral', (tester) async { + await tester.pumpWidget( + host(_tx(type: TransactionType.sell, state: TransactionState.processing)), + ); + await tester.pump(); + + final title = tester.widget( + find.byKey(const ValueKey('pendingTxDetailTitle')), + ); + expect(title.data, S.current.pendingTransactionDetailTitle); + }); + + testWidgets('buy + processing has no deactivate button', (tester) async { + await tester.pumpWidget( + host(_tx(state: TransactionState.processing)), + ); + await tester.pump(); + + expect(find.text(S.current.pendingTransactionDeactivate), findsNothing); + }); + + testWidgets('sheet cancel does not call deactivate', (tester) async { + await tester.pumpWidget(host(_tx())); + await tester.pump(); + + await tester.tap(find.text(S.current.pendingTransactionDeactivate)); + await tester.pumpAndSettle(); + + expect(find.byType(CancelQuoteConfirmSheet), findsOneWidget); + expect(find.text(S.current.pendingTransactionDeactivateConfirm), findsOneWidget); + + final sheetCancel = find.descendant( + of: find.byType(CancelQuoteConfirmSheet), + matching: find.widgetWithText(AppFilledButton, S.current.cancel), + ); + await tester.tap(sheetCancel); + await tester.pumpAndSettle(); + + verifyNever(() => cubit.deactivate(any())); + expect(find.byType(CancelQuoteConfirmSheet), findsNothing); + }); + + testWidgets('sheet confirm calls deactivate once', (tester) async { + await tester.pumpWidget(host(_tx(id: 42))); + await tester.pump(); + + await tester.tap(find.text(S.current.pendingTransactionDeactivate)); + await tester.pumpAndSettle(); + + final sheetConfirm = find.descendant( + of: find.byType(CancelQuoteConfirmSheet), + matching: find.widgetWithText( + AppFilledButton, + S.current.pendingTransactionDeactivate, + ), + ); + await tester.tap(sheetConfirm); + await tester.pumpAndSettle(); + + verify(() => cubit.deactivate(any())).called(1); + }); + + testWidgets('shows loading indicator while deactivating', (tester) async { + when(() => cubit.state).thenReturn(const PendingTransactionDetailLoading()); + + await tester.pumpWidget(host(_tx())); + await tester.pump(); + + expect(find.byType(CupertinoActivityIndicator), findsOneWidget); + }); + + testWidgets('PopScope blocks pop while loading', (tester) async { + when(() => cubit.state).thenReturn(const PendingTransactionDetailLoading()); + + await tester.pumpWidget(host(_tx())); + await tester.pump(); + + final popScope = tester.widget(find.byType(PopScope)); + expect(popScope.canPop, isFalse); + }); + + testWidgets('PopScope allows pop on initial', (tester) async { + await tester.pumpWidget(host(_tx())); + await tester.pump(); + + final popScope = tester.widget(find.byType(PopScope)); + expect(popScope.canPop, isTrue); + }); + + testWidgets('shows snackbar on Failure', (tester) async { + whenListen( + cubit, + Stream.fromIterable([ + const PendingTransactionDetailFailure(), + ]), + initialState: const PendingTransactionDetailInitial(), + ); + + await tester.pumpWidget(host(_tx())); + await tester.pump(); + + expect(find.text(S.current.pendingTransactionDeactivateFailed), findsOneWidget); + }); + + testWidgets('Success pops the route', (tester) async { + final states = StreamController(); + addTearDown(states.close); + whenListen( + cubit, + states.stream, + initialState: const PendingTransactionDetailInitial(), + ); + + final router = GoRouter( + initialLocation: '/parent', + routes: [ + GoRoute( + path: '/parent', + builder: (_, _) => const Scaffold(body: Text('parent-marker')), + routes: [ + GoRoute( + path: 'child', + builder: (_, _) => BlocProvider.value( + value: cubit, + child: PendingTransactionDetailView(transaction: _tx()), + ), + ), + ], + ), + ], + ); + + await tester.pumpWidget(host(_tx(), router: router)); + await tester.pump(); + expect(find.text('parent-marker'), findsOneWidget); + + final popped = router.push('/parent/child'); + await tester.pump(); + await tester.pump(); + expect(find.byType(PendingTransactionDetailView), findsOneWidget); + + states.add(const PendingTransactionDetailSuccess()); + await tester.pump(); + + expect(await popped, '1'); + expect(find.text('parent-marker'), findsOneWidget); + expect(find.byType(PendingTransactionDetailView), findsNothing); + }); + + testWidgets('Success pops the uid when id is null', (tester) async { + final states = StreamController(); + addTearDown(states.close); + whenListen( + cubit, + states.stream, + initialState: const PendingTransactionDetailInitial(), + ); + + final router = GoRouter( + initialLocation: '/parent', + routes: [ + GoRoute( + path: '/parent', + builder: (_, _) => const Scaffold(body: Text('parent-marker')), + routes: [ + GoRoute( + path: 'child', + builder: (_, _) => BlocProvider.value( + value: cubit, + child: PendingTransactionDetailView( + transaction: _tx(id: null, uid: 'u-wait'), + ), + ), + ), + ], + ), + ], + ); + + await tester.pumpWidget(host(_tx(id: null, uid: 'u-wait'), router: router)); + await tester.pump(); + + final popped = router.push('/parent/child'); + await tester.pump(); + await tester.pump(); + + states.add(const PendingTransactionDetailSuccess()); + await tester.pump(); + + expect(await popped, 'u-wait'); + }); + + testWidgets('renders amount-only and asset-only when the other is missing', (tester) async { + await tester.pumpWidget( + host( + _tx(inputAmount: 100, inputAsset: null), + ), + ); + await tester.pump(); + expect(find.byKey(const ValueKey('pendingTxDetailAmount')), findsOneWidget); + + await tester.pumpWidget( + host( + _tx(inputAmount: null, inputAsset: 'CHF'), + ), + ); + await tester.pump(); + expect(find.byKey(const ValueKey('pendingTxDetailAsset')), findsOneWidget); + }); + + testWidgets('renders fractional amount with two decimals', (tester) async { + await tester.pumpWidget(host(_tx(inputAmount: 100.5))); + await tester.pump(); + expect(find.textContaining('100.50'), findsOneWidget); + }); + + testWidgets('renders uid when id is null', (tester) async { + await tester.pumpWidget( + host( + _tx(id: null, uid: 'uid-xyz'), + ), + ); + await tester.pump(); + expect(find.text('uid-xyz'), findsOneWidget); + }); + + testWidgets('page widget builds the view', (tester) async { + await tester.pumpWidget( + MaterialApp( + locale: const Locale('de'), + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + supportedLocales: S.delegate.supportedLocales, + home: BlocProvider.value( + value: cubit, + child: PendingTransactionDetailPage(transaction: _tx()), + ), + ), + ); + await tester.pump(); + expect(find.byType(PendingTransactionDetailView), findsOneWidget); + }); + + testWidgets('sell type label is shown', (tester) async { + await tester.pumpWidget( + host(_tx(type: TransactionType.sell, state: TransactionState.processing)), + ); + await tester.pump(); + expect(find.text(S.current.transactionSell), findsOneWidget); + }); + + testWidgets('non buy/sell type falls back to the enum value string', (tester) async { + await tester.pumpWidget( + host( + const TransactionDto( + type: TransactionType.swap, + state: TransactionState.processing, + ), + ), + ); + await tester.pump(); + expect(find.text(TransactionType.swap.value), findsOneWidget); + expect(find.byKey(const ValueKey('pendingTxDetailId')), findsNothing); + expect(find.byKey(const ValueKey('pendingTxDetailDate')), findsNothing); + }); + + testWidgets('null type falls back to em dash', (tester) async { + await tester.pumpWidget( + host( + const TransactionDto( + type: null, + state: TransactionState.processing, + ), + ), + ); + await tester.pump(); + expect(find.text('—'), findsOneWidget); + }); + }); + + group('$CancelQuoteConfirmSheet', () { + Widget sheetHost(Widget home) => MaterialApp( + locale: const Locale('de'), + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + supportedLocales: S.delegate.supportedLocales, + home: home, + ); + + testWidgets('non-const constructor and both button pops', (tester) async { + // Load localizations so S.current is available for the non-const ctor. + await tester.pumpWidget(sheetHost(const SizedBox.shrink())); + await tester.pump(); + + // Non-const so CancelQuoteConfirmSheet's constructor lines are covered. + // ignore: prefer_const_constructors + final sheet = CancelQuoteConfirmSheet(strings: S.current); + + await tester.pumpWidget( + sheetHost( + Builder( + builder: (context) => Scaffold( + body: Column( + children: [ + AppFilledButton( + label: 'open-cancel', + onPressed: () { + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => sheet, + ); + }, + ), + AppFilledButton( + label: 'open-confirm', + onPressed: () { + // Second non-const pump path for the primary button. + // ignore: prefer_const_constructors + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => CancelQuoteConfirmSheet(strings: S.current), + ); + }, + ), + ], + ), + ), + ), + ), + ); + await tester.pump(); + + await tester.tap(find.text('open-cancel')); + await tester.pumpAndSettle(); + expect(find.byType(CancelQuoteConfirmSheet), findsOneWidget); + + await tester.tap( + find.descendant( + of: find.byType(CancelQuoteConfirmSheet), + matching: find.widgetWithText(AppFilledButton, S.current.cancel), + ), + ); + await tester.pumpAndSettle(); + expect(find.byType(CancelQuoteConfirmSheet), findsNothing); + + await tester.tap(find.text('open-confirm')); + await tester.pumpAndSettle(); + + await tester.tap( + find.descendant( + of: find.byType(CancelQuoteConfirmSheet), + matching: find.widgetWithText( + AppFilledButton, + S.current.pendingTransactionDeactivate, + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.byType(CancelQuoteConfirmSheet), findsNothing); + }); + }); +} diff --git a/test/screens/dashboard/pending_transaction_detail_responsive_matrix_test.dart b/test/screens/dashboard/pending_transaction_detail_responsive_matrix_test.dart new file mode 100644 index 000000000..e67267346 --- /dev/null +++ b/test/screens/dashboard/pending_transaction_detail_responsive_matrix_test.dart @@ -0,0 +1,91 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/transactions/dto/transactions_dto.dart'; +import 'package:realunit_wallet/screens/dashboard/cubits/pending_transaction_detail/pending_transaction_detail_cubit.dart'; +import 'package:realunit_wallet/screens/dashboard/pending_transaction_detail_page.dart'; +import 'package:realunit_wallet/styles/themes.dart'; + +import '../../helper/helper.dart'; + +class _MockDetailCubit extends MockCubit + implements PendingTransactionDetailCubit {} + +Future _pumpScreen(WidgetTester tester, MatrixCell cell, Widget child) async { + await tester.binding.setSurfaceSize(cell.mediaQuery.size); + addTearDown(() async => await tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + MediaQuery( + data: cell.mediaQuery, + child: MaterialApp( + theme: realUnitTheme, + locale: const Locale('de'), + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + supportedLocales: S.delegate.supportedLocales, + home: child, + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); +} + +void main() { + const buyWaiting = TransactionDto( + id: 1, + type: TransactionType.buy, + state: TransactionState.waitingForPayment, + inputAmount: 500, + inputAsset: 'CHF', + date: null, + ); + + late _MockDetailCubit cubit; + + setUp(() { + cubit = _MockDetailCubit(); + when(() => cubit.state).thenReturn(const PendingTransactionDetailInitial()); + when(() => cubit.deactivate(any())).thenAnswer((_) async {}); + }); + + setUpAll(() { + registerFallbackValue(buyWaiting); + }); + + group('PendingTransactionDetailView responsive matrix (full device x textScale)', () { + for (final cell in kFullResponsiveMatrix) { + testWidgets(cell.id, (tester) async { + await withTargetPlatform(cell.device.platform, () async { + final subject = BlocProvider.value( + value: cubit, + child: const PendingTransactionDetailView(transaction: buyWaiting), + ); + + await expectNoLayoutOverflow( + tester, + () => _pumpScreen(tester, cell, subject), + reason: 'PendingTransactionDetailView overflow / ${cell.label}', + ); + + await expectFullyTappable( + tester, + find.text(S.current.pendingTransactionDeactivate), + within: find.byType(PendingTransactionDetailView), + reason: + 'PendingTransactionDetailView / ${cell.label}: deactivate CTA not tappable', + ); + }); + }); + } + }); +} diff --git a/test/screens/dashboard/pending_transactions_cubit_test.dart b/test/screens/dashboard/pending_transactions_cubit_test.dart index 2564da295..d38b6703a 100644 --- a/test/screens/dashboard/pending_transactions_cubit_test.dart +++ b/test/screens/dashboard/pending_transactions_cubit_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:realunit_wallet/packages/service/dfx/models/transactions/dto/transactions_dto.dart'; @@ -27,8 +29,7 @@ void main() { test('emits the fetched pending list on construction', () async { final tx1 = _StubTx(); final tx2 = _StubTx(); - when(() => service.fetchPendingTransactions()) - .thenAnswer((_) async => [tx1, tx2]); + when(() => service.fetchPendingTransactions()).thenAnswer((_) async => [tx1, tx2]); final cubit = PendingTransactionsCubit(service); await cubit.stream.firstWhere((s) => s.isNotEmpty); @@ -37,8 +38,9 @@ void main() { }); test('falls back to an empty list when the service throws', () async { - when(() => service.fetchPendingTransactions()) - .thenAnswer((_) async => throw Exception('network')); + when( + () => service.fetchPendingTransactions(), + ).thenAnswer((_) async => throw Exception('network')); final cubit = PendingTransactionsCubit(service); // The catch branch emits the same [] the cubit started in, so we @@ -47,5 +49,168 @@ void main() { expect(cubit.state, isEmpty); }); + + test('reload re-fetches the pending list', () async { + final first = [ + const TransactionDto(id: 1, type: TransactionType.buy), + ]; + final second = [ + const TransactionDto(id: 2, type: TransactionType.buy), + ]; + var call = 0; + when(() => service.fetchPendingTransactions()).thenAnswer((_) async { + call++; + return call == 1 ? first : second; + }); + + final cubit = PendingTransactionsCubit(service); + await cubit.stream.firstWhere((s) => s.isNotEmpty); + expect(cubit.state, first); + + await cubit.reload(); + expect(cubit.state, second); + }); + + test('reload that throws keeps a previously loaded non-empty list', () async { + final buy = const TransactionDto(id: 1, type: TransactionType.buy); + var call = 0; + when(() => service.fetchPendingTransactions()).thenAnswer((_) async { + call++; + if (call == 1) return [buy]; + throw Exception('network'); + }); + + final cubit = PendingTransactionsCubit(service); + await cubit.stream.firstWhere((s) => s.isNotEmpty); + expect(cubit.state, [buy]); + + await cubit.reload(); + expect(cubit.state, [buy]); + }); + + test('overlapping reloads: newer fetch wins over stale completion', () async { + final initial = const TransactionDto(id: 1, type: TransactionType.buy); + final stale = const TransactionDto(id: 2, type: TransactionType.buy); + final newer = const TransactionDto(id: 3, type: TransactionType.buy); + final load2 = Completer>(); + var call = 0; + when(() => service.fetchPendingTransactions()).thenAnswer((_) async { + call++; + if (call == 1) return [initial]; + if (call == 2) return load2.future; + return [newer]; + }); + + final cubit = PendingTransactionsCubit(service); + await cubit.stream.firstWhere((s) => s.isNotEmpty); + expect(cubit.state, [initial]); + + // load 2 (stale) starts first and hangs; load 3 returns immediately. + final reload2 = cubit.reload(); + final reload3 = cubit.reload(); + await reload3; + expect(cubit.state, [newer]); + + load2.complete([stale]); + await reload2; + // Stale load 2 must not overwrite the newer list. + expect(cubit.state, [newer]); + }); + + test('drop removes the matching id from the list', () async { + final buy = const TransactionDto(id: 7, type: TransactionType.buy); + final sell = const TransactionDto(id: 8, type: TransactionType.sell); + when(() => service.fetchPendingTransactions()).thenAnswer((_) async => [buy, sell]); + + final cubit = PendingTransactionsCubit(service); + await cubit.stream.firstWhere((s) => s.length == 2); + + cubit.drop('7'); + expect(cubit.state, [sell]); + }); + + test('drop removes the matching uid when id is null', () async { + final buy = const TransactionDto( + uid: 'u-1', + type: TransactionType.buy, + state: TransactionState.waitingForPayment, + ); + when(() => service.fetchPendingTransactions()).thenAnswer((_) async => [buy]); + + final cubit = PendingTransactionsCubit(service); + await cubit.stream.firstWhere((s) => s.isNotEmpty); + + cubit.drop('u-1'); + expect(cubit.state, isEmpty); + }); + + test('applyDetailReturn drops then reloads', () async { + final buy = const TransactionDto(id: 7, type: TransactionType.buy); + final sell = const TransactionDto(id: 8, type: TransactionType.sell); + when(() => service.fetchPendingTransactions()).thenAnswer((_) async => [buy, sell]); + + final cubit = PendingTransactionsCubit(service); + await cubit.stream.firstWhere((s) => s.length == 2); + + when(() => service.fetchPendingTransactions()).thenAnswer((_) async => [sell]); + await cubit.applyDetailReturn('7'); + + expect(cubit.state, [sell]); + verify(() => service.fetchPendingTransactions()).called(2); + }); + + test('applyDetailReturn with null only reloads', () async { + final buy = const TransactionDto(id: 7, type: TransactionType.buy); + when(() => service.fetchPendingTransactions()).thenAnswer((_) async => [buy]); + + final cubit = PendingTransactionsCubit(service); + await cubit.stream.firstWhere((s) => s.isNotEmpty); + + await cubit.applyDetailReturn(null); + expect(cubit.state, [buy]); + verify(() => service.fetchPendingTransactions()).called(2); + }); + + test('applyDetailReturn on a closed cubit is a no-op', () async { + when(() => service.fetchPendingTransactions()).thenAnswer((_) async => []); + final cubit = PendingTransactionsCubit(service); + await cubit.close(); + + await cubit.applyDetailReturn('7'); + verify(() => service.fetchPendingTransactions()).called(1); + }); + + test('drop with empty id is a no-op', () async { + final buy = const TransactionDto(id: 7, type: TransactionType.buy); + when(() => service.fetchPendingTransactions()).thenAnswer((_) async => [buy]); + + final cubit = PendingTransactionsCubit(service); + await cubit.stream.firstWhere((s) => s.isNotEmpty); + + cubit.drop(''); + expect(cubit.state, [buy]); + }); + + test('reload completes without throwing when cubit is closed during fetch', () async { + final buyTx = const TransactionDto( + id: 7, + type: TransactionType.buy, + state: TransactionState.waitingForPayment, + ); + final fetchGate = Completer>(); + var call = 0; + when(() => service.fetchPendingTransactions()).thenAnswer((_) async { + call++; + if (call == 1) return [buyTx]; + return fetchGate.future; + }); + + final cubit = PendingTransactionsCubit(service); + await cubit.stream.firstWhere((s) => s.isNotEmpty); + final reloadFuture = cubit.reload(); + await cubit.close(); + fetchGate.complete([buyTx]); + await expectLater(reloadFuture, completes); + }); }); } diff --git a/test/screens/dashboard/widgets/pending_transaction_row_test.dart b/test/screens/dashboard/widgets/pending_transaction_row_test.dart index d0dd4ec34..eb1d9b2a6 100644 --- a/test/screens/dashboard/widgets/pending_transaction_row_test.dart +++ b/test/screens/dashboard/widgets/pending_transaction_row_test.dart @@ -11,43 +11,47 @@ TransactionDto _tx({ TransactionState? state, double? inputAmount, String? inputAsset, -}) => - TransactionDto( - id: 1, - type: type, - state: state, - inputAmount: inputAmount, - inputAsset: inputAsset, - date: DateTime.utc(2026, 5, 15, 10), - ); +}) => TransactionDto( + id: 1, + type: type, + state: state, + inputAmount: inputAmount, + inputAsset: inputAsset, + date: DateTime.utc(2026, 5, 15, 10), +); void main() { group('$PendingTransactionRow', () { - testWidgets('always renders a CupertinoActivityIndicator (still pending)', - (tester) async { - await tester.pumpApp(Scaffold( - body: PendingTransactionRow( - transaction: _tx(type: TransactionType.buy, state: TransactionState.processing), + testWidgets('always renders a CupertinoActivityIndicator (still pending)', (tester) async { + await tester.pumpApp( + Scaffold( + body: PendingTransactionRow( + transaction: _tx(type: TransactionType.buy, state: TransactionState.processing), + ), ), - )); + ); expect(find.byType(CupertinoActivityIndicator), findsOneWidget); }); testWidgets('buy vs sell produce DIFFERENT first-line labels', (tester) async { - await tester.pumpApp(Scaffold( - body: PendingTransactionRow( - transaction: _tx(type: TransactionType.buy, state: TransactionState.processing), + await tester.pumpApp( + Scaffold( + body: PendingTransactionRow( + transaction: _tx(type: TransactionType.buy, state: TransactionState.processing), + ), ), - )); + ); // Capture buy line. final buyLine = tester.widgetList(find.byType(Text)).first.data; - await tester.pumpApp(Scaffold( - body: PendingTransactionRow( - transaction: _tx(type: TransactionType.sell, state: TransactionState.processing), + await tester.pumpApp( + Scaffold( + body: PendingTransactionRow( + transaction: _tx(type: TransactionType.sell, state: TransactionState.processing), + ), ), - )); + ); final sellLine = tester.widgetList(find.byType(Text)).first.data; expect(buyLine, isNotNull); @@ -55,32 +59,125 @@ void main() { expect(buyLine, isNot(sellLine)); }); - testWidgets('state=waitingForPayment vs other state produces DIFFERENT second-line labels', - (tester) async { - await tester.pumpApp(Scaffold( - body: PendingTransactionRow( - transaction: _tx( - type: TransactionType.buy, - state: TransactionState.waitingForPayment, + testWidgets('state=waitingForPayment vs other state produces DIFFERENT second-line labels', ( + tester, + ) async { + await tester.pumpApp( + Scaffold( + body: PendingTransactionRow( + transaction: _tx( + type: TransactionType.buy, + state: TransactionState.waitingForPayment, + ), ), ), - )); + ); final waitingSecondLine = tester.widgetList(find.byType(Text)).elementAt(1).data; - await tester.pumpApp(Scaffold( - body: PendingTransactionRow( - transaction: _tx( - type: TransactionType.buy, - state: TransactionState.processing, + await tester.pumpApp( + Scaffold( + body: PendingTransactionRow( + transaction: _tx( + type: TransactionType.buy, + state: TransactionState.processing, + ), ), ), - )); - final processingSecondLine = - tester.widgetList(find.byType(Text)).elementAt(1).data; + ); + final processingSecondLine = tester.widgetList(find.byType(Text)).elementAt(1).data; expect(waitingSecondLine, isNotNull); expect(processingSecondLine, isNotNull); expect(waitingSecondLine, isNot(processingSecondLine)); }); + + testWidgets('never shows an IconButton', (tester) async { + await tester.pumpApp( + Scaffold( + body: PendingTransactionRow( + transaction: _tx( + type: TransactionType.buy, + state: TransactionState.waitingForPayment, + ), + onTap: () {}, + ), + ), + ); + + expect(find.byType(IconButton), findsNothing); + }); + + testWidgets('tapping the row invokes onTap', (tester) async { + var taps = 0; + await tester.pumpApp( + Scaffold( + body: PendingTransactionRow( + transaction: _tx( + type: TransactionType.buy, + state: TransactionState.waitingForPayment, + ), + onTap: () { + taps++; + }, + ), + ), + ); + + await tester.tap(find.byType(PendingTransactionRow)); + // CupertinoActivityIndicator animates indefinitely, so pumpAndSettle + // would hang. + await tester.pump(); + + expect(taps, 1); + }); + + testWidgets('without onTap the row is not wrapped in InkWell', (tester) async { + await tester.pumpApp( + Scaffold( + body: PendingTransactionRow( + transaction: _tx( + type: TransactionType.buy, + state: TransactionState.processing, + ), + ), + ), + ); + + expect(find.byType(InkWell), findsNothing); + }); + + testWidgets('integer inputAmount renders without decimals', (tester) async { + await tester.pumpApp( + Scaffold( + body: PendingTransactionRow( + transaction: _tx( + type: TransactionType.buy, + state: TransactionState.processing, + inputAmount: 100.0, + inputAsset: 'CHF', + ), + ), + ), + ); + + expect(find.textContaining('100 CHF'), findsOneWidget); + }); + + testWidgets('fractional inputAmount renders with two decimals', (tester) async { + await tester.pumpApp( + Scaffold( + body: PendingTransactionRow( + transaction: _tx( + type: TransactionType.buy, + state: TransactionState.processing, + inputAmount: 100.5, + inputAsset: 'CHF', + ), + ), + ), + ); + + expect(find.textContaining('100.50 CHF'), findsOneWidget); + }); }); } diff --git a/test/screens/dashboard/widgets/sections/dashboard_pending_transactions_view_test.dart b/test/screens/dashboard/widgets/sections/dashboard_pending_transactions_view_test.dart index 524a71efa..0e9d4c6f5 100644 --- a/test/screens/dashboard/widgets/sections/dashboard_pending_transactions_view_test.dart +++ b/test/screens/dashboard/widgets/sections/dashboard_pending_transactions_view_test.dart @@ -1,66 +1,279 @@ 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/transactions/dto/transactions_dto.dart'; import 'package:realunit_wallet/screens/dashboard/bloc/pending_transactions_cubit.dart'; import 'package:realunit_wallet/screens/dashboard/widgets/pending_transaction_row.dart'; import 'package:realunit_wallet/screens/dashboard/widgets/sections/dashboard_pending_transactions.dart'; - -import '../../../../helper/helper.dart'; +import 'package:realunit_wallet/setup/routing/routes/app_routes.dart'; class _MockPendingCubit extends MockCubit> implements PendingTransactionsCubit {} -TransactionDto _tx({int id = 1, TransactionType type = TransactionType.buy}) => - TransactionDto( - id: id, - type: type, - state: TransactionState.processing, - date: DateTime.utc(2026, 5, 15), - ); +TransactionDto _tx({ + int? id = 1, + String? uid, + TransactionType type = TransactionType.buy, + TransactionState state = TransactionState.processing, +}) => TransactionDto( + id: id, + uid: uid, + type: type, + state: state, + date: DateTime.utc(2026, 5, 15), +); void main() { late _MockPendingCubit cubit; + setUpAll(() { + registerFallbackValue(_tx()); + registerFallbackValue(''); + registerFallbackValue(null); + }); + setUp(() { cubit = _MockPendingCubit(); + when(() => cubit.reload()).thenAnswer((_) async {}); + when(() => cubit.drop(any())).thenReturn(null); + when(() => cubit.applyDetailReturn(any())).thenAnswer((_) async {}); }); Widget host() => BlocProvider.value( - value: cubit, - child: const Scaffold(body: DashboardPendingTransactionsView()), - ); + value: cubit, + child: MaterialApp( + locale: const Locale('de'), + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + supportedLocales: S.delegate.supportedLocales, + // Non-const so DashboardPendingTransactionsView's constructor is covered. + // ignore: prefer_const_constructors + home: Scaffold(body: DashboardPendingTransactionsView()), + ), + ); + + Widget hostWithRouter() { + final router = GoRouter( + initialLocation: '/dashboard', + routes: [ + GoRoute( + name: AppRoutes.dashboard, + path: '/dashboard', + builder: (_, _) => BlocProvider.value( + value: cubit, + child: const Scaffold(body: DashboardPendingTransactionsView()), + ), + routes: [ + GoRoute( + name: AppRoutes.pendingTransaction, + path: 'pendingTransaction', + builder: (_, state) => Scaffold( + body: Text( + 'detail-${(state.extra as TransactionDto).id}', + key: const ValueKey('pending-detail-marker'), + ), + ), + ), + ], + ), + ], + ); + + return MaterialApp.router( + routerConfig: router, + locale: const Locale('de'), + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + supportedLocales: S.delegate.supportedLocales, + ); + } group('$DashboardPendingTransactionsView', () { - testWidgets('empty list: renders SizedBox.shrink (no PendingTransactionRow)', - (tester) async { + testWidgets('empty list: renders SizedBox.shrink (no PendingTransactionRow)', (tester) async { when(() => cubit.state).thenReturn([]); - await tester.pumpApp(host()); + await tester.pumpWidget(host()); + await tester.pump(); expect(find.byType(PendingTransactionRow), findsNothing); }); - testWidgets('non-empty list: renders one PendingTransactionRow per tx', - (tester) async { + testWidgets('non-empty list: renders one PendingTransactionRow per tx', (tester) async { when(() => cubit.state).thenReturn([ _tx(id: 1), _tx(id: 2, type: TransactionType.sell), _tx(id: 3), ]); - await tester.pumpApp(host()); + await tester.pumpWidget(host()); + await tester.pump(); expect(find.byType(PendingTransactionRow), findsNWidgets(3)); + expect(find.byKey(const ValueKey('pendingTx-1')), findsOneWidget); + }); + + testWidgets('never shows a deactivate IconButton on any row', (tester) async { + when(() => cubit.state).thenReturn([ + _tx(id: 1, state: TransactionState.waitingForPayment), + _tx(id: 2, type: TransactionType.sell), + _tx(id: 3, state: TransactionState.waitingForPayment), + ]); + + await tester.pumpWidget(host()); + await tester.pump(); + + expect(find.byType(IconButton), findsNothing); }); - testWidgets('non-empty list also renders a section header above the rows', - (tester) async { + testWidgets('tap on buy-waiting row navigates to pending detail and reloads', (tester) async { + when(() => cubit.state).thenReturn([ + _tx(id: 1, state: TransactionState.waitingForPayment), + _tx(id: 2, type: TransactionType.sell), + ]); + + await tester.pumpWidget(hostWithRouter()); + await tester.pump(); + + await tester.tap(find.byKey(const ValueKey('pendingTx-1'))); + // CupertinoActivityIndicator animates indefinitely — no pumpAndSettle. + await tester.pump(); + await tester.pump(); + + expect(find.byKey(const ValueKey('pending-detail-marker')), findsOneWidget); + expect(find.text('detail-1'), findsOneWidget); + + // Pop the cancelled id so the list drops the row even if reload fails. + final navigator = tester.state(find.byType(Navigator).first); + navigator.pop('1'); + await tester.pump(); + await tester.pump(); + + verify(() => cubit.applyDetailReturn('1')).called(1); + }); + + testWidgets('pop without a cancelled id still applies the return', (tester) async { + when(() => cubit.state).thenReturn([ + _tx(id: 1, state: TransactionState.waitingForPayment), + ]); + + await tester.pumpWidget(hostWithRouter()); + await tester.pump(); + + await tester.tap(find.byKey(const ValueKey('pendingTx-1'))); + await tester.pump(); + await tester.pump(); + + final navigator = tester.state(find.byType(Navigator).first); + navigator.pop(); + await tester.pump(); + await tester.pump(); + + verify(() => cubit.applyDetailReturn(null)).called(1); + }); + + testWidgets('unmount while the detail route is open still reloads the cubit', (tester) async { + when(() => cubit.state).thenReturn([ + _tx(id: 1, state: TransactionState.waitingForPayment), + ]); + + final showList = ValueNotifier(true); + addTearDown(showList.dispose); + final router = GoRouter( + initialLocation: '/dashboard', + routes: [ + GoRoute( + name: AppRoutes.dashboard, + path: '/dashboard', + builder: (_, _) => ValueListenableBuilder( + valueListenable: showList, + builder: (_, visible, _) => BlocProvider.value( + value: cubit, + child: Scaffold( + body: visible + ? const DashboardPendingTransactionsView() + : const SizedBox.shrink(), + ), + ), + ), + routes: [ + GoRoute( + name: AppRoutes.pendingTransaction, + path: 'pendingTransaction', + builder: (_, state) => Scaffold( + body: Text( + 'detail-${(state.extra as TransactionDto).id}', + key: const ValueKey('pending-detail-marker'), + ), + ), + ), + ], + ), + ], + ); + + await tester.pumpWidget( + MaterialApp.router( + routerConfig: router, + locale: const Locale('de'), + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + supportedLocales: S.delegate.supportedLocales, + ), + ); + await tester.pump(); + + await tester.tap(find.byKey(const ValueKey('pendingTx-1'))); + await tester.pump(); + await tester.pump(); + expect(find.byKey(const ValueKey('pending-detail-marker')), findsOneWidget); + + // Unmount the list view but keep the navigator so pushNamed can resolve. + showList.value = false; + await tester.pump(); + router.pop(); + await tester.pump(); + await tester.pump(); + + verify(() => cubit.applyDetailReturn(null)).called(1); + }); + + testWidgets('tap on sell row also navigates (details without cancel CTA)', (tester) async { + when(() => cubit.state).thenReturn([ + _tx(id: 2, type: TransactionType.sell), + ]); + + await tester.pumpWidget(hostWithRouter()); + await tester.pump(); + + await tester.tap(find.byKey(const ValueKey('pendingTx-2'))); + await tester.pump(); + await tester.pump(); + + expect(find.text('detail-2'), findsOneWidget); + }); + + testWidgets('non-empty list also renders a section header above the rows', (tester) async { when(() => cubit.state).thenReturn([_tx()]); - await tester.pumpApp(host()); + await tester.pumpWidget(host()); + await tester.pump(); // Header position: the first Text widget is rendered before the // PendingTransactionRow's children. We only pin that the header Text @@ -75,5 +288,22 @@ void main() { expect(headerCount, greaterThan(0)); expect(find.byType(PendingTransactionRow), findsOneWidget); }); + + testWidgets('uid-only row uses uid in the ValueKey', (tester) async { + when(() => cubit.state).thenReturn([ + TransactionDto( + id: null, + uid: 'waiting-uid', + type: TransactionType.buy, + state: TransactionState.waitingForPayment, + date: DateTime.utc(2026, 5, 15), + ), + ]); + + await tester.pumpWidget(host()); + await tester.pump(); + + expect(find.byKey(const ValueKey('pendingTx-waiting-uid')), findsOneWidget); + }); }); }