diff --git a/.github/workflows/handbook-build-check.yaml b/.github/workflows/handbook-build-check.yaml index 797ed36fd..788b7a7e5 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" != "278" ]; then - echo "expected 278 screenshots, got $count" >&2 + if [ "$count" != "284" ]; then + echo "expected 284 screenshots, got $count" >&2 exit 1 fi @@ -173,11 +173,11 @@ jobs: exit 1 fi - # Screenshots dir must contain all 278 PNGs assembled from Goldens. + # Screenshots dir must contain all 284 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 # 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-handbook-persona-dca 275-handbook-persona-exit-1j; do code=$(curl -s -o /dev/null -w '%{http_code}' -u "${HANDBOOK_USER:-x}:${HANDBOOK_PASS:-x}" "http://127.0.0.1:8080/screenshots/${name}.png") # 200 (auth happens to match) or 401 (auth fails but file exists) # both prove the file is on disk. 404 means it was not assembled. diff --git a/.github/workflows/handbook.yaml b/.github/workflows/handbook.yaml index f45eb014e..3b15ff9f5 100644 --- a/.github/workflows/handbook.yaml +++ b/.github/workflows/handbook.yaml @@ -121,19 +121,20 @@ jobs: env: API_DIR: _api-checkout HANDLEBARS_PREFIX: _handlebars-only - # Exact expected mail-preview file count: 24 mails + 1 index = 25. - # `-ne` below makes ANY drift (drop OR addition) fail the build — - # adding a new mail upstream therefore requires an explicit edit - # here, which is the whole point: a silently-added (or silently- - # removed) mail must never reach the handbook image undetected. - # When you legitimately add/remove a mail in - # DFXswiss/api/scripts/generate-realunit-previews.js, bump this - # number in the SAME PR as the api-side change-PR (or its - # follow-up). The exact count is computed as: - # N(add() calls in the generator) + # Exact expected mail-preview file count: 24 standalone add() mails + # + 3 pendingTypes expansions + 1 index = 28. `-ne` below makes ANY + # drift (drop OR addition) fail the build — adding a new mail + # upstream therefore requires an explicit edit here, which is the + # whole point: a silently-added (or silently-removed) mail must + # never reach the handbook image undetected. When you legitimately + # add/remove a mail in DFXswiss/api/scripts/generate-realunit-previews.js, + # bump this number in the SAME PR as the api-side change-PR (or + # its follow-up). The exact count is computed as: + # N(standalone add() calls in the generator; the loop template + # is not counted here) # + N(pendingTypes entries the for-loop expands) # + 1 (00_index.html) - EXPECTED_HTML_COUNT: 25 + EXPECTED_HTML_COUNT: 28 run: | set -euo pipefail diff --git a/.github/workflows/pull-request.yaml b/.github/workflows/pull-request.yaml index d596f6ce8..ec6e3d453 100644 --- a/.github/workflows/pull-request.yaml +++ b/.github/workflows/pull-request.yaml @@ -63,6 +63,7 @@ jobs: - run: dart run tool/generate_localization.dart - run: dart run tool/generate_release_info.dart - run: flutter pub run build_runner build + - run: bash scripts/run-handbook-flows.sh --matcher-self-test - run: flutter analyze # Excludes the `golden` tag: visual-regression tests live under # `test/goldens/` and are validated on the self-hosted runner diff --git a/.github/workflows/tier3-handbook.yaml b/.github/workflows/tier3-handbook.yaml index d2d40832d..00f36fb28 100644 --- a/.github/workflows/tier3-handbook.yaml +++ b/.github/workflows/tier3-handbook.yaml @@ -14,7 +14,12 @@ name: Tier 3 — Handbook flows # the existing Analyze & Test job. Reviewers opt-in by labelling # the PR. The label gate is the cost control, not the branch # filter — stacked PRs against integration branches need the same -# opt-in option. +# opt-in option. Types are `opened` / `synchronize` / `reopened` / +# `labeled` / `unlabeled`. `ready_for_review` is omitted on purpose: +# a labelled draft that is later marked ready must not start a +# second macOS run — the labelled run already covers that SHA. +# (Contrast `pull-request.yaml`, where draft-skip means becoming +# ready is the first real run and `ready_for_review` belongs there.) # * `push: develop` always runs — post-merge verification is the # authoritative source of truth for "did the handbook flows survive # this merge", independent of any PR state. Same reasoning as @@ -22,9 +27,11 @@ name: Tier 3 — Handbook flows # * `workflow_dispatch` for manual catch-up runs and a re-run path when # the macOS image flips an Xcode default and we need to verify the fix # before re-arming the gate. Always runs (label gate doesn't apply). -# * `labeled` / `unlabeled` keep the trigger surface in sync with the -# `tier3:full` opt-in: adding the label after the PR opened starts a -# run; removing it cancels via the concurrency block below. +# * `labeled` / `unlabeled` stay in `types` so GitHub delivers the +# opt-in events. Adding `tier3:full` starts a run (job `if` matches +# that label name). Removing it does not start a run and does not +# cancel an in-flight one — labeled events use a unique concurrency +# group, so an unlabeled skip cannot stop the flow already running. # # Concurrency: # Same pattern as `pull-request.yaml`: group by `pull_request.number` @@ -36,7 +43,10 @@ name: Tier 3 — Handbook flows # get their own `ci--label-` group so a label toggle # does NOT kill an already-running ~15-minute flow execution — the user # experience of "I added the label, the previous run died" is worse -# than the cost of one extra runner-minute. +# than the cost of one extra runner-minute. That unique group is also +# why omitting `ready_for_review` matters: a later ready event would +# join the PR-number group and would not cancel the in-flight labeled +# run, so both would race on macos-latest. # # Locale: scripts/run-handbook-flows.sh pins the booted simulator to de_CH # so German handbook assertions pass on `macos-latest` runners (default @@ -66,8 +76,11 @@ name: Tier 3 — Handbook flows # as a safety net for that residual crash class, retrying the # driver hang/death class against both the CLI tee-log and # `--debug-output` maestro.log (`IOSDriverTimeoutException`, -# ConnectException on :7001, Connection refused/reset) — -# assertion failures without those patterns are never retried. +# ConnectException on :7001, Connection refused/reset, +# UnknownFailure HTTP 500 on :7001/deviceInfo). A hung JVM after +# that 500 is killed per attempt (`MAESTRO_ATTEMPT_TIMEOUT_SEC`) +# so the 60-minute job does not sit until GitHub cancels it. +# Assertion failures without those patterns are never retried. # The post-mortem block (lsof / ps / simctl log + Maestro # `--debug-output`) captures failure state so future regressions # can be diagnosed forensically. The CI-hardening track that landed @@ -110,7 +123,7 @@ on: # covered by `push: develop` on the same SHA — see the longer # rationale in `pull-request.yaml`). branches-ignore: [main] - types: [opened, synchronize, reopened, ready_for_review, labeled, unlabeled] + types: [opened, synchronize, reopened, labeled, unlabeled] concurrency: group: >- @@ -131,9 +144,19 @@ jobs: # Drafts pass through unconditionally on push (they cannot push, so # the only way here is via a labelled PR) — no extra `draft == false` # guard needed. + # `labeled` must match the opt-in label itself: with only `contains(...)`, + # adding any other label on an already-labelled PR would start a second + # macos-latest run (labeled events use a unique concurrency group and + # would not cancel the in-flight one). `unlabeled` is skipped here — + # the unique group also means it cannot cancel an in-flight flow. if: >- github.event_name != 'pull_request' - || contains(github.event.pull_request.labels.*.name, 'tier3:full') + || (github.event.action == 'labeled' && github.event.label.name == 'tier3:full') + || ( + github.event.action != 'labeled' + && github.event.action != 'unlabeled' + && contains(github.event.pull_request.labels.*.name, 'tier3:full') + ) runs-on: macos-latest # ~12 min setup (Flutter + tooling, build, sim erase/boot/install) plus # ~26-33 min for the 26 handbook flows — each flow restarts the XCUITest diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index da7f04aac..73a0891b0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,6 +33,25 @@ The app must stay fully usable on every **standard phone** we support (smallest New sticky-CTA UI without `ScrollableActionsLayout` + matrix entry is a **blocking** review finding. +## Scanner navigation — CRITICAL + +[`QrScannerView`](lib/widgets/scanner/qr_scanner_view.dart) / `MobileScanner` forwards **every camera frame** while a barcode is in view — it is not a one-shot capture. + +**Navigation rule** + +- Navigating from a scan result in the same turn as `cubit.reset()` is **forbidden**: it drops the cubit's "already decoded" / Valid guard, so the next frame pushes a second copy of the next route. The user ends up on two stacked amount (or quote) screens and cannot leave cleanly. +- Production contract: every `QrScannerView` consumer uses [`pushThenRearm`](lib/widgets/scanner/push_then_rearm.dart) to push the next route. Rearm / `reset` runs after `Route.completed` (the outgoing animation is gone) or immediately if the push throws — **never** in the same listener turn. In this app a scanner that does not push a route is not a valid consumer — add it to the catalog with `pushThenRearm` or do not construct `QrScannerView`. +- Cubit `onCodeDetected` must ignore further detections while the decoded/valid state is held. That guard is **necessary and not sufficient** — without `pushThenRearm`, an early `reset()` makes it a no-op. + +**Test rule (gate for this bug class)** + +- Catalog: [`test/helper/scanner_navigation_catalog.dart`](test/helper/scanner_navigation_catalog.dart). +- Self-test: [`scanner_navigation_catalog_test.dart`](test/helper/scanner_navigation_catalog_test.dart) — every catalogued production + regression path exists; production files contain `QrScannerView(` **and** `pushThenRearm(`; regression files fire `BarcodeCapture` and assert the destination `findsOne`; **discovery** walks `lib/` and fails if any `QrScannerView(` consumer is missing from the catalog (skipping the widget definition file itself). +- Each surface has a real-cubit widget test that fires **two** `BarcodeCapture`s and expects the destination `findsOne`, then pop, then a third capture is accepted again. Mocking the cubit / `whenListen` of a single Valid/Decoded state cannot catch this bug class. +- Details and PR checklist: [`docs/testing.md`](docs/testing.md) § *Scanner navigation*. + +New `QrScannerView` consumer without catalog entry + `pushThenRearm` + double-capture test is a **blocking** review finding. + ## Branch Flow Three branches participate in the release lane: diff --git a/android/fastlane/Fastfile b/android/fastlane/Fastfile index eb2a22c02..10048e55c 100644 --- a/android/fastlane/Fastfile +++ b/android/fastlane/Fastfile @@ -51,10 +51,33 @@ def upload_sentry_symbols(marketing_version:, version_code:, symbols_path:, dart UI.user_error!("Sentry upload: Dart obfuscation map not found at #{dart_symbol_map_path}") end - sh("dart", "run", "sentry_dart_plugin", - "--sentry-define=symbols_path=#{symbols_path}", - "--sentry-define=dart_symbol_map_path=#{dart_symbol_map_path}" - ) + # Self-hosted Sentry can return "Not all chunks available for + # assembling" after "Nothing to upload" (files already on the + # server, assemble stuck). That pair continues. Other plugin + # errors retry up to max_attempts, then fail the lane. + max_attempts = 3 + attempt = 0 + begin + attempt += 1 + sh("dart", "run", "sentry_dart_plugin", + "--sentry-define=symbols_path=#{symbols_path}", + "--sentry-define=dart_symbol_map_path=#{dart_symbol_map_path}" + ) + rescue FastlaneCore::Interface::FastlaneShellError => e + stuck = e.message.include?("Not all chunks available for assembling") && + e.message.include?("Nothing to upload") + if stuck + # Files are already on the server; waiting for assemble loops on + # incomplete chunks. Do not fail the lane after that. + UI.important("Sentry already has these files but assemble is stuck; continuing") + elsif attempt >= max_attempts + raise + else + UI.important("Sentry symbol upload failed (#{attempt}/#{max_attempts}), retrying in 20s") + sleep 20 + retry + end + end end end @@ -133,23 +156,11 @@ platform :android do end end - upload_sentry_symbols( - marketing_version: marketing_version, - version_code: version_code, - symbols_path: "build/debug_info/appbundle", - dart_symbol_map_path: "build/appbundle-obfuscation-map.json" - ) - upload_sentry_symbols( - marketing_version: marketing_version, - version_code: version_code, - symbols_path: "build/debug_info/apk", - dart_symbol_map_path: "build/apk-obfuscation-map.json" - ) - # Upload the binary + changelog only. The changelog is tied to this # build's version code (taken from the AAB); the listing metadata and # images are pushed by the dedicated call below, keeping the two # concerns separate and avoiding a duplicate metadata upload. + # Play Store first: a stuck Sentry assemble must not block testers. upload_to_play_store( track: release_track, aab: aab_path, @@ -173,6 +184,19 @@ platform :android do skip_upload_screenshots: false, track: release_track ) + + upload_sentry_symbols( + marketing_version: marketing_version, + version_code: version_code, + symbols_path: "build/debug_info/appbundle", + dart_symbol_map_path: "build/appbundle-obfuscation-map.json" + ) + upload_sentry_symbols( + marketing_version: marketing_version, + version_code: version_code, + symbols_path: "build/debug_info/apk", + dart_symbol_map_path: "build/apk-obfuscation-map.json" + ) end desc "Upload Play Store listing (metadata + screenshots) without a binary" diff --git a/assets/languages/strings_de.arb b/assets/languages/strings_de.arb index b69627dd7..7ed95d199 100644 --- a/assets/languages/strings_de.arb +++ b/assets/languages/strings_de.arb @@ -26,6 +26,8 @@ "blockchain": "Blockchain", "buy": "Kaufen", "buyBackToMain": "Zurück zum Hauptbereich", + "buyChargedAmount": "Genau belastet", + "buyMaxAmount": "Maximalbetrag: ${amount} ${currency}", "buyMinAmount": "Mindestbetrag: ${amount} ${currency}", "buyPaymentConfirm": "Jetzt verbindlich kaufen", "buyPaymentDetailsTitle": "Zahlungsdetails", @@ -183,6 +185,8 @@ "payFailureQuoteExpired": "Das Zahlungsangebot ist abgelaufen. Bitte scannen Sie den Code erneut.", "payFailureSignatureUnsupported": "Diese Wallet kann keine Transaktionen signieren. Wechseln Sie zu einer Software- oder BitBox-Wallet.", "payFailureTitle": "Zahlung fehlgeschlagen", + "payInfoBody": "Die Bezahlfunktion kann an allen OpenCryptoPay-Standorten genutzt werden.\n\nDer Tausch erfolgt in zwei Schritten. Zuerst wird REALU in ZCHF getauscht. ZCHF ist ein CHF-Stablecoin, der anschliessend an den Zahlungsempfänger übertragen wird.\n\nREALU ist ein Wertpapier und wird nicht für die Bezahlung selbst verwendet.", + "payInfoTitle": "Bevor Sie bezahlen", "paymentInformationFailed": "Beim Abrufen der Zahlungsinformationen ist ein Fehler aufgetreten.", "paymentInformationFailedDescription": "Bitte versuchen Sie es später erneut. Wenn der Fehler weiterhin besteht, wenden Sie sich an unseren Support.", "payoutAccountAdd": "Auszahlungskonto hinzufügen", @@ -350,6 +354,8 @@ "sendFailureSignatureCancelled": "Die Signatur wurde abgebrochen. Ihre REALU bleiben unangetastet.", "sendFailureSignatureUnsupported": "Diese Wallet kann keine gaslosen Überweisungen signieren. Wechseln Sie zu einer Software-Wallet, um REALU zu senden.", "sendFailureTitle": "Überweisung fehlgeschlagen", + "sendInfoBody": "Sie können REALU nur an Wallet-Adressen senden, die bei RealUnit als Aktionärin oder Aktionär registriert sind. Nur so kann der Empfänger sein Stimmrecht ausüben.\n\nDurch die Ausführung dieser Transaktion wird das Aktienbuch aktualisiert.", + "sendInfoTitle": "Bevor Sie senden", "sending": "Wird gesendet", "sendPaste": "Einfügen", "sendPreparing": "Überweisung wird vorbereitet", @@ -359,7 +365,7 @@ "sendRecipientManualHint": "Scannen Sie einen Wallet-QR-Code oder fügen Sie die Empfängeradresse unten ein.", "sendRecipientTitle": "Empfänger scannen", "sendShares": "${shares} REALU", - "sendSigning": "Bestätigen Sie die Überweisung in Ihrer Wallet", + "sendSigning": "Überweisung wird gesendet", "sendSuccess": "Überweisung gesendet", "sendSuccessDescription": "Ihre REALU sind auf dem Weg zum Empfänger.", "setNationalityFailed": "Ihre Staatsangehörigkeit konnte nicht gesetzt werden:\n${message}", @@ -465,4 +471,4 @@ "youPay": "Sie bezahlen", "youReceive": "Sie erhalten", "youSell": "Sie verkaufen" -} \ No newline at end of file +} diff --git a/assets/languages/strings_en.arb b/assets/languages/strings_en.arb index 58a7d2b84..44db6efe9 100644 --- a/assets/languages/strings_en.arb +++ b/assets/languages/strings_en.arb @@ -26,6 +26,8 @@ "blockchain": "Blockchain", "buy": "Buy", "buyBackToMain": "Back to main area", + "buyChargedAmount": "Exact charge", + "buyMaxAmount": "Maximum amount: ${amount} ${currency}", "buyMinAmount": "Minimum amount: ${amount} ${currency}", "buyPaymentConfirm": "Buy now bindingly", "buyPaymentDetailsTitle": "Payment details", @@ -183,6 +185,8 @@ "payFailureQuoteExpired": "The payment quote expired. Please scan the code again.", "payFailureSignatureUnsupported": "This wallet cannot sign transactions. Switch to a software or BitBox wallet.", "payFailureTitle": "Payment failed", + "payInfoBody": "You can use the payment function at all OpenCryptoPay locations.\n\nThe exchange happens in two steps. First, REALU is swapped for ZCHF. ZCHF is a Swiss franc stablecoin, which is then transferred to the payment recipient.\n\nREALU is a security and is not used for the payment itself.", + "payInfoTitle": "Before you pay", "paymentInformationFailed": "An error occurred while getting the payment information.", "paymentInformationFailedDescription": "Please try again later. If the error persists, contact our support team.", "payoutAccountAdd": "Add payout account", @@ -350,6 +354,8 @@ "sendFailureSignatureCancelled": "The signature was cancelled. Your REALU is untouched.", "sendFailureSignatureUnsupported": "This wallet cannot sign gasless transfers. Switch to a software wallet to send REALU.", "sendFailureTitle": "Transfer failed", + "sendInfoBody": "You can only send REALU to wallet addresses that are registered as RealUnit shareholders. This is required so the recipient can exercise their voting rights.\n\nCompleting this transfer updates the share register.", + "sendInfoTitle": "Before you send", "sending": "Sending", "sendPaste": "Paste", "sendPreparing": "Preparing transfer", @@ -359,7 +365,7 @@ "sendRecipientManualHint": "Scan a wallet QR code, or paste the recipient address below.", "sendRecipientTitle": "Scan recipient", "sendShares": "${shares} REALU", - "sendSigning": "Confirm the transfer in your wallet", + "sendSigning": "Sending the transfer", "sendSuccess": "Transfer sent", "sendSuccessDescription": "Your REALU is on its way to the recipient.", "setNationalityFailed": "Could not set your nationality:\n${message}", @@ -465,4 +471,4 @@ "youPay": "You pay", "youReceive": "You receive", "youSell": "You sell" -} \ No newline at end of file +} diff --git a/docs/handbook/README.md b/docs/handbook/README.md index 88fe69b5a..6c7497e23 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 278 Handbook-Screenshots +Es gibt keinen separaten Regeneration-Schritt: Die 284 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 — @@ -64,6 +64,15 @@ Auch der Tier-3-GitHub-Workflow hat dafür einen `flows`-`workflow_dispatch`-Inp neu laufen lassen. (Die Screenshots zieht das Handbook aus den Goldens, nicht mehr aus diesen Maestro-Läufen.) +## Live Geo-Filter-Tabelle + +Die Sektion **Aktientoken — Geo-Filter** (`#spec-geo`) lädt `GET /v1/country` +zur Laufzeit. Länderzeilen gehören nicht ins Repo. Namen kommen als Deutsch und +Englisch aus `Intl.DisplayNames` (ISO 3166), nicht aus `foreignName`. CSV, Excel +und PDF exportieren die angezeigte Liste. Im Image proxied nginx `/v1/country` +auf `https://api.dfx.swiss/v1/country`; eine lokale HTML-Vorschau fällt auf die +öffentliche API zurück. + ## Einen neuen Handbook-Eintrag hinzufügen 1. **Page + Golden-Test**: `lib/screens//_page.dart` + zugehörigen diff --git a/docs/handbook/de/geo-filter.js b/docs/handbook/de/geo-filter.js new file mode 100644 index 000000000..a96223bd5 --- /dev/null +++ b/docs/handbook/de/geo-filter.js @@ -0,0 +1,414 @@ +/** + * Live RealUnit share-token geo-filter table. + * + * Source of truth is GET /v1/country. Rows are never copied into this repo. + * Country labels are German and English via Intl.DisplayNames (ISO 3166-1), + * not API foreignName (often native script). + */ +(function () { + var URLS = ['/v1/country', 'https://api.dfx.swiss/v1/country']; + var COLUMNS = [ + { key: 'symbol', label: 'ISO' }, + { key: 'nameDe', label: 'Land (DE)' }, + { key: 'nameEn', label: 'Country (EN)' }, + { key: 'residence', label: 'Wohnsitz' }, + { key: 'nationality', label: 'Nationalität' }, + { key: 'residenceExisting', label: 'Wohnsitz Bestand' }, + { key: 'nationalityExisting', label: 'Nationalität Bestand' }, + { key: 'ipEnable', label: 'IP neu' }, + { key: 'ipExisting', label: 'IP Bestand' }, + { key: 'taxEnable', label: 'Steuer neu' }, + { key: 'taxExisting', label: 'Steuer Bestand' }, + ]; + + var displayDe; + var displayEn; + try { + displayDe = new Intl.DisplayNames(['de'], { type: 'region', fallback: 'none' }); + displayEn = new Intl.DisplayNames(['en'], { type: 'region', fallback: 'none' }); + } catch (e) { + try { + displayDe = new Intl.DisplayNames(['de'], { type: 'region' }); + displayEn = new Intl.DisplayNames(['en'], { type: 'region' }); + } catch (e2) { + displayDe = null; + displayEn = null; + } + } + + function $(id) { + return document.getElementById(id); + } + + function regionName(display, symbol, fallback) { + if (display && symbol) { + try { + var label = display.of(symbol); + if (label && String(label).toUpperCase() !== String(symbol).toUpperCase()) { + return label; + } + } catch (e) {} + } + return fallback || symbol || ''; + } + + function cell(value) { + if (value === true) return 'ja'; + if (value === false) return 'nein'; + if (value == null || value === '') return '—'; + return String(value); + } + + function tone(field, value) { + if (value == null || value === '') return 'muted'; + if (field === 'residence') { + if (value === 'Allowed') return 'ok'; + if (value === 'Sanction') return 'bad'; + if (value === 'Restricted') return 'warn'; + return 'muted'; + } + if (field === 'nationality') { + if (value === 'Ok') return 'ok'; + if (value === 'Blocked') return 'bad'; + if (value === 'Exception') return 'warn'; + return 'muted'; + } + if (value === true) return 'ok'; + if (value === false) return 'bad'; + return 'muted'; + } + + function rowView(country) { + var geo = country.realunit || {}; + var english = country.name || country.symbol; + return { + symbol: country.symbol, + nameDe: regionName(displayDe, country.symbol, english), + nameEn: regionName(displayEn, country.symbol, english), + residence: geo.residence, + nationality: geo.nationality, + residenceExisting: geo.residenceExisting, + nationalityExisting: geo.nationalityExisting, + ipEnable: geo.ipEnable, + ipExisting: geo.ipExisting, + taxEnable: geo.taxEnable, + taxExisting: geo.taxExisting, + }; + } + + function load(urls) { + var url = urls[0]; + return fetch(url, { credentials: 'same-origin' }).then(function (res) { + if (!res.ok) throw new Error('HTTP ' + res.status); + return res.json().then(function (body) { + if (!Array.isArray(body)) throw new Error('unexpected payload'); + return { body: body, url: url }; + }); + }).catch(function (err) { + if (urls.length > 1) return load(urls.slice(1)); + throw err; + }); + } + + function filteredRows(state) { + var q = ($('geo-filter-search') || {}).value || ''; + q = q.trim().toLowerCase(); + var residence = ($('geo-filter-residence') || {}).value || ''; + var nationality = ($('geo-filter-nationality') || {}).value || ''; + return state.rows.filter(function (row) { + if ( + q && + (row.symbol || '').toLowerCase().indexOf(q) < 0 && + (row.nameDe || '').toLowerCase().indexOf(q) < 0 && + (row.nameEn || '').toLowerCase().indexOf(q) < 0 + ) { + return false; + } + if (residence && row.residence !== residence) return false; + if (nationality && row.nationality !== nationality) return false; + return true; + }); + } + + function stamp() { + var d = new Date(); + var p = function (n) { + return n < 10 ? '0' + n : String(n); + }; + return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()); + } + + function fileBase() { + return 'realunit-geo-filter-' + stamp(); + } + + function saveBlob(blob, filename) { + var url = URL.createObjectURL(blob); + var a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(function () { + URL.revokeObjectURL(url); + }, 1000); + } + + function xmlEscape(value) { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + function csvField(value) { + var s = cell(value); + if (/[;"\n\r]/.test(s)) return '"' + s.replace(/"/g, '""') + '"'; + return s; + } + + function downloadCsv(rows) { + var lines = [COLUMNS.map(function (c) { return csvField(c.label); }).join(';')]; + rows.forEach(function (row) { + lines.push(COLUMNS.map(function (c) { return csvField(row[c.key]); }).join(';')); + }); + var blob = new Blob(['\uFEFF' + lines.join('\r\n')], { type: 'text/csv;charset=utf-8' }); + saveBlob(blob, fileBase() + '.csv'); + } + + function downloadExcel(rows) { + var xml = '\r\n'; + xml += '\r\n'; + xml += '' + xmlEscape(c.label) + ''; + }).join('') + '\r\n'; + rows.forEach(function (row) { + xml += '' + COLUMNS.map(function (c) { + return '' + xmlEscape(cell(row[c.key])) + ''; + }).join('') + '\r\n'; + }); + xml += ''; + var blob = new Blob([xml], { type: 'application/vnd.ms-excel' }); + saveBlob(blob, fileBase() + '.xls'); + } + + var WINANSI = { + 0x20ac: 128, + 0x201a: 130, + 0x0192: 131, + 0x201e: 132, + 0x2026: 133, + 0x2020: 134, + 0x2021: 135, + 0x02c6: 136, + 0x2030: 137, + 0x0160: 138, + 0x2039: 139, + 0x0152: 140, + 0x017d: 142, + 0x2018: 145, + 0x2019: 146, + 0x201c: 147, + 0x201d: 148, + 0x2022: 149, + 0x2013: 150, + 0x2014: 151, + 0x02dc: 152, + 0x2122: 153, + 0x0161: 154, + 0x203a: 155, + 0x0153: 156, + 0x017e: 158, + 0x0178: 159, + }; + + function pdfEscape(text) { + var s = ''; + var raw = String(text); + for (var i = 0; i < raw.length; i++) { + var code = raw.charCodeAt(i); + if (WINANSI[code] != null) code = WINANSI[code]; + if (code === 92) s += '\\\\'; + else if (code === 40) s += '\\('; + else if (code === 41) s += '\\)'; + else if (code === 13 || code === 10) s += ' '; + else if (code >= 32 && code < 128) s += String.fromCharCode(code); + else if (code >= 128 && code <= 255) s += '\\' + ('00' + code.toString(8)).slice(-3); + else s += '?'; + } + return s; + } + + function downloadPdf(rows) { + var pageW = 842; + var pageH = 595; + var margin = 32; + var fontSize = 8; + var lineH = 11; + var headerH = 36; + var usable = pageH - margin * 2 - headerH; + var rowsPerPage = Math.max(1, Math.floor(usable / lineH) - 1); + var pages = []; + for (var i = 0; i < rows.length || (rows.length === 0 && pages.length === 0); i += rowsPerPage) { + pages.push(rows.slice(i, i + rowsPerPage)); + if (rows.length === 0) break; + } + var colX = [margin, 62, 168, 274, 348, 422, 496, 546, 596, 646, 700]; + + function pageStream(pageRows, pageIndex, pageCount) { + var y = pageH - margin - 14; + var out = 'BT\n/F1 11 Tf\n'; + out += margin + ' ' + y + ' Td\n(' + pdfEscape('RealUnit Aktientoken — Geo-Filter') + ') Tj\n'; + out += '/F1 8 Tf\n0 -12 Td\n(' + pdfEscape(fileBase() + ' · Seite ' + (pageIndex + 1) + '/' + pageCount) + ') Tj\n'; + y -= headerH; + out += '/F1 8 Tf\n'; + COLUMNS.forEach(function (col, idx) { + out += '1 0 0 1 ' + colX[idx] + ' ' + y + ' Tm\n(' + pdfEscape(col.label) + ') Tj\n'; + }); + y -= lineH; + pageRows.forEach(function (row) { + COLUMNS.forEach(function (col, idx) { + var value = cell(row[col.key]); + if (value.length > 22 && (col.key === 'nameDe' || col.key === 'nameEn')) value = value.slice(0, 21) + '...'; + out += '1 0 0 1 ' + colX[idx] + ' ' + y + ' Tm\n(' + pdfEscape(value) + ') Tj\n'; + }); + y -= lineH; + }); + out += 'ET\n'; + return out; + } + + var objects = []; + objects.push('<< /Type /Catalog /Pages 2 0 R >>'); + var pageIds = []; + var contentIds = []; + var fontId; + var startId = 3; + for (var p = 0; p < pages.length; p++) { + pageIds.push(startId + p); + contentIds.push(startId + pages.length + p); + } + fontId = startId + pages.length * 2; + var kids = pageIds.map(function (id) { return id + ' 0 R'; }).join(' '); + objects.push('<< /Type /Pages /Kids [' + kids + '] /Count ' + pages.length + ' >>'); + + var contents = []; + pages.forEach(function (pageRows, idx) { + objects.push( + '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ' + + pageW + + ' ' + + pageH + + '] /Contents ' + + contentIds[idx] + + ' 0 R /Resources << /Font << /F1 ' + + fontId + + ' 0 R >> >> >>' + ); + contents[idx] = pageStream(pageRows, idx, pages.length); + }); + contents.forEach(function (stream) { + objects.push('<< /Length ' + stream.length + ' >>\nstream\n' + stream + 'endstream'); + }); + objects.push('<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>'); + + var xref = [0]; + var pdf = '%PDF-1.4\n'; + objects.forEach(function (body, idx) { + xref.push(pdf.length); + pdf += idx + 1 + ' 0 obj\n' + body + '\nendobj\n'; + }); + var xrefPos = pdf.length; + pdf += 'xref\n0 ' + (objects.length + 1) + '\n'; + pdf += '0000000000 65535 f \n'; + xref.slice(1).forEach(function (offset) { + var n = String(offset); + pdf += ('0000000000' + n).slice(-10) + ' 00000 n \n'; + }); + pdf += 'trailer\n<< /Size ' + (objects.length + 1) + ' /Root 1 0 R >>\nstartxref\n' + xrefPos + '\n%%EOF'; + saveBlob(new Blob([pdf], { type: 'application/pdf' }), fileBase() + '.pdf'); + } + + function render(state) { + var tbody = $('geo-filter-body'); + var status = $('geo-filter-status'); + var missing = $('geo-filter-missing'); + if (!tbody) return; + + var rows = filteredRows(state); + tbody.textContent = ''; + rows.forEach(function (row) { + var tr = document.createElement('tr'); + COLUMNS.forEach(function (col) { + var td = document.createElement('td'); + td.textContent = cell(row[col.key]); + td.className = 'geo-tone-' + tone(col.key, row[col.key]); + tr.appendChild(td); + }); + tbody.appendChild(tr); + }); + + if (status) { + status.textContent = + rows.length + ' von ' + state.rows.length + ' Ländern · Quelle ' + state.url; + } + if (missing) { + missing.hidden = state.hasRealunit; + } + state.visible = rows; + var ready = rows.length > 0; + ['geo-filter-csv', 'geo-filter-xls', 'geo-filter-pdf'].forEach(function (id) { + var btn = $(id); + if (btn) btn.disabled = !ready; + }); + } + + function bindDownloads(state) { + var csv = $('geo-filter-csv'); + var xls = $('geo-filter-xls'); + var pdf = $('geo-filter-pdf'); + if (csv) csv.addEventListener('click', function () { downloadCsv(state.visible || []); }); + if (xls) xls.addEventListener('click', function () { downloadExcel(state.visible || []); }); + if (pdf) pdf.addEventListener('click', function () { downloadPdf(state.visible || []); }); + } + + function init() { + var root = $('geo-filter-live'); + if (!root) return; + var status = $('geo-filter-status'); + if (status) status.textContent = 'Lade GET /v1/country …'; + + load(URLS) + .then(function (result) { + var rows = result.body.map(rowView); + var hasRealunit = result.body.some(function (country) { + return country && country.realunit != null; + }); + var state = { rows: rows, url: result.url, hasRealunit: hasRealunit, visible: rows }; + ['geo-filter-search', 'geo-filter-residence', 'geo-filter-nationality'].forEach(function (id) { + var el = $(id); + if (el) el.addEventListener('input', function () { render(state); }); + if (el) el.addEventListener('change', function () { render(state); }); + }); + bindDownloads(state); + render(state); + }) + .catch(function (err) { + if (status) { + status.textContent = + 'Tabelle konnte nicht geladen werden (' + (err && err.message ? err.message : err) + ').'; + } + }); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})(); diff --git a/docs/handbook/de/index.html b/docs/handbook/de/index.html index 5c52b111b..d2f10836e 100644 --- a/docs/handbook/de/index.html +++ b/docs/handbook/de/index.html @@ -656,7 +656,8 @@ font-size: 13px; color: var(--ink-2); } - a.dl-btn { + a.dl-btn, + button.dl-btn { text-decoration: none; font-size: 12.5px; font-weight: 600; @@ -670,7 +671,8 @@ border-color 0.15s, color 0.15s; } - a.dl-btn:hover { + a.dl-btn:hover, + button.dl-btn:hover:not(:disabled) { background: var(--surface); border-color: var(--brand); } @@ -724,6 +726,104 @@ border-radius: 8px; padding: 14px 16px; } + #spec-80 .persona-trades { + width: 100%; + border-collapse: collapse; + font-size: 13px; + margin: 8px 0 0; + } + #spec-80 .persona-trades th, + #spec-80 .persona-trades td { + border-bottom: 1px solid var(--line-2); + padding: 4px 8px; + text-align: left; + } + #spec-80 .persona-trades th { + color: var(--ink-3); + font-weight: 600; + } + #spec-80 .persona-trades td.num { + text-align: right; + font-variant-numeric: tabular-nums; + } + #geo-filter-live .geo-toolbar { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 0 0 12px 0; + align-items: center; + } + #geo-filter-live .geo-toolbar input, + #geo-filter-live .geo-toolbar select { + font: inherit; + padding: 6px 8px; + border: 1px solid var(--line-2); + border-radius: 4px; + background: var(--surface); + color: var(--ink); + } + #geo-filter-live .geo-toolbar input { + min-width: 180px; + flex: 1; + } + #geo-filter-live .geo-downloads { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 0 0 12px 0; + } + #geo-filter-live .geo-downloads button.dl-btn { + cursor: pointer; + font: inherit; + } + #geo-filter-live .geo-downloads button.dl-btn:disabled { + opacity: 0.45; + cursor: not-allowed; + } + #geo-filter-status { + margin: 0 0 10px 0; + font-size: 12.5px; + color: var(--ink-3); + } + #geo-filter-scroller { + overflow-x: auto; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface); + } + #geo-filter-table { + width: 100%; + border-collapse: collapse; + font-size: 12.5px; + } + #geo-filter-table th, + #geo-filter-table td { + padding: 6px 8px; + border-bottom: 1px solid var(--line); + text-align: left; + white-space: nowrap; + } + #geo-filter-table thead th { + background: var(--surface-2); + font-weight: 600; + position: sticky; + top: 0; + } + #geo-filter-table tbody tr:hover { + background: var(--surface-2); + } + .geo-tone-ok { + color: #0f7a3d; + } + .geo-tone-warn { + color: #9a6b12; + } + .geo-tone-bad { + color: #b42318; + } + .geo-tone-muted { + color: var(--ink-3); + } /* Downloads section (#spec-downloads) — static, hand-maintained list of the three public tester/download links. Reuses the .test card chrome (border + :target highlight) and the .copy-link button; the body adds a @@ -773,6 +873,7 @@ word-break: break-all; } +
@@ -1033,6 +1134,12 @@
  • 79Insider-Freischaltung — Bezahlen & Senden
  • +
  • + 80Testkunden — Bestandsentwicklung +
  • +
  • + GAktientoken — Geo-Filter +
  • WWeb · realunit.app
  • @@ -8060,6 +8167,312 @@

    79Insider-Freischaltung — Bezahlen & Senden +
    + +
    +
    +

    80Testkunden — Bestandsentwicklung

    +
    test/goldens/screens/dashboard/handbook_persona_*
    +
    +
    + 6 Screens + +
    +
    +
    +

    + Fünf synthetische Testkunden (keine Live-Konten) mit unterschiedlichem + Kaufverhalten. Jede Historie hat mindestens zehn Bestandswechsel innerhalb + eines Jahres, Stand der Tabellen 28.08.2026. Die Diagramme sind + Golden-Baselines der MAX-Ansicht; bei K3 zusätzlich 1J nach dem Verkauf auf 0. + Unter Bestand erscheinen die drei jüngsten On-Chain-Transfers + (Letzte Transaktionen) — bei Bestand 0 (K3) blendet das Dashboard + diese Karte aus. Die Kurve ist der Marktwert in CHF (Stückzahl × REALU-Preis + des jeweiligen Tages), nicht die reine Stückzahl. +

    + +
    +
    +
    + + + handbook_persona_dca.png +
    +
    + K1 Monatskäufer — MAX-Treppe aus zwölf gleichen Käufen +
    +
    + K1 Monatskäufer. Zwölf gleiche Käufe à 80 REALU, Bestand 960. + + + + + + + + + + + + + + + + +
    DatumRichtungΔ REALUBestand
    2025-09-27Kauf+8080
    2025-10-27Kauf+80160
    2025-11-26Kauf+80240
    2025-12-26Kauf+80320
    2026-01-25Kauf+80400
    2026-02-24Kauf+80480
    2026-03-26Kauf+80560
    2026-04-25Kauf+80640
    2026-05-25Kauf+80720
    2026-06-24Kauf+80800
    2026-07-24Kauf+80880
    2026-08-23Kauf+80960
    +
    +
    + +
    +
    + + + handbook_persona_lump.png +
    +
    + K2 Einmalkauf — MAX mit einem grossen Sprung und kleinen Nachkäufen +
    +
    + K2 Einmalkauf. Ein Lump von 5000 REALU, danach neun Nachkäufe à 50, Bestand 5450. + + + + + + + + + + + + + + +
    DatumRichtungΔ REALUBestand
    2025-09-22Kauf+50005000
    2025-11-01Kauf+505050
    2025-12-01Kauf+505100
    2025-12-31Kauf+505150
    2026-01-30Kauf+505200
    2026-03-01Kauf+505250
    2026-03-31Kauf+505300
    2026-04-30Kauf+505350
    2026-05-30Kauf+505400
    2026-07-29Kauf+505450
    +
    +
    + +
    +
    + + + handbook_persona_exit.png +
    +
    + K3 Verkauf auf 0 — MAX steigt und fällt auf die Null-Linie +
    +
    + K3 Verkauf auf 0. Acht Käufe, dann vier Verkäufe auf Bestand 0. + MAX zeigt den Anstieg und den Abverkauf; die Null-Linie bleibt sichtbar. + + + + + + + + + + + + + + + + +
    DatumRichtungΔ REALUBestand
    2025-10-12Kauf+200200
    2025-11-11Kauf+200400
    2025-12-11Kauf+150550
    2026-01-10Kauf+150700
    2026-02-09Kauf+100800
    2026-03-11Kauf+100900
    2026-04-10Kauf+80980
    2026-05-10Kauf+801060
    2026-06-09Verkauf−200860
    2026-07-09Verkauf−300560
    2026-08-03Verkauf−300260
    2026-08-18Verkauf−2600
    +
    +
    + +
    +
    + + + handbook_persona_exit_1j.png +
    +
    + K3 Verkauf auf 0 — 1J mit Abverkauf innerhalb des Jahres +
    +
    + Dieselbe K3-Historie in der Ansicht 1J. Der Abverkauf liegt + innerhalb des Jahresfensters, der Chart bleibt nach dem letzten + Verkauf eine sichtbare Null-Linie (Achse 0…10). +
    +
    + +
    +
    + + + handbook_persona_mix.png +
    +
    + K4 Mix — MAX mit Käufen und Teilverkäufen +
    +
    + K4 Mix. Acht Käufe und vier Teilverkäufe, Bestand 600. + + + + + + + + + + + + + + + + +
    DatumRichtungΔ REALUBestand
    2025-10-02Kauf+150150
    2025-11-01Kauf+150300
    2025-12-01Verkauf−50250
    2025-12-31Kauf+200450
    2026-01-30Kauf+100550
    2026-03-01Verkauf−100450
    2026-03-31Kauf+80530
    2026-04-30Kauf+80610
    2026-05-30Verkauf−60550
    2026-06-29Kauf+40590
    2026-07-29Kauf+40630
    2026-08-23Verkauf−30600
    +
    +
    + +
    +
    + + + handbook_persona_scale.png +
    +
    + K5 Aufstocken — MAX mit steigenden Kaufraten +
    +
    + K5 Aufstocken. Zwölf steigende Käufe (40 … 260 REALU), Bestand 1800. + + + + + + + + + + + + + + + + +
    DatumRichtungΔ REALUBestand
    2025-09-27Kauf+4040
    2025-10-27Kauf+60100
    2025-11-26Kauf+80180
    2025-12-26Kauf+100280
    2026-01-25Kauf+120400
    2026-02-24Kauf+140540
    2026-03-26Kauf+160700
    2026-04-25Kauf+180880
    2026-05-25Kauf+2001080
    2026-06-24Kauf+2201300
    2026-07-24Kauf+2401540
    2026-08-23Kauf+2601800
    +
    +
    +
    +
    + +
    + +
    + +
    +
    +

    GAktientoken — Geo-Filter

    +
    GET /v1/country · realunit.*
    +
    +
    + Live aus der API + +
    +
    +
    +

    + Wohnsitz- und Nationalitäts-Filter für den RealUnit-Aktientoken, direkt + aus GET /v1/country. Die JSON-Felder sind + realunit.residence und realunit.nationality + (plus nullable Existing/IP/Tax unter demselben realunit-Objekt); + auf der country-Tabelle heissen die Spalten + realunitResidence / realunitNationality. + Ländernamen sind Deutsch und Englisch (ISO 3166 über + Intl.DisplayNames), nicht die native foreignName + der API. CSV, Excel und PDF laden die aktuell angezeigte Liste. + Die Tabelle kopiert keine Zeilen ins Repo. Onboarding prüft nur Residence + und Nationality; IP, Steueransässigkeit und Grandfathering bleiben leer, + bis Legal sie füllt. +

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

    Lade GET /v1/country …

    + +
    + + + + + + + + + + + + + + + + + +
    ISOLand (DE)Country (EN)WohnsitzNationalitätWohnsitz BestandNationalität BestandIP neuIP BestandSteuer neuSteuer Bestand
    +
    +
    +
    + +
    +
    diff --git a/docs/screens.md b/docs/screens.md index 9784edae2..d72bcaabc 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 278 Golden baselines** — every screen **plus every tested + covers **all 284 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 @@ -136,7 +136,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 278 handbook slots is a Visual-Regression +- **Handbook numbering.** Each of the 284 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 2d47812b1..0372ef5f2 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -44,7 +44,7 @@ Test layout mirrors `lib/`. Stack: [`flutter_test`](https://pub.dev/packages/flu | `test/screens//cubit(s)/**` and `test/screens//bloc/**` | Cubit/Bloc state-transition specs (in the activated surface for line-coverage) | | `test/screens//**/*_page_test.dart` | `testWidgets` view specs (cover the page, not the cubit logic) | | `test/integration/` | Cross-layer Tier-1 specs using `FakeBitboxCredentials` (e.g. `kyc_sign_flow_test.dart`) | -| `test/helper/` | Shared test infra: [`pump_app.dart`](../test/helper/pump_app.dart), [`fake_bitbox_credentials.dart`](../test/helper/fake_bitbox_credentials.dart), [`responsive_matrix.dart`](../test/helper/responsive_matrix.dart), [`layout_assertions.dart`](../test/helper/layout_assertions.dart), [`responsive_surface_catalog.dart`](../test/helper/responsive_surface_catalog.dart) | +| `test/helper/` | Shared test infra: [`pump_app.dart`](../test/helper/pump_app.dart), [`fake_bitbox_credentials.dart`](../test/helper/fake_bitbox_credentials.dart), [`responsive_matrix.dart`](../test/helper/responsive_matrix.dart), [`layout_assertions.dart`](../test/helper/layout_assertions.dart), [`responsive_surface_catalog.dart`](../test/helper/responsive_surface_catalog.dart), [`scanner_navigation_catalog.dart`](../test/helper/scanner_navigation_catalog.dart) | | `test/models/` | DTO / marshalling specs (`asset_test.dart`, `balance_test.dart`, `transaction_test.dart`, …) | | `test/setup/` | App lifecycle / bootstrap specs (`lifecycle_initializer_test.dart`) | | `test/styles/` | Currency / language fixtures (`currency_test.dart`, `language_test.dart`) | @@ -142,6 +142,34 @@ Rules for PRs: Reference implementation: `test/screens/hardware_connect_bitbox/connect_bitbox_responsive_matrix_test.dart` (7 devices × 5 text scales × 5 button states + focused regressions). 29 surfaces are catalogued in [`responsive_surface_catalog.dart`](../test/helper/responsive_surface_catalog.dart) (living list — not a completeness proof; see above). +### Scanner navigation (required) + +**Bug class this gates:** a live camera delivers the same QR on every frame. Pushing the next route and calling `cubit.reset()` in the same listener turn drops the "already decoded" guard, so frame 2 pushes a second copy of the amount/quote page. The user is stuck on two stacked routes and cannot leave cleanly. + +**Production contract** + +1. Every [`QrScannerView`](../lib/widgets/scanner/qr_scanner_view.dart) consumer uses [`pushThenRearm`](../lib/widgets/scanner/push_then_rearm.dart): push first, call `rearm` / `reset` after `Route.completed` (or immediately if the push throws), never in the same listener turn. A scanner that does not push a route is not a valid `QrScannerView` consumer in this app. +2. Do **not** call `cubit.reset()` in the same turn as `Navigator.push` on a success/decoded branch. Invalid-scan snackbar paths that do not push a route may still reset immediately. +3. Cubit `onCodeDetected` must ignore further detections while Valid/Decoded is held. That guard is necessary and **not sufficient** without `pushThenRearm`. + +**Test contract (gates every catalogued scanner surface against this bug class)** + +| Piece | Role | +|---|---| +| [`scanner_navigation_catalog.dart`](../test/helper/scanner_navigation_catalog.dart) | Living list of production paths that construct `QrScannerView` + their real-cubit double-capture regression tests | +| [`scanner_navigation_catalog_test.dart`](../test/helper/scanner_navigation_catalog_test.dart) | Existence of catalogued files; production contains `QrScannerView(` **and** `pushThenRearm(`; regression files contain `BarcodeCapture` + destination `findsOne`; **discovery** — every `QrScannerView(` under `lib/` (except the widget definition) must be listed | +| Real-cubit regression specs (e.g. `send_recipient_scanner_navigation_test.dart`) | Pump the public page (real cubit, not mocked); fire two `onDetect` / `BarcodeCapture`s; expect destination `findsOne`; pop; third capture accepted again | + +Mocking the scan cubit and `whenListen`-ing a single Valid/Decoded emission **cannot** catch this bug class — the gate is the real cubit plus two `onDetect` calls while the decoded state is still held. + +Rules for PRs: + +- Any new `QrScannerView` consumer **must** use `pushThenRearm`. +- Register the surface in `kScannerNavigationCatalog` and add a real-cubit double-capture test **in the same PR**. +- Do not weaken cubit Valid/Decoded guards; do not make `QrScannerView` one-shot. + +Reference implementations: `test/screens/send/send_recipient_scanner_navigation_test.dart`, `test/screens/pay/pay_scan_scanner_navigation_test.dart`. + ### Service + HTTP For services that hit the DFX API, swap in `MockClient` from [`http/testing`](https://pub.dev/packages/http) and a `_MockAppStore` that returns it via the `httpClient` getter. diff --git a/handbook.nginx.conf b/handbook.nginx.conf index 0f9fde6ed..89619c236 100644 --- a/handbook.nginx.conf +++ b/handbook.nginx.conf @@ -64,6 +64,27 @@ server { # here; otherwise defining add_header inside the block would silently # drop them. Cache-Control is "no-store" so a healthcheck answer is # never cached by intermediaries while the container is actually down. + # Public country list used by the live geo-filter table. Same payload as + # api.dfx.swiss/v1/country; kept behind Basic Auth with the rest of the + # handbook. Variable proxy_pass needs a resolver so nginx looks the + # host up at request time (the image has no baked IP). + resolver 1.1.1.1 ipv6=off valid=300s; + location = /v1/country { + set $dfx_country_upstream https://api.dfx.swiss/v1/country; + proxy_pass $dfx_country_upstream; + proxy_ssl_server_name on; + proxy_ssl_name api.dfx.swiss; + proxy_set_header Host api.dfx.swiss; + # Country list is public. Do not forward handbook Basic Auth to the API. + proxy_set_header Authorization ""; + proxy_connect_timeout 10s; + proxy_read_timeout 20s; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Cache-Control "private, no-store" always; + } + location = /healthz { auth_basic off; add_header X-Content-Type-Options "nosniff" always; diff --git a/ios/fastlane/Fastfile b/ios/fastlane/Fastfile index 067dfa265..46065a344 100644 --- a/ios/fastlane/Fastfile +++ b/ios/fastlane/Fastfile @@ -49,7 +49,26 @@ def upload_sentry_symbols(marketing_version:, version_code:) UI.user_error!("Sentry upload: no dSYMs found under build/ios/archive") end - sh("dart", "run", "sentry_dart_plugin") + # Same stuck-assemble pair as Android: files already on the server + # and assemble cannot finish. Continue so TestFlight still runs. + max_attempts = 3 + attempt = 0 + begin + attempt += 1 + sh("dart", "run", "sentry_dart_plugin") + rescue FastlaneCore::Interface::FastlaneShellError => e + stuck = e.message.include?("Not all chunks available for assembling") && + e.message.include?("Nothing to upload") + if stuck + UI.important("Sentry already has these files but assemble is stuck; continuing") + elsif attempt >= max_attempts + raise + else + UI.important("Sentry symbol upload failed (#{attempt}/#{max_attempts}), retrying in 20s") + sleep 20 + retry + end + end end end diff --git a/lib/main.dart b/lib/main.dart index 47c34073c..a07923439 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,6 +8,7 @@ import 'package:realunit_wallet/packages/utils/fuck_firebase.dart'; import 'package:realunit_wallet/screens/home/bloc/home_bloc.dart'; import 'package:realunit_wallet/screens/pin/bloc/auth/pin_auth_cubit.dart'; import 'package:realunit_wallet/screens/settings/bloc/settings_bloc.dart'; +import 'package:realunit_wallet/setup/account_currency_sync.dart'; import 'package:realunit_wallet/setup/di.dart'; import 'package:realunit_wallet/setup/error_handling/crash_reporting.dart'; import 'package:realunit_wallet/setup/error_handling/error_handlers.dart'; @@ -60,6 +61,8 @@ class WalletApp extends StatefulWidget { } class _WalletAppState extends State { + late final AccountCurrencySync _accountCurrency = getIt(); + @override void initState() { super.initState(); @@ -88,6 +91,29 @@ class _WalletAppState extends State { routerConfig: routerConfig, builder: (context, child) => MultiBlocListener( listeners: [ + BlocListener( + listenWhen: (previous, current) => + previous.openWallet == null && current.openWallet != null, + listener: (context, homeState) { + _accountCurrency.onOpened(homeState.openWallet); + }, + ), + BlocListener( + listenWhen: (previous, current) => + previous.openWallet != null && + current.openWallet != null && + !identical(previous.openWallet, current.openWallet), + listener: (context, homeState) { + _accountCurrency.onSwitched(homeState.openWallet); + }, + ), + BlocListener( + listenWhen: (previous, current) => + previous.openWallet != null && current.openWallet == null, + listener: (context, homeState) { + _accountCurrency.onClosed(); + }, + ), BlocListener( listener: (context, homeState) { if (!homeState.isLoadingWallet) { diff --git a/lib/packages/repository/settings_repository.dart b/lib/packages/repository/settings_repository.dart index 7031b8ada..b2c22c1c2 100644 --- a/lib/packages/repository/settings_repository.dart +++ b/lib/packages/repository/settings_repository.dart @@ -25,7 +25,11 @@ class SettingsRepository { set language(String langCode) => _sharedPreferences.setString('language', langCode); - String get currency => _sharedPreferences.getString('currency') ?? 'CHF'; + bool get hasStoredCurrency => _sharedPreferences.getString('currency') != null; + + // Unset default is EUR. Residence-based CHF/EUR comes from the account + // currency on GET /v2/user (applied by SettingsBloc, not persisted here). + String get currency => _sharedPreferences.getString('currency') ?? 'EUR'; set currency(String currencyCode) => _sharedPreferences.setString('currency', currencyCode); diff --git a/lib/packages/service/dfx/exceptions/api_exception.dart b/lib/packages/service/dfx/exceptions/api_exception.dart index 74f3e3db9..445ee7376 100644 --- a/lib/packages/service/dfx/exceptions/api_exception.dart +++ b/lib/packages/service/dfx/exceptions/api_exception.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:realunit_wallet/packages/service/dfx/exceptions/payment/buy_exceptions.dart'; class ApiException implements Exception { @@ -29,6 +31,26 @@ class ApiException implements Exception { } } + /// Builds an [ApiException] from an HTTP error body that may or may not be JSON. + /// + /// JSON objects go through [fromJson] (KYC/registration subclasses preserved). + /// Non-JSON or non-object bodies yield an empty [message] — never the raw body. + factory ApiException.fromBody(String body, {required int httpStatusCode}) { + try { + final decoded = jsonDecode(body); + if (decoded is Map) { + return ApiException.fromJson(decoded, httpStatusCode: httpStatusCode); + } + } on FormatException { + // Non-JSON body (plain text, HTML, empty). No API user-facing text. + } + return ApiException( + statusCode: httpStatusCode, + code: 'UNKNOWN', + message: '', + ); + } + @override String toString() => 'RealUnitApiException: $message (code: $code, statusCode: $statusCode)'; diff --git a/lib/packages/service/dfx/exceptions/payment/transfer_exceptions.dart b/lib/packages/service/dfx/exceptions/payment/transfer_exceptions.dart index 4a4b9aa76..371ec4bbf 100644 --- a/lib/packages/service/dfx/exceptions/payment/transfer_exceptions.dart +++ b/lib/packages/service/dfx/exceptions/payment/transfer_exceptions.dart @@ -2,8 +2,9 @@ // (enter/scan recipient → amount → confirm → sign → transfer → confirm). Each // one renders a human-readable string (enumerated in `exception_surface_test.dart`) // so it surfaces cleanly in logs and user-facing error states instead of the -// Dart default `Instance of '...'`. Typed failures drive control flow — no -// error-string parsing. +// Dart default `Instance of '...'`. Cubit and UI branch only on these types. +// String recognition of a viem receipt-timeout lives only at the API boundary +// (`txHashFromReceiptTimeout` in this file, used by the transfer service). import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart'; @@ -86,3 +87,29 @@ class TransferAlreadyConfirmedException extends ApiException { @override String toString() => 'TransferAlreadyConfirmedException: $message'; } + +/// Confirm already broadcast the transfer but the receipt wait timed out +/// (HTTP 500 whose message names the tx hash). Callers must treat this as +/// success — the chain has the tx; a retry must not prepare a second send. +class TransferReceiptTimeoutException extends ApiException { + /// Hash named in the timeout message; always 0x + 64 hex. + final String txHash; + + const TransferReceiptTimeoutException({ + super.statusCode, + required super.code, + required super.message, + required this.txHash, + }); + + @override + String toString() => 'TransferReceiptTimeoutException: $message'; +} + +/// Returns the tx hash from a viem/receipt-wait timeout message, or null. +String? txHashFromReceiptTimeout(String message) { + if (!message.toLowerCase().contains('timed out while waiting for transaction')) { + return null; + } + return RegExp(r'0x[a-fA-F0-9]{64}').firstMatch(message)?.group(0); +} diff --git a/lib/packages/service/dfx/models/payment/buy/buy_payment_info.dart b/lib/packages/service/dfx/models/payment/buy/buy_payment_info.dart index cbbeb3616..0472678a1 100644 --- a/lib/packages/service/dfx/models/payment/buy/buy_payment_info.dart +++ b/lib/packages/service/dfx/models/payment/buy/buy_payment_info.dart @@ -18,7 +18,7 @@ class BuyPaymentInfo extends Equatable { // authority on whether the quote is valid for trading and what the // current min/max limits are for the user+currency combination. final bool isValid; - // The whole-currency amount this quote charges, echoed by the API — the + // The amount this quote charges (Rappen-exact), echoed by the API — the // Details page must render this, never re-derive it from keystrokes. final double amount; final double? minVolume; diff --git a/lib/packages/service/dfx/models/payment/payment_info_error.dart b/lib/packages/service/dfx/models/payment/payment_info_error.dart index 42409bbe6..f790c2bda 100644 --- a/lib/packages/service/dfx/models/payment/payment_info_error.dart +++ b/lib/packages/service/dfx/models/payment/payment_info_error.dart @@ -4,6 +4,7 @@ enum PaymentInfoError { primaryEmailRequired, primaryEmailNotConfirmed, minAmountNotMet, + maxAmountExceeded, bitboxDisconnected, priceSourceUnavailable, unknown, diff --git a/lib/packages/service/dfx/models/user/dto/user_dto.dart b/lib/packages/service/dfx/models/user/dto/user_dto.dart index 337223487..1868a0662 100644 --- a/lib/packages/service/dfx/models/user/dto/user_dto.dart +++ b/lib/packages/service/dfx/models/user/dto/user_dto.dart @@ -1,23 +1,31 @@ import 'package:realunit_wallet/packages/service/dfx/models/kyc/kyc_level.dart'; +import 'package:realunit_wallet/styles/currency.dart'; class UserDto { final String? mail; final UserKycDto kyc; final UserCapabilitiesDto capabilities; + /// Account display currency from GET /v2/user. Null when the field is + /// absent or not a currency this app build knows. + final Currency? currency; + const UserDto({ this.mail, required this.kyc, this.capabilities = const UserCapabilitiesDto(), + this.currency, }); factory UserDto.fromJson(Map json) { + final currencyJson = json['currency'] as Map?; return UserDto( mail: json['mail'] as String?, kyc: UserKycDto.fromJson(json['kyc'] as Map), capabilities: json['capabilities'] != null ? UserCapabilitiesDto.fromJson(json['capabilities'] as Map) : const UserCapabilitiesDto(), + currency: Currency.tryFromCode(currencyJson?['name'] as String?), ); } } 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..1a94bd02c 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 @@ -15,7 +15,7 @@ class RealUnitBuyPaymentInfoService extends DFXAuthService { RealUnitBuyPaymentInfoService(super.appStore, super.walletService); - Future getPaymentInfo(int amount, {Currency currency = Currency.chf}) async { + Future getPaymentInfo(num amount, {Currency currency = Currency.chf}) async { final buyDto = RealUnitBuyDto(amount: amount, currency: currency); final uri = buildUri(host, _buyPaymentInfoPath); @@ -51,11 +51,9 @@ class RealUnitBuyPaymentInfoService extends DFXAuthService { error: responseDto.error, ); } else if (response.statusCode == 403) { - final errorJson = jsonDecode(response.body) as Map; - throw ApiException.fromJson(errorJson, httpStatusCode: response.statusCode); + throw ApiException.fromBody(response.body, httpStatusCode: response.statusCode); } else { - final errorJson = jsonDecode(response.body) as Map; - throw ApiException.fromJson(errorJson, httpStatusCode: response.statusCode); + throw ApiException.fromBody(response.body, httpStatusCode: response.statusCode); } } @@ -67,8 +65,7 @@ class RealUnitBuyPaymentInfoService extends DFXAuthService { ); if (response.statusCode != 200 && response.statusCode != 201) { - final errorJson = jsonDecode(response.body) as Map; - throw ApiException.fromJson(errorJson, httpStatusCode: response.statusCode); + throw ApiException.fromBody(response.body, httpStatusCode: response.statusCode); } final json = jsonDecode(response.body) as Map; diff --git a/lib/packages/service/dfx/real_unit_transfer_service.dart b/lib/packages/service/dfx/real_unit_transfer_service.dart index 7bf57f727..caa65aa7f 100644 --- a/lib/packages/service/dfx/real_unit_transfer_service.dart +++ b/lib/packages/service/dfx/real_unit_transfer_service.dart @@ -197,6 +197,15 @@ class RealUnitTransferService extends DFXAuthService { txHash: rawTxHash is String ? rawTxHash : null, ); } + final timeoutHash = txHashFromReceiptTimeout(error.message); + if (response.statusCode == 500 && timeoutHash != null) { + throw TransferReceiptTimeoutException( + statusCode: response.statusCode, + code: error.code, + message: error.message, + txHash: timeoutHash, + ); + } throw error; } diff --git a/lib/packages/utils/fiat_amount.dart b/lib/packages/utils/fiat_amount.dart index cf732a473..0e7babd0f 100644 --- a/lib/packages/utils/fiat_amount.dart +++ b/lib/packages/utils/fiat_amount.dart @@ -1,16 +1,48 @@ -/// Parses user-typed fiat text, accepting a comma as the decimal separator. -/// Rejects grouping-ambiguous input — a lone separator followed by exactly -/// three digits (`1,000` / `1.000`) usually means a thousands group, and -/// reading it as a decimal would quote 1/1000th of the intended amount. +/// Parses user-typed fiat text for CHF/EUR (at most two decimal places). +/// +/// Swiss apostrophes and spaces are thousands marks. A single `.` or `,` +/// followed by exactly three digits is a thousands group (`105.000` / +/// `90,000` → 105000 / 90000), not a 3-decimal fraction — quoting that as +/// 105.0 would silently buy a thousandth of the intended amount. One or two +/// digits after the separator stay a decimal (`300,75` → 300.75). double? tryParseFiatAmount(String input) { - if (RegExp(r'^\d+[.,]\d{3}$').hasMatch(input)) return null; - return double.tryParse(input.replaceAll(',', '.')); + final trimmed = input.trim().replaceAll(' ', ''); + if (trimmed.isEmpty) return null; + + // Swiss apostrophes are thousands marks only (`90'000`, `105'000.50`). + // Strip them only after the grouping shape is valid — otherwise `0'105` + // becomes `0105` → 105 and `1'23` becomes 123. + if (trimmed.contains("'")) { + if (!RegExp(r"^[1-9]\d{0,2}('\d{3})+([.,]\d{1,2})?$").hasMatch(trimmed)) { + return null; + } + return tryParseFiatAmount(trimmed.replaceAll("'", '')); + } + + // 105.000 / 1,000,000 — same separator throughout. First group has no + // leading zero (`0.105` is three fractional digits, not one hundred five). + if (RegExp(r'^[1-9]\d{0,2}([.,])(\d{3}(?:\1\d{3})*)$').hasMatch(trimmed)) { + return double.tryParse(trimmed.replaceAll(RegExp('[.,]'), '')); + } + + // 300,75 / 300.75 / 0,5 — decimal with at most Rappen/cent precision. + // Optional leading minus keeps the sell-cubit contract (`-100` is still + // parsed; the UI digitsOnly formatter already prevents typing it). + if (RegExp(r'^-?\d+[.,]\d{1,2}$').hasMatch(trimmed)) { + return double.tryParse(trimmed.replaceAll(',', '.')); + } + + if (RegExp(r'^-?\d+$').hasMatch(trimmed)) { + return double.tryParse(trimmed); + } + + return null; } -/// The whole-currency integer the backend charges for the raw [input] the user -/// typed (e.g. `300,75` → `301`); empty input counts as zero. -int chargedFiatAmount(String input) { +/// Rappen-snapped major units the backend is asked to quote (e.g. `300,75` → +/// `300.75`). Never rounds to whole currency. Empty input counts as zero. +double chargedFiatAmount(String input) { final amount = tryParseFiatAmount(input.isEmpty ? '0' : input); if (amount == null) throw FormatException('Invalid fiat amount', input); - return amount.round(); + return (amount * 100).round() / 100; } diff --git a/lib/screens/buy/buy_page.dart b/lib/screens/buy/buy_page.dart index 2d827bf91..9efa7e6d5 100644 --- a/lib/screens/buy/buy_page.dart +++ b/lib/screens/buy/buy_page.dart @@ -8,6 +8,7 @@ import 'package:realunit_wallet/screens/buy/cubits/buy_payment_info/buy_payment_ import 'package:realunit_wallet/screens/buy/widgets/payment_action_button.dart'; import 'package:realunit_wallet/screens/buy/widgets/payment_converter.dart'; import 'package:realunit_wallet/screens/buy/widgets/payment_information.dart'; +import 'package:realunit_wallet/screens/settings/bloc/settings_bloc.dart'; import 'package:realunit_wallet/setup/di.dart'; import 'package:realunit_wallet/widgets/scrollable_actions_layout.dart'; @@ -16,11 +17,13 @@ class BuyPage extends StatelessWidget { @override Widget build(BuildContext context) { + final currency = context.read().state.currency; return MultiBlocProvider( providers: [ BlocProvider( create: (_) => BuyConverterCubit( getIt(), + currency: currency, )..onFiatChanged('300'), ), BlocProvider( @@ -29,7 +32,15 @@ class BuyPage extends StatelessWidget { ), ), ], - child: const BuyView(), + child: BlocListener( + listenWhen: (previous, current) => previous.currency != current.currency, + listener: (context, settingsState) { + final cubit = context.read(); + if (cubit.state.currency == settingsState.currency) return; + cubit.onCurrencyChanged(settingsState.currency); + }, + child: const BuyView(), + ), ); } } @@ -54,12 +65,21 @@ class _BuyViewState extends State { ), ), body: BlocConsumer( - listenWhen: (prev, next) => prev.loading && !next.loading, + listenWhen: (prev, next) => + prev.currency != next.currency || (prev.loading && !next.loading), listener: (context, state) { + final payment = context.read(); + // Drop the previous quote before a new fetch so Confirm cannot + // bind an old Success while getPaymentInfo is in flight (it does + // not emit Loading over Success). + payment.clear(); + if (state.loading) return; _syncController(_amountController, state.fiatText); _syncController(_resultController, state.sharesText); - context.read().getPaymentInfo( - amount: _amountController.text, + // The quote charges the Rappen-exact payable of the conversion, + // not the field text: the field keeps what the user typed. + payment.getPaymentInfo( + amount: state.quoteAmountText, currency: state.currency, ); }, @@ -84,9 +104,7 @@ class _BuyViewState extends State { ], ), actions: [ - PaymentActionButton( - amountController: _amountController, - ), + const PaymentActionButton(), ], ), ), diff --git a/lib/screens/buy/cubits/buy_converter/buy_converter_cubit.dart b/lib/screens/buy/cubits/buy_converter/buy_converter_cubit.dart index 8fa7ac9a8..2e2e38ce4 100644 --- a/lib/screens/buy/cubits/buy_converter/buy_converter_cubit.dart +++ b/lib/screens/buy/cubits/buy_converter/buy_converter_cubit.dart @@ -11,7 +11,8 @@ part 'buy_converter_state.dart'; class BuyConverterCubit extends Cubit { final DfxBrokerbotService _brokerbotService; - BuyConverterCubit(this._brokerbotService) : super(const BuyConverterState()); + BuyConverterCubit(this._brokerbotService, {Currency currency = Currency.eur}) + : super(BuyConverterState(currency: currency)); Timer? _fiatDebounce; Timer? _sharesDebounce; @@ -26,9 +27,12 @@ class BuyConverterCubit extends Cubit { // newer result (e.g. `460 → shares=321` overwriting `4600 → shares=3216`). int _seq = 0; - /// User changed fiat → convert to shares + /// User changed fiat → convert to shares. The conversion result lands in + /// [BuyConverterState.sharesText] and [BuyConverterState.payableText] only: + /// writing it back into [BuyConverterState.fiatText] would overwrite the + /// text field mid-edit and make typing/deleting impossible. Future onFiatChanged(String value) async { - emit(state.copyWith(fiatText: value)); + emit(state.copyWith(fiatText: value, payableText: '')); _fiatDebounce?.cancel(); final mySeq = ++_seq; @@ -39,9 +43,12 @@ class BuyConverterCubit extends Cubit { try { final result = await _brokerbotService.getBuyShares(value, state.currency); if (isClosed || mySeq != _seq) return; + final priceMinor = (result.pricePerShare * 100).round(); + final payable = result.shares * priceMinor / 100; emit( state.copyWith( sharesText: result.shares.toString(), + payableText: payable.toStringAsFixed(2), loading: false, ), ); @@ -53,9 +60,11 @@ class BuyConverterCubit extends Cubit { }); } - /// User changed shares → convert to fiat + /// User changed shares → convert to fiat. Writing the result into + /// [BuyConverterState.fiatText] is safe here: the user is typing in the + /// shares field, so the fiat field is pure conversion output. Future onSharesChanged(String value) async { - emit(state.copyWith(sharesText: value)); + emit(state.copyWith(sharesText: value, payableText: '')); _sharesDebounce?.cancel(); final mySeq = ++_seq; @@ -69,6 +78,7 @@ class BuyConverterCubit extends Cubit { emit( state.copyWith( fiatText: result.totalCost.toStringAsFixed(_fractionDigits(value)), + payableText: result.totalCost.toStringAsFixed(2), loading: false, ), ); @@ -85,14 +95,17 @@ class BuyConverterCubit extends Cubit { // Flip currency immediately so the picker reflects the user choice // even if an in-flight conversion arrives later and gets dropped by // the seq guard. - emit(state.copyWith(loading: true, currency: currency)); + emit(state.copyWith(loading: true, currency: currency, payableText: '')); try { final result = await _brokerbotService.getBuyShares(state.fiatText, currency); if (isClosed || mySeq != _seq) return; + final priceMinor = (result.pricePerShare * 100).round(); + final payable = result.shares * priceMinor / 100; emit( state.copyWith( sharesText: result.shares.toString(), + payableText: payable.toStringAsFixed(2), loading: false, ), ); diff --git a/lib/screens/buy/cubits/buy_converter/buy_converter_state.dart b/lib/screens/buy/cubits/buy_converter/buy_converter_state.dart index 7e2cfe402..5f4e087e7 100644 --- a/lib/screens/buy/cubits/buy_converter/buy_converter_state.dart +++ b/lib/screens/buy/cubits/buy_converter/buy_converter_state.dart @@ -3,30 +3,41 @@ part of 'buy_converter_cubit.dart'; class BuyConverterState extends Equatable { final String fiatText; final String sharesText; + // Rappen-exact amount the live conversion charges (shares × list price in + // Rappen). Empty while no conversion result is live. The fiat input field + // must never render this — it always keeps the user's own [fiatText]. + final String payableText; final Currency currency; final bool loading; const BuyConverterState({ this.fiatText = '', this.sharesText = '', + this.payableText = '', this.currency = Currency.chf, this.loading = false, }); + /// Amount a quote must be requested with: the Rappen-exact payable when a + /// conversion result is live, otherwise the raw typed amount. + String get quoteAmountText => payableText.isNotEmpty ? payableText : fiatText; + BuyConverterState copyWith({ String? fiatText, String? sharesText, + String? payableText, Currency? currency, bool? loading, }) { return BuyConverterState( fiatText: fiatText ?? this.fiatText, sharesText: sharesText ?? this.sharesText, + payableText: payableText ?? this.payableText, currency: currency ?? this.currency, loading: loading ?? this.loading, ); } @override - List get props => [fiatText, sharesText, currency, loading]; + List get props => [fiatText, sharesText, payableText, currency, loading]; } diff --git a/lib/screens/buy/cubits/buy_payment_info/buy_payment_info_cubit.dart b/lib/screens/buy/cubits/buy_payment_info/buy_payment_info_cubit.dart index e97acfa99..88a240d33 100644 --- a/lib/screens/buy/cubits/buy_payment_info/buy_payment_info_cubit.dart +++ b/lib/screens/buy/cubits/buy_payment_info/buy_payment_info_cubit.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:developer' as developer; import 'package:async/async.dart'; @@ -14,12 +15,13 @@ import 'package:realunit_wallet/styles/currency.dart'; part 'buy_payment_info_state.dart'; -// Backend QuoteError code for the "amount below the per-currency minimum" -// case. The API returns this in the success body's `error` field together -// with the authoritative `minVolume`; the app surfaces it as a typed state -// for the UI to render. Other QuoteError values (KYC, limit, …) are -// already routed via dedicated ApiExceptions and dedicated failure states. +// Backend QuoteError codes that arrive on the quote success body as +// `isValid: false` plus the authoritative volume. AmountTooLow carries +// `minVolume`; AmountTooHigh / LimitExceeded carry `maxVolume`. KYC and +// registration still arrive as dedicated ApiExceptions. const String _quoteErrorAmountTooLow = 'AmountTooLow'; +const String _quoteErrorAmountTooHigh = 'AmountTooHigh'; +const String _quoteErrorLimitExceeded = 'LimitExceeded'; // Backend QuoteError code for the "buyer has no primary email on record" // case. The API pre-tells this on the quote (`isValid: false`) so the app @@ -36,25 +38,40 @@ const String _quoteErrorPrimaryEmailNotConfirmed = 'PrimaryEmailNotConfirmed'; class BuyPaymentInfoCubit extends Cubit { final RealUnitBuyPaymentInfoService _buyPaymentInfoService; CancelableOperation? _completer; + int _seq = 0; BuyPaymentInfoCubit( RealUnitBuyPaymentInfoService buyPaymentInfoService, ) : _buyPaymentInfoService = buyPaymentInfoService, super(const BuyPaymentInfoInitial()); + void clear() { + _seq++; + unawaited(_completer?.cancel() ?? Future.value()); + _completer = null; + if (state is BuyPaymentInfoInitial) return; + emit(const BuyPaymentInfoInitial()); + } + Future getPaymentInfo({String amount = '300', Currency currency = Currency.chf}) async { + final mySeq = ++_seq; await _completer?.cancel(); + if (isClosed || mySeq != _seq) return; if (state is! BuyPaymentInfoSuccess) { emit(const BuyPaymentInfoLoading()); } - _completer = CancelableOperation.fromFuture( + final operation = CancelableOperation.fromFuture( _runGetPaymentInfo(amount, currency), ); + _completer = operation; - final newState = await _completer!.value; - if (isClosed) return; + // `.value` never completes after cancel(); `.valueOrCancellation()` + // completes with null so a later getPaymentInfo is not stuck on the + // cancelled Future. + final newState = await operation.valueOrCancellation(); + if (isClosed || mySeq != _seq || newState == null) return; emit(newState); } @@ -75,6 +92,14 @@ class BuyPaymentInfoCubit extends Cubit { minAmount: paymentInfo.minVolume!, ); } + if ((paymentInfo.error == _quoteErrorAmountTooHigh || + paymentInfo.error == _quoteErrorLimitExceeded) && + paymentInfo.maxVolume != null) { + return BuyPaymentInfoMaxAmountExceededFailure( + PaymentInfoError.maxAmountExceeded, + maxAmount: paymentInfo.maxVolume!, + ); + } if (paymentInfo.error == _quoteErrorPrimaryEmailRequired) { return const BuyPaymentInfoFailure(PaymentInfoError.primaryEmailRequired); } @@ -105,7 +130,8 @@ class BuyPaymentInfoCubit extends Cubit { // (Aktionariat) is down, so no quote can be built — surface that // explicitly instead of a generic failure. Must stay below the // KYC/Registration clauses (those are ApiException subclasses). - if (e.statusCode == 503 || e.code == 'PRICE_SOURCE_UNAVAILABLE') { + // Gateway plain-text 502 is the same "no quote" situation as 503. + if (e.statusCode == 503 || e.statusCode == 502 || e.code == 'PRICE_SOURCE_UNAVAILABLE') { return BuyPaymentInfoFailure( PaymentInfoError.priceSourceUnavailable, message: e.message, @@ -115,7 +141,7 @@ class BuyPaymentInfoCubit extends Cubit { return BuyPaymentInfoFailure(PaymentInfoError.unknown, message: e.message); } catch (e) { developer.log(e.toString()); - return BuyPaymentInfoFailure(PaymentInfoError.unknown, message: e.toString()); + return const BuyPaymentInfoFailure(PaymentInfoError.unknown, message: ''); } } diff --git a/lib/screens/buy/cubits/buy_payment_info/buy_payment_info_state.dart b/lib/screens/buy/cubits/buy_payment_info/buy_payment_info_state.dart index 06fd49a6e..75d1f8305 100644 --- a/lib/screens/buy/cubits/buy_payment_info/buy_payment_info_state.dart +++ b/lib/screens/buy/cubits/buy_payment_info/buy_payment_info_state.dart @@ -31,7 +31,7 @@ class BuyPaymentInfoFailure extends BuyPaymentInfoState { /// User-facing API error text when this failure came from an API body. /// Empty for local hardware gates (BitBox) and for quote-code routing - /// that has its own structured UI (KYC / registration / min amount). + /// that has its own structured UI (KYC / registration / min / max amount). final String message; const BuyPaymentInfoFailure( @@ -53,3 +53,12 @@ class BuyPaymentInfoMinAmountNotMetFailure extends BuyPaymentInfoFailure { @override List get props => [error, minAmount]; } + +class BuyPaymentInfoMaxAmountExceededFailure extends BuyPaymentInfoFailure { + final double maxAmount; + + const BuyPaymentInfoMaxAmountExceededFailure(super.error, {required this.maxAmount}); + + @override + List get props => [error, maxAmount]; +} diff --git a/lib/screens/buy/widgets/buy_confirm_button.dart b/lib/screens/buy/widgets/buy_confirm_button.dart index e0cf2f25f..d37017e5f 100644 --- a/lib/screens/buy/widgets/buy_confirm_button.dart +++ b/lib/screens/buy/widgets/buy_confirm_button.dart @@ -54,7 +54,7 @@ class BuyConfirmButtonView extends StatelessWidget { extra: BuyPaymentDetailsParams( buyPaymentInfo: buyPaymentInfo, // The charged amount comes from the quote itself, never keystrokes. - amount: '${buyPaymentInfo.amount.round()}', + amount: buyPaymentInfo.amount.toStringAsFixed(2), // Backward compatible: prefer the API-designated purpose once it // ships; until then `reference` (always returned) is the value. purposeOfPayment: state.remittanceInfo ?? state.reference, diff --git a/lib/screens/buy/widgets/payment_action_button.dart b/lib/screens/buy/widgets/payment_action_button.dart index 972ed141b..98be8c084 100644 --- a/lib/screens/buy/widgets/payment_action_button.dart +++ b/lib/screens/buy/widgets/payment_action_button.dart @@ -15,163 +15,180 @@ import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart'; /// Bottom call-to-action of the buy page. It mirrors the `BuyPaymentInfoCubit` /// state: on a valid quote it shows the binding-buy CTA, otherwise it offers /// the action the current gate requires (register, KYC, reconnect, retry) or a -/// disabled button for the min-amount validation error. +/// disabled button for the min-amount or max-amount validation error. class PaymentActionButton extends StatelessWidget { - final TextEditingController amountController; + const PaymentActionButton({super.key}); - const PaymentActionButton({ - super.key, - required this.amountController, - }); + // Re-fetches the quote with the converter's Rappen-exact payable (falling + // back to the typed amount) — never with the raw text-field content. + void _refetchQuote(BuildContext context) { + final converterState = context.read().state; + context.read().getPaymentInfo( + amount: converterState.quoteAmountText, + currency: converterState.currency, + ); + } @override Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, paymentState) { - if (paymentState is BuyPaymentInfoSuccess) { - return BuyConfirmButton( - buyPaymentInfo: paymentState.buyPaymentInfo, - ); - } - if (paymentState is BuyPaymentInfoFailure) { - if (paymentState.error == PaymentInfoError.minAmountNotMet) { - final state = paymentState as BuyPaymentInfoMinAmountNotMetFailure; - return Padding( - padding: const EdgeInsets.symmetric(vertical: 20.0), - child: Column( - spacing: 8.0, - children: [ - Text( - S - .of(context) - .buyMinAmount( - '${state.minAmount.ceil()}', - context.read().state.currency.code, + return BlocBuilder( + buildWhen: (previous, current) => previous.currency != current.currency, + builder: (context, converterState) { + return BlocBuilder( + builder: (context, paymentState) { + if (paymentState is BuyPaymentInfoSuccess) { + if (paymentState.buyPaymentInfo.currency != converterState.currency) { + return const SizedBox.shrink(); + } + return BuyConfirmButton( + buyPaymentInfo: paymentState.buyPaymentInfo, + ); + } + if (paymentState is BuyPaymentInfoFailure) { + if (paymentState.error == PaymentInfoError.minAmountNotMet) { + final state = paymentState as BuyPaymentInfoMinAmountNotMetFailure; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 20.0), + child: Column( + spacing: 8.0, + children: [ + Text( + S + .of(context) + .buyMinAmount( + '${state.minAmount.ceil()}', + context.read().state.currency.code, + ), + style: const TextStyle( + color: RealUnitColors.neutral500, + fontSize: 14, + ), + ), + AppFilledButton( + onPressed: null, + label: S.of(context).next, + ), + ], + ), + ); + } + if (paymentState.error == PaymentInfoError.maxAmountExceeded) { + final state = paymentState as BuyPaymentInfoMaxAmountExceededFailure; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 20.0), + child: Column( + spacing: 8.0, + children: [ + Text( + S + .of(context) + .buyMaxAmount( + '${state.maxAmount.floor()}', + context.read().state.currency.code, + ), + style: const TextStyle( + color: RealUnitColors.neutral500, + fontSize: 14, ), - style: const TextStyle( - color: RealUnitColors.neutral500, - fontSize: 14, - ), + ), + AppFilledButton( + onPressed: null, + label: S.of(context).next, + ), + ], + ), + ); + } + if (paymentState.error == PaymentInfoError.registrationRequired) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 20), + child: AppFilledButton( + onPressed: () async { + await context.pushNamed(AppRoutes.kyc, extra: paymentState.context); + if (context.mounted) _refetchQuote(context); + }, + label: S.of(context).next, ), - AppFilledButton( - onPressed: null, + ); + } + if (paymentState.error == PaymentInfoError.kycRequired) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 20), + child: AppFilledButton( + onPressed: () async { + await context.pushNamed(AppRoutes.kyc, extra: paymentState.context); + if (context.mounted) _refetchQuote(context); + }, label: S.of(context).next, ), - ], - ), - ); - } - if (paymentState.error == PaymentInfoError.registrationRequired) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 20), - child: AppFilledButton( - onPressed: () async { - await context.pushNamed(AppRoutes.kyc, extra: paymentState.context); - if (context.mounted) { - context.read().getPaymentInfo( - amount: amountController.text, - currency: context.read().state.currency, - ); - } - }, - label: S.of(context).next, - ), - ); - } - if (paymentState.error == PaymentInfoError.kycRequired) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 20), - child: AppFilledButton( - onPressed: () async { - await context.pushNamed(AppRoutes.kyc, extra: paymentState.context); - if (context.mounted) { - context.read().getPaymentInfo( - amount: amountController.text, - currency: context.read().state.currency, - ); - } - }, - label: S.of(context).next, - ), - ); - } - if (paymentState.error == PaymentInfoError.primaryEmailRequired) { - // Pre-tap gate: the API flagged the quote invalid because the - // account has no primary email. Route to email capture first, - // then re-fetch the quote so a now-valid quote surfaces the - // binding-buy CTA — mirrors the registration / KYC gates above. - return Padding( - padding: const EdgeInsets.symmetric(vertical: 20), - child: AppFilledButton( - onPressed: () async { - await context.pushNamed( - SupportRoutes.emailCapture, - extra: S.of(context).buyPrimaryEmailRequiredCaptureDescription, - ); - if (context.mounted) { - context.read().getPaymentInfo( - amount: amountController.text, - currency: context.read().state.currency, - ); - } - }, - label: S.of(context).next, - ), - ); - } - if (paymentState.error == PaymentInfoError.primaryEmailNotConfirmed) { - // The buyer has a primary email on record but has not yet confirmed it. - // The KYC page auto-routes to its confirm-email step in this case, so - // route there instead of to email capture, then re-fetch - // the quote so a now-confirmed email surfaces the binding-buy CTA. - return Padding( - padding: const EdgeInsets.symmetric(vertical: 20), - child: AppFilledButton( - onPressed: () async { - await context.pushNamed(AppRoutes.kyc, extra: paymentState.context); - if (context.mounted) { - context.read().getPaymentInfo( - amount: amountController.text, - currency: context.read().state.currency, - ); - } - }, - label: S.of(context).next, - ), - ); - } - if (paymentState.error == PaymentInfoError.bitboxDisconnected) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 20), - child: AppFilledButton( - onPressed: () async { - final paymentInfoCubit = context.read(); - final converterCubit = context.read(); - await showBitboxReconnectSheet(context); - paymentInfoCubit.getPaymentInfo( - amount: amountController.text, - currency: converterCubit.state.currency, - ); - }, - label: S.of(context).bitboxReconnect, - ), - ); - } - if (paymentState.error == PaymentInfoError.unknown || - paymentState.error == PaymentInfoError.priceSourceUnavailable) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 20), - child: AppFilledButton( - onPressed: () => context.read().getPaymentInfo( - amount: amountController.text, - currency: context.read().state.currency, - ), - label: S.of(context).retry, - ), - ); - } - } - return const SizedBox.shrink(); + ); + } + if (paymentState.error == PaymentInfoError.primaryEmailRequired) { + // Pre-tap gate: the API flagged the quote invalid because the + // account has no primary email. Route to email capture first, + // then re-fetch the quote so a now-valid quote surfaces the + // binding-buy CTA — mirrors the registration / KYC gates above. + return Padding( + padding: const EdgeInsets.symmetric(vertical: 20), + child: AppFilledButton( + onPressed: () async { + await context.pushNamed( + SupportRoutes.emailCapture, + extra: S.of(context).buyPrimaryEmailRequiredCaptureDescription, + ); + if (context.mounted) _refetchQuote(context); + }, + label: S.of(context).next, + ), + ); + } + if (paymentState.error == PaymentInfoError.primaryEmailNotConfirmed) { + // The buyer has a primary email on record but has not yet confirmed it. + // The KYC page auto-routes to its confirm-email step in this case, so + // route there instead of to email capture, then re-fetch + // the quote so a now-confirmed email surfaces the binding-buy CTA. + return Padding( + padding: const EdgeInsets.symmetric(vertical: 20), + child: AppFilledButton( + onPressed: () async { + await context.pushNamed(AppRoutes.kyc, extra: paymentState.context); + if (context.mounted) _refetchQuote(context); + }, + label: S.of(context).next, + ), + ); + } + if (paymentState.error == PaymentInfoError.bitboxDisconnected) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 20), + child: AppFilledButton( + onPressed: () async { + final paymentInfoCubit = context.read(); + final converterCubit = context.read(); + await showBitboxReconnectSheet(context); + paymentInfoCubit.getPaymentInfo( + amount: converterCubit.state.quoteAmountText, + currency: converterCubit.state.currency, + ); + }, + label: S.of(context).bitboxReconnect, + ), + ); + } + if (paymentState.error == PaymentInfoError.unknown || + paymentState.error == PaymentInfoError.priceSourceUnavailable) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 20), + child: AppFilledButton( + onPressed: () => _refetchQuote(context), + label: S.of(context).retry, + ), + ); + } + } + return const SizedBox.shrink(); + }, + ); }, ); } diff --git a/lib/screens/buy/widgets/payment_converter.dart b/lib/screens/buy/widgets/payment_converter.dart index 4f9d0f9c3..3b761fa85 100644 --- a/lib/screens/buy/widgets/payment_converter.dart +++ b/lib/screens/buy/widgets/payment_converter.dart @@ -100,7 +100,7 @@ class _PaymentConverterState extends State { child: TextField( controller: _amountController, keyboardType: const .numberWithOptions(decimal: true), - inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'[0-9.,]'))], + inputFormatters: [FilteringTextInputFormatter.allow(RegExp("[0-9.,']"))], decoration: const InputDecoration( border: .none, contentPadding: .symmetric( @@ -204,6 +204,26 @@ class _PaymentConverterState extends State { ), ), ), + BlocBuilder( + builder: (context, state) { + if (!_showsExactCharge(state)) { + return const SizedBox.shrink(); + } + return Padding( + padding: const .symmetric( + horizontal: 12.0, + vertical: 4.0, + ), + child: Text( + '${S.of(context).buyChargedAmount}: ${state.payableText} ${state.currency.code}', + key: const Key('buy-charged-amount'), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: RealUnitColors.neutral600, + ), + ), + ); + }, + ), const SizedBox(height: 32), Padding( padding: const .symmetric( @@ -302,4 +322,18 @@ class _PaymentConverterState extends State { ], ); } + + // payableText is always two fractional digits; fiatText is raw keystrokes + // ('300' vs '300.00'). Compare numerically so an exact match stays hidden. + bool _showsExactCharge(BuyConverterState state) { + if (state.payableText.isEmpty || state.payableText == state.fiatText) { + return false; + } + final fiat = num.tryParse(state.fiatText.replaceAll(',', '.')); + final payable = num.tryParse(state.payableText.replaceAll(',', '.')); + if (fiat != null && payable != null) { + return fiat != payable; + } + return true; + } } diff --git a/lib/screens/dashboard/bloc/dashboard_bloc.dart b/lib/screens/dashboard/bloc/dashboard_bloc.dart index a08e1de37..6b06864ce 100644 --- a/lib/screens/dashboard/bloc/dashboard_bloc.dart +++ b/lib/screens/dashboard/bloc/dashboard_bloc.dart @@ -41,9 +41,12 @@ class DashboardBloc extends Bloc { final APriceService _priceService; final RealUnitAccountService _accountService; final Asset asset; + int _generation = 0; Future _onRefreshPriceEvent(RefreshPriceEvent event, Emitter emit) async { + final generation = _generation; final price = await _priceService.getPriceOfAsset(realUnitAsset, state.currency); + if (generation != _generation) return; emit(state.copyWith(price: price)); } @@ -51,7 +54,9 @@ class DashboardBloc extends Bloc { RefreshPriceChartEvent event, Emitter emit, ) async { + final generation = _generation; final priceChart = await _priceService.getPriceChart(realUnitAsset, state.currency); + if (generation != _generation) return; emit(state.copyWith(priceChart: priceChart)); } @@ -59,14 +64,22 @@ class DashboardBloc extends Bloc { RefreshPortfolioHistoryEvent event, Emitter emit, ) async { - final portfolioHistory = await _accountService.getPortfolioHistory( - state.currency, - ); + final generation = _generation; + final portfolioHistory = await _accountService.getPortfolioHistory(state.currency); + if (generation != _generation) return; emit(state.copyWith(portfolioHistory: portfolioHistory)); } void _onCurrencyChangedEvent(CurrencyChangedEvent event, Emitter emit) { - emit(state.copyWith(currency: event.currency)); + _generation++; + emit( + state.copyWith( + currency: event.currency, + price: BigInt.zero, + priceChart: const [], + portfolioHistory: const [], + ), + ); refresh(); } } diff --git a/lib/screens/dashboard/bloc/portfolio_chart/portfolio_chart_cubit.dart b/lib/screens/dashboard/bloc/portfolio_chart/portfolio_chart_cubit.dart index d64600b1b..00dd3ee1d 100644 --- a/lib/screens/dashboard/bloc/portfolio_chart/portfolio_chart_cubit.dart +++ b/lib/screens/dashboard/bloc/portfolio_chart/portfolio_chart_cubit.dart @@ -81,18 +81,31 @@ class PortfolioChartCubit extends Cubit { ); final maxX = _prices.last.time.millisecondsSinceEpoch.toDouble(); - // Filter price points to those within the selected time period (between minX and maxX) - final visibleSpots = _prices - .where( - (p) => p.time.millisecondsSinceEpoch >= minX && p.time.millisecondsSinceEpoch <= maxX, - ) - .map( - (p) => FlSpot( - p.time.millisecondsSinceEpoch.toDouble(), - double.parse(formatFixed(p.value, 2)), - ), - ) - .toList(); + // Split into the last sample before the window and the samples inside it. + // Chronological `_prices` is the production order from the account API. + PortfolioValuePoint? prior; + final interior = []; + for (final p in _prices) { + final x = p.time.millisecondsSinceEpoch.toDouble(); + if (x < minX) { + prior = p; + } else if (x <= maxX) { + interior.add(p); + } + } + + double yOf(PortfolioValuePoint p) => double.parse(formatFixed(p.value, 2)); + + // Hold the last pre-window value at minX so the stroke spans the selected + // period instead of starting at the first interior sample (a 1Y window + // whose next point is six months in would otherwise draw a stub). + final visibleSpots = [ + if (interior.isNotEmpty && + prior != null && + interior.first.time.millisecondsSinceEpoch.toDouble() > minX) + FlSpot(minX, yOf(prior)), + for (final p in interior) FlSpot(p.time.millisecondsSinceEpoch.toDouble(), yOf(p)), + ]; if (visibleSpots.isEmpty) { emit( @@ -128,8 +141,12 @@ class PortfolioChartCubit extends Cubit { (min - average).abs(), ); - // Add padding (20% extra space) and ensure minimum range for visual clarity - final paddedDeviation = math.max(maxDeviation * 1.2, average * 0.05); + // Add padding (20% extra space) and ensure minimum range for visual clarity. + // `average * 0.05` is 0 when the series is all zeros, which used to collapse + // minY/maxY and hide the LineChart; fall back to 5.0 currency units then. + final relativeFloor = average.abs() * 0.05; + final minFloor = relativeFloor > 0 ? relativeFloor : 5.0; + final paddedDeviation = math.max(maxDeviation * 1.2, minFloor); // Calculate rounded horizontal line values centered around average final horizontalLineValues = _calculateHorizontalLines(paddedDeviation, average); @@ -153,9 +170,8 @@ class PortfolioChartCubit extends Cubit { const lineCount = 6; const intervalCount = lineCount - 1; - if (deviation <= 0) { - return List.generate(lineCount, (_) => average); - } + // Callers always pass a positive deviation (relative 5% floor, or 5.0 + // for an all-zero series). A non-positive value would collapse minY/maxY. // Calculate an interval that spans the data range final rawInterval = (2 * deviation) / intervalCount; @@ -164,7 +180,8 @@ class PortfolioChartCubit extends Cubit { // Center the lines around the average by rounding it to the nearest interval final centerLine = (average / interval).round() * interval; - // Position the bottom line so the center is roughly in the middle + // Position the bottom line so the center is roughly in the middle. + // Holdings cannot go negative — never draw a Y-axis below 0. final bottomLine = math.max(0.0, centerLine - 3 * interval); return List.generate(lineCount, (i) => bottomLine + i * interval); diff --git a/lib/screens/dashboard/widgets/sections/dashboard_actions.dart b/lib/screens/dashboard/widgets/sections/dashboard_actions.dart index 0e5076a06..6ee3cf2cb 100644 --- a/lib/screens/dashboard/widgets/sections/dashboard_actions.dart +++ b/lib/screens/dashboard/widgets/sections/dashboard_actions.dart @@ -52,18 +52,6 @@ class DashboardActions extends StatelessWidget { onPressed: () => context.pushNamed(AppRoutes.pay), ), ), - if (insiderFeaturesUnlocked) - Expanded( - child: ActionButton( - icon: Icon( - Icons.send_rounded, - color: RealUnitColors.basic.white, - size: 20, - ), - label: S.of(context).send, - onPressed: () => context.pushNamed(AppRoutes.send), - ), - ), ], ); } diff --git a/lib/screens/kyc/cubits/kyc/kyc_cubit.dart b/lib/screens/kyc/cubits/kyc/kyc_cubit.dart index d4c34f658..ab1ac7a3a 100644 --- a/lib/screens/kyc/cubits/kyc/kyc_cubit.dart +++ b/lib/screens/kyc/cubits/kyc/kyc_cubit.dart @@ -15,6 +15,8 @@ import 'package:realunit_wallet/packages/service/dfx/models/wallet/real_unit_reg import 'package:realunit_wallet/packages/service/dfx/real_unit_legal_service.dart'; import 'package:realunit_wallet/packages/service/dfx/real_unit_registration_service.dart'; import 'package:realunit_wallet/packages/wallet/wallet.dart'; +import 'package:realunit_wallet/setup/account_currency_sync.dart'; +import 'package:realunit_wallet/setup/di.dart'; part 'kyc_state.dart'; @@ -49,6 +51,11 @@ class KycCubit extends Cubit { // current one. Acts as a cancellation token for non-cancellable work. int _runGeneration = 0; + /// Wallet that was open when the current `checkKyc()` started. A late + /// GET /v2/user must not apply after delete or after a different wallet + /// is loaded (`LoadWalletEvent` can go A→B without a null gap). + AWallet? _walletAtCheckStart; + KycCubit( DfxKycService kycService, RealUnitRegistrationService registrationService, @@ -62,6 +69,9 @@ class KycCubit extends Cubit { Future checkKyc({String? context}) async { _kycContext = context ?? _kycContext; + _walletAtCheckStart = getIt.isRegistered() + ? getIt().currentWallet + : null; final generation = ++_runGeneration; try { await _runCheckKyc(generation).timeout(_checkKycTimeout); @@ -74,6 +84,11 @@ class KycCubit extends Cubit { } } + void _applyAccountCurrency(UserDto user) { + if (!getIt.isRegistered()) return; + getIt().applyFromUser(user, captured: _walletAtCheckStart); + } + Future _runCheckKyc(int generation) async { try { if (isClosed || generation != _runGeneration) return; @@ -88,6 +103,7 @@ class KycCubit extends Cubit { final kycStatus = results.elementAt(0) as KycLevelDto; final user = results.elementAt(1) as UserDto; + _applyAccountCurrency(user); final level = kycStatus.kycLevel.value; if (user.mail == null) { diff --git a/lib/screens/pay/cubits/pay_scan/pay_scan_cubit.dart b/lib/screens/pay/cubits/pay_scan/pay_scan_cubit.dart index d40c3cf68..8b6ebf502 100644 --- a/lib/screens/pay/cubits/pay_scan/pay_scan_cubit.dart +++ b/lib/screens/pay/cubits/pay_scan/pay_scan_cubit.dart @@ -13,6 +13,12 @@ class PayScanCubit extends Cubit { /// Called once per detected barcode. Guards against re-entry after a /// successful decode so a continuously-detecting scanner does not re-emit. + /// + /// This guard is necessary and not sufficient: the page must not call + /// [reset] until [pushThenRearm] finishes (`Route.completed` or thrown push), + /// never in the same listener turn. Resetting in the same turn as the + /// push drops the [PayScanDecoded] hold and the next camera frame pushes + /// a second quote page. void onCodeDetected(String raw) { if (state is PayScanDecoded) return; try { diff --git a/lib/screens/pay/pay_info_page.dart b/lib/screens/pay/pay_info_page.dart new file mode 100644 index 000000000..8a8ab6a18 --- /dev/null +++ b/lib/screens/pay/pay_info_page.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/screens/pay/pay_scan_page.dart'; +import 'package:realunit_wallet/widgets/scrollable_actions_layout.dart'; + +class PayInfoPage extends StatelessWidget { + final String? initialPayload; + + const PayInfoPage({super.key, this.initialPayload}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(S.of(context).payInfoTitle)), + body: SafeArea( + child: Padding( + padding: const .symmetric(horizontal: 20, vertical: 16), + child: ScrollableActionsLayout( + centerBody: true, + body: Text( + S.of(context).payInfoBody, + textAlign: .center, + style: Theme.of(context).textTheme.bodyMedium, + ), + actions: [ + FilledButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => PayScanPage(initialPayload: initialPayload), + ), + ), + child: Text(S.of(context).next), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/pay/pay_process_page.dart b/lib/screens/pay/pay_process_page.dart index 277b8d756..fcd0338c5 100644 --- a/lib/screens/pay/pay_process_page.dart +++ b/lib/screens/pay/pay_process_page.dart @@ -10,6 +10,7 @@ import 'package:realunit_wallet/packages/service/wallet_service.dart'; import 'package:realunit_wallet/screens/pay/cubits/pay_process/pay_process_cubit.dart'; import 'package:realunit_wallet/setup/di.dart'; import 'package:realunit_wallet/styles/colors.dart'; +import 'package:realunit_wallet/widgets/route_animation_gate.dart'; class PayProcessPage extends StatelessWidget { final String paymentLinkId; @@ -32,8 +33,11 @@ class PayProcessPage extends StatelessWidget { appStore: getIt(), paymentLinkId: paymentLinkId, zchfNeeded: zchfNeeded, - )..start(), - child: const PayProcessView(), + ), + child: RouteAnimationGate( + onSettled: (c) => c.read().start(), + child: const PayProcessView(), + ), ); } } @@ -138,6 +142,11 @@ class PayProcessView extends StatelessWidget { required String title, required String description, }) async { + await waitForIncomingRouteAnimation(context); + if (!context.mounted) { + return; + } + await showModalBottomSheet( context: context, isDismissible: false, @@ -174,6 +183,11 @@ class PayProcessView extends StatelessWidget { /// swap is never redone, so the ZCHF already held is reused. Dismissing leaves /// that ZCHF safely in the wallet. Future _showRetrySheet(BuildContext context, PayProcessPayRetry state) async { + await waitForIncomingRouteAnimation(context); + if (!context.mounted) { + return; + } + final cubit = context.read(); // The sheet returns true when the user retries (keep the page) and false // when they close (leave the flow); a barrier dismissal yields null. diff --git a/lib/screens/pay/pay_quote_page.dart b/lib/screens/pay/pay_quote_page.dart index 5ae79073d..71ec878f6 100644 --- a/lib/screens/pay/pay_quote_page.dart +++ b/lib/screens/pay/pay_quote_page.dart @@ -8,6 +8,7 @@ import 'package:realunit_wallet/screens/pay/cubits/pay_quote/pay_quote_cubit.dar import 'package:realunit_wallet/screens/pay/pay_process_page.dart'; import 'package:realunit_wallet/setup/di.dart'; import 'package:realunit_wallet/styles/colors.dart'; +import 'package:realunit_wallet/widgets/route_animation_gate.dart'; import 'package:realunit_wallet/widgets/scrollable_actions_layout.dart'; class PayQuotePage extends StatelessWidget { @@ -18,8 +19,11 @@ class PayQuotePage extends StatelessWidget { @override Widget build(BuildContext context) { return BlocProvider( - create: (_) => PayQuoteCubit(getIt(), paymentLinkId)..load(), - child: const PayQuoteView(), + create: (_) => PayQuoteCubit(getIt(), paymentLinkId), + child: RouteAnimationGate( + onSettled: (c) => c.read().load(), + child: const PayQuoteView(), + ), ); } } diff --git a/lib/screens/pay/pay_scan_page.dart b/lib/screens/pay/pay_scan_page.dart index 7d982ac5a..ce79d7e0a 100644 --- a/lib/screens/pay/pay_scan_page.dart +++ b/lib/screens/pay/pay_scan_page.dart @@ -3,6 +3,8 @@ // camera. The decode logic it feeds is unit-tested in lnurl_decoder_test.dart // and the cubit behaviour in pay_scan_cubit_test.dart; the camera preview // itself is out of scope for widget tests. +import 'dart:async'; + import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -11,6 +13,7 @@ import 'package:realunit_wallet/generated/i18n.dart'; import 'package:realunit_wallet/screens/pay/cubits/pay_scan/pay_scan_cubit.dart'; import 'package:realunit_wallet/screens/pay/pay_quote_page.dart'; import 'package:realunit_wallet/styles/colors.dart'; +import 'package:realunit_wallet/widgets/scanner/push_then_rearm.dart'; import 'package:realunit_wallet/widgets/scanner/qr_scanner_view.dart'; class PayScanPage extends StatelessWidget { @@ -68,16 +71,19 @@ class _PayScanViewState extends State { @override Widget build(BuildContext context) { return BlocConsumer( + listenWhen: (previous, current) => + (current is PayScanDecoded && previous is! PayScanDecoded) || + (current is PayScanInvalid && previous is! PayScanInvalid), listener: (context, state) { if (state is PayScanDecoded) { setState(() => _awaitingInitialDecode = false); - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => PayQuotePage(paymentLinkId: state.link.id), + unawaited( + pushThenRearm( + context, + page: PayQuotePage(paymentLinkId: state.link.id), + rearm: () => context.read().reset(), ), ); - // Reset so returning to the scanner re-arms detection. - context.read().reset(); } if (state is PayScanInvalid) { setState(() => _awaitingInitialDecode = false); diff --git a/lib/screens/receive/receive_page.dart b/lib/screens/receive/receive_page.dart index 701193b30..2ce2afe72 100644 --- a/lib/screens/receive/receive_page.dart +++ b/lib/screens/receive/receive_page.dart @@ -1,10 +1,14 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; import 'package:realunit_wallet/packages/service/app_store.dart'; import 'package:realunit_wallet/packages/wallet/payment_uri.dart'; import 'package:realunit_wallet/screens/receive/widgets/qr_address_widget.dart'; import 'package:realunit_wallet/setup/di.dart'; +import 'package:realunit_wallet/setup/routing/routes/app_routes.dart'; +import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart'; import 'package:realunit_wallet/widgets/handlebars.dart'; +import 'package:realunit_wallet/widgets/scrollable_actions_layout.dart'; class ReceivePage extends StatelessWidget { const ReceivePage({super.key, this.isBottomSheet = true}); @@ -26,17 +30,28 @@ class ReceivePage extends StatelessWidget { ), ), ), - body: SingleChildScrollView( - child: Column( - children: [ - if (isBottomSheet) Handlebars.horizontal(context), - SizedBox( - width: double.infinity, - height: isBottomSheet ? 20 : 0, - ), - QRAddressWidget( - uri: EthereumURI(address: address, amount: '').toString(), - subtitle: address, + body: SafeArea( + child: ScrollableActionsLayout( + body: Column( + children: [ + if (isBottomSheet) Handlebars.horizontal(context), + SizedBox( + width: double.infinity, + height: isBottomSheet ? 20 : 0, + ), + QRAddressWidget( + uri: EthereumURI(address: address, amount: '').toString(), + subtitle: address, + ), + ], + ), + actions: [ + Padding( + padding: const .symmetric(horizontal: 20, vertical: 12), + child: AppFilledButton( + label: S.of(context).send, + onPressed: () => context.pushNamed(AppRoutes.send), + ), ), ], ), diff --git a/lib/screens/receive/widgets/qr_address_widget.dart b/lib/screens/receive/widgets/qr_address_widget.dart index 20b87894f..f2a546dac 100644 --- a/lib/screens/receive/widgets/qr_address_widget.dart +++ b/lib/screens/receive/widgets/qr_address_widget.dart @@ -35,29 +35,32 @@ class QRAddressWidget extends StatelessWidget { crossAxisAlignment: .center, spacing: 20.0, children: [ - Text.rich( - TextSpan( - children: [ - TextSpan( - text: _slice(subtitle, 0, 6), - style: const TextStyle(fontWeight: .bold), - ), - const TextSpan(text: ' '), - TextSpan( - text: _slice(subtitle, 6, 21), - ), - const TextSpan(text: '\n'), - TextSpan( - text: _slice(subtitle, 21, 36), - ), - const TextSpan(text: ' '), - TextSpan( - text: _slice(subtitle, 36), - style: const TextStyle(fontWeight: .bold), - ), - ], + Flexible( + child: Text.rich( + TextSpan( + children: [ + TextSpan( + text: _slice(subtitle, 0, 6), + style: const TextStyle(fontWeight: .bold), + ), + const TextSpan(text: ' '), + TextSpan( + text: _slice(subtitle, 6, 21), + ), + const TextSpan(text: '\n'), + TextSpan( + text: _slice(subtitle, 21, 36), + ), + const TextSpan(text: ' '), + TextSpan( + text: _slice(subtitle, 36), + style: const TextStyle(fontWeight: .bold), + ), + ], + ), + style: Theme.of(context).textTheme.bodyMedium, + textAlign: .center, ), - style: Theme.of(context).textTheme.bodyMedium, ), const Icon( Icons.copy_outlined, diff --git a/lib/screens/sell/cubits/sell_payment_info/sell_payment_info_cubit.dart b/lib/screens/sell/cubits/sell_payment_info/sell_payment_info_cubit.dart index 2c4ba5d82..f0cde3796 100644 --- a/lib/screens/sell/cubits/sell_payment_info/sell_payment_info_cubit.dart +++ b/lib/screens/sell/cubits/sell_payment_info/sell_payment_info_cubit.dart @@ -42,7 +42,7 @@ class SellPaymentInfoCubit extends Cubit { emit(const SellPaymentInfoLoading()); final paymentInfo = await _sellPaymentInfoService.getPaymentInfo( - chargedFiatAmount(amount), + chargedFiatAmount(amount).round(), iban, currency: currency, ); diff --git a/lib/screens/sell_bitbox/cubit/sell_bitbox_cubit.dart b/lib/screens/sell_bitbox/cubit/sell_bitbox_cubit.dart index 281e96bdc..3fe5a985c 100644 --- a/lib/screens/sell_bitbox/cubit/sell_bitbox_cubit.dart +++ b/lib/screens/sell_bitbox/cubit/sell_bitbox_cubit.dart @@ -25,6 +25,7 @@ class SellBitboxCubit extends Cubit { final AppStore _appStore; Timer? _ethPollingTimer; + bool _started = false; SellBitboxCubit({ required SellPaymentInfo paymentInfo, @@ -37,7 +38,15 @@ class SellBitboxCubit extends Cubit { _blockchainService = blockchainService, _sellService = sellService, _appStore = appStore, - super(SellBitboxCheckingEth()) { + super(SellBitboxCheckingEth()); + + /// Begins the ETH-balance check. Safe to call more than once; only the + /// first call schedules work. + void start() { + if (_started) { + return; + } + _started = true; scheduleMicrotask(_checkEthBalance); } diff --git a/lib/screens/sell_bitbox/sell_bitbox_page.dart b/lib/screens/sell_bitbox/sell_bitbox_page.dart index 8a3effdc9..cc639e196 100644 --- a/lib/screens/sell_bitbox/sell_bitbox_page.dart +++ b/lib/screens/sell_bitbox/sell_bitbox_page.dart @@ -15,6 +15,7 @@ import 'package:realunit_wallet/screens/sell_bitbox/widgets/sell_bitbox_swap_ste import 'package:realunit_wallet/setup/di.dart'; import 'package:realunit_wallet/styles/colors.dart'; import 'package:realunit_wallet/widgets/handlebars.dart'; +import 'package:realunit_wallet/widgets/route_animation_gate.dart'; class SellBitboxPage extends StatelessWidget { final SellPaymentInfo paymentInfo; @@ -31,7 +32,10 @@ class SellBitboxPage extends StatelessWidget { sellService: getIt(), appStore: getIt(), ), - child: SellBitboxView(paymentInfo: paymentInfo), + child: RouteAnimationGate( + onSettled: (c) => c.read().start(), + child: SellBitboxView(paymentInfo: paymentInfo), + ), ); } } @@ -46,6 +50,10 @@ class SellBitboxView extends StatelessWidget { return BlocConsumer( listener: (context, state) async { if (state is SellBitboxBitboxRequired) { + await waitForIncomingRouteAnimation(context); + if (!context.mounted) { + return; + } final reconnected = await showBitboxReconnectSheet(context); if (reconnected && context.mounted) { context.read().retryAfterConnection(); @@ -61,6 +69,10 @@ class SellBitboxView extends StatelessWidget { ); } if (state is SellBitboxSuccess) { + await waitForIncomingRouteAnimation(context); + if (!context.mounted) { + return; + } final isSuccess = await _showSuccessSheet(context); if (isSuccess == true && context.mounted) { context.pop(); diff --git a/lib/screens/send/cubits/send_process/send_process_cubit.dart b/lib/screens/send/cubits/send_process/send_process_cubit.dart index 4ac9ad76c..13d27338d 100644 --- a/lib/screens/send/cubits/send_process/send_process_cubit.dart +++ b/lib/screens/send/cubits/send_process/send_process_cubit.dart @@ -178,6 +178,8 @@ class SendProcessCubit extends Cubit { } else { nextState = const SendProcessSuccess(''); } + } on TransferReceiptTimeoutException catch (e) { + nextState = SendProcessSuccess(e.txHash); } on TransferConfirmMismatchException { nextState = const SendProcessFailure(SendProcessFailureReason.confirmMismatch); } on TransferSignatureUnsupportedException { diff --git a/lib/screens/send/cubits/send_recipient/send_recipient_cubit.dart b/lib/screens/send/cubits/send_recipient/send_recipient_cubit.dart index d98e3556b..1697ad502 100644 --- a/lib/screens/send/cubits/send_recipient/send_recipient_cubit.dart +++ b/lib/screens/send/cubits/send_recipient/send_recipient_cubit.dart @@ -20,6 +20,12 @@ class SendRecipientCubit extends Cubit { /// Called once per detected barcode while the scanner is open. Guards against /// re-entry after a successful decode so a continuously-detecting scanner does /// not re-emit. + /// + /// This guard is necessary and not sufficient: the page must not call + /// [reset] until [pushThenRearm] finishes (`Route.completed` or thrown push), + /// never in the same listener turn. Resetting in the same turn as the + /// push drops the [SendRecipientValid] hold and the next camera frame + /// pushes a second amount page. void onCodeDetected(String raw) { if (state is SendRecipientValid) { return; diff --git a/lib/screens/send/send_info_page.dart b/lib/screens/send/send_info_page.dart new file mode 100644 index 000000000..2df399c32 --- /dev/null +++ b/lib/screens/send/send_info_page.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/screens/send/send_recipient_page.dart'; +import 'package:realunit_wallet/widgets/scrollable_actions_layout.dart'; + +class SendInfoPage extends StatelessWidget { + const SendInfoPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(S.of(context).sendInfoTitle)), + body: SafeArea( + child: Padding( + padding: const .symmetric(horizontal: 20, vertical: 16), + child: ScrollableActionsLayout( + centerBody: true, + body: Text( + S.of(context).sendInfoBody, + textAlign: .center, + style: Theme.of(context).textTheme.bodyMedium, + ), + actions: [ + FilledButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const SendRecipientPage(), + ), + ), + child: Text(S.of(context).next), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/send/send_process_page.dart b/lib/screens/send/send_process_page.dart index 260be1053..22f03b484 100644 --- a/lib/screens/send/send_process_page.dart +++ b/lib/screens/send/send_process_page.dart @@ -7,11 +7,13 @@ import 'package:realunit_wallet/packages/service/dfx/real_unit_transfer_service. import 'package:realunit_wallet/screens/send/cubits/send_process/send_process_cubit.dart'; import 'package:realunit_wallet/setup/di.dart'; import 'package:realunit_wallet/styles/colors.dart'; +import 'package:realunit_wallet/widgets/route_animation_gate.dart'; import 'package:realunit_wallet/widgets/scrollable_actions_layout.dart'; /// Final step: prepare → sign (EIP-712 delegation + EIP-7702 authorization) → /// confirm, then render the txHash success or a typed failure. The cubit drives -/// every outcome as a state — no error-string parsing in the view. +/// every outcome as a state; the failure sheet never surfaces raw viem/exception +/// text (generic always uses localized copy). class SendProcessPage extends StatelessWidget { final String recipient; final int amount; @@ -26,8 +28,11 @@ class SendProcessPage extends StatelessWidget { appStore: getIt(), recipient: recipient, amount: amount, - )..start(), - child: const SendProcessView(), + ), + child: RouteAnimationGate( + onSettled: (c) => c.read().start(), + child: const SendProcessView(), + ), ); } } @@ -94,11 +99,7 @@ class SendProcessView extends StatelessWidget { }; String _failureMessage(BuildContext context, SendProcessFailure state) { - final apiText = state.message; - if (apiText != null && apiText.isNotEmpty) { - return apiText; - } - return switch (state.reason) { + final localized = switch (state.reason) { SendProcessFailureReason.signatureUnsupported => S.of(context).sendFailureSignatureUnsupported, SendProcessFailureReason.signatureCancelled => S.of(context).sendFailureSignatureCancelled, SendProcessFailureReason.gasFundingUnavailable => S.of(context).sendFailureGasUnavailable, @@ -108,6 +109,18 @@ class SendProcessView extends StatelessWidget { SendProcessFailureReason.confirmMismatch => S.of(context).sendFailureConfirmMismatch, SendProcessFailureReason.generic => S.of(context).sendFailureGeneric, }; + + // Generic failures always use localized copy — never raw API/exception text + // (e.g. viem receipt-timeout strings stored as e.toString()). + if (state.reason == SendProcessFailureReason.generic) { + return localized; + } + + final apiText = state.message; + if (apiText != null && apiText.isNotEmpty) { + return apiText; + } + return localized; } /// Shows the terminal result sheet. Returns after the sheet is dismissed. @@ -125,6 +138,11 @@ class SendProcessView extends StatelessWidget { required String description, bool canRetry = false, }) async { + await waitForIncomingRouteAnimation(context); + if (!context.mounted) { + return; + } + final shouldPopPage = await showModalBottomSheet( context: context, isDismissible: false, diff --git a/lib/screens/send/send_recipient_page.dart b/lib/screens/send/send_recipient_page.dart index 57a00aab6..4d415ac8b 100644 --- a/lib/screens/send/send_recipient_page.dart +++ b/lib/screens/send/send_recipient_page.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -5,6 +7,7 @@ import 'package:realunit_wallet/generated/i18n.dart'; import 'package:realunit_wallet/screens/send/cubits/send_recipient/send_recipient_cubit.dart'; import 'package:realunit_wallet/screens/send/send_amount_page.dart'; import 'package:realunit_wallet/styles/colors.dart'; +import 'package:realunit_wallet/widgets/scanner/push_then_rearm.dart'; import 'package:realunit_wallet/widgets/scanner/qr_scanner_view.dart'; import 'package:realunit_wallet/widgets/scrollable_actions_layout.dart'; @@ -43,14 +46,18 @@ class _SendRecipientViewState extends State { @override Widget build(BuildContext context) { return BlocListener( + listenWhen: (previous, current) => + (current is SendRecipientValid && previous is! SendRecipientValid) || + current is SendRecipientInvalid, listener: (context, state) { if (state is SendRecipientValid) { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => SendAmountPage(recipient: state.address), + unawaited( + pushThenRearm( + context, + page: SendAmountPage(recipient: state.address), + rearm: () => context.read().reset(), ), ); - context.read().reset(); } if (state is SendRecipientInvalid) { ScaffoldMessenger.of(context).showSnackBar( diff --git a/lib/screens/settings/bloc/settings_bloc.dart b/lib/screens/settings/bloc/settings_bloc.dart index 50bc0fbdb..02c49156c 100644 --- a/lib/screens/settings/bloc/settings_bloc.dart +++ b/lib/screens/settings/bloc/settings_bloc.dart @@ -13,14 +13,17 @@ class SettingsBloc extends Bloc { this._settingsRepository, this.getNewAuthToken, { this.onNetworkModeChanged, - }) : super(SettingsState( - language: Language.fromCode(_settingsRepository.language), - currency: Currency.fromCode(_settingsRepository.currency), - networkMode: _settingsRepository.networkMode, - insiderFeaturesUnlocked: - _settingsRepository.insiderFeaturesUnlocked, - )) { + }) : super( + SettingsState( + language: Language.fromCode(_settingsRepository.language), + currency: Currency.fromCode(_settingsRepository.currency), + networkMode: _settingsRepository.networkMode, + insiderFeaturesUnlocked: _settingsRepository.insiderFeaturesUnlocked, + ), + ) { on(_onSetCurrencyEvent); + on(_onApplyAccountCurrencyEvent); + on(_onClearAccountCurrencyEvent); on(_onSetLanguageEvent); on(_onSetNetworkModeEvent); on(_onToggleHideAmountEvent); @@ -45,8 +48,26 @@ class SettingsBloc extends Bloc { emit(state.copyWith(currency: event.currency)); } + void _onApplyAccountCurrencyEvent( + ApplyAccountCurrencyEvent event, + Emitter emit, + ) { + if (_settingsRepository.hasStoredCurrency) return; + emit(state.copyWith(currency: event.currency)); + } + + void _onClearAccountCurrencyEvent( + ClearAccountCurrencyEvent event, + Emitter emit, + ) { + if (_settingsRepository.hasStoredCurrency) return; + emit(state.copyWith(currency: Currency.fromCode(_settingsRepository.currency))); + } + Future _onSetNetworkModeEvent( - SetNetworkModeEvent event, Emitter emit) async { + SetNetworkModeEvent event, + Emitter emit, + ) async { _settingsRepository.networkMode = event.networkMode; await getNewAuthToken(); onNetworkModeChanged?.call(); diff --git a/lib/screens/settings/bloc/settings_event.dart b/lib/screens/settings/bloc/settings_event.dart index dd596c8fe..763874de6 100644 --- a/lib/screens/settings/bloc/settings_event.dart +++ b/lib/screens/settings/bloc/settings_event.dart @@ -25,6 +25,22 @@ final class SetCurrencyEvent extends SettingsEvent { List get props => [currency]; } +/// Account currency from GET /v2/user. Applied only when the user has not +/// stored a local preference. Not written to prefs. +final class ApplyAccountCurrencyEvent extends SettingsEvent { + final Currency currency; + + const ApplyAccountCurrencyEvent(this.currency); + + @override + List get props => [currency]; +} + +/// Drops a non-persisted account default (wallet closed or switched). +final class ClearAccountCurrencyEvent extends SettingsEvent { + const ClearAccountCurrencyEvent(); +} + final class SetNetworkModeEvent extends SettingsEvent { final NetworkMode networkMode; diff --git a/lib/screens/settings_wallet_address/settings_wallet_address_page.dart b/lib/screens/settings_wallet_address/settings_wallet_address_page.dart index 414ccff19..b9f9364bf 100644 --- a/lib/screens/settings_wallet_address/settings_wallet_address_page.dart +++ b/lib/screens/settings_wallet_address/settings_wallet_address_page.dart @@ -1,12 +1,16 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/svg.dart'; +import 'package:go_router/go_router.dart'; +import 'package:web3dart/web3dart.dart'; import 'package:realunit_wallet/generated/i18n.dart'; import 'package:realunit_wallet/packages/service/app_store.dart'; import 'package:realunit_wallet/packages/wallet/payment_uri.dart'; import 'package:realunit_wallet/screens/receive/widgets/qr_address_widget.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'; -import 'package:web3dart/web3dart.dart'; +import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart'; +import 'package:realunit_wallet/widgets/scrollable_actions_layout.dart'; class SettingsWalletAddressPage extends StatelessWidget { const SettingsWalletAddressPage({super.key}); @@ -21,49 +25,55 @@ class SettingsWalletAddressPage extends StatelessWidget { appBar: AppBar( title: Text(S.of(context).walletAddress), ), - body: Center( + body: SafeArea( child: Padding( padding: const .symmetric( horizontal: 20.0, vertical: 12.0, ), - child: SingleChildScrollView( - child: SafeArea( - child: Column( - spacing: 40.0, - mainAxisAlignment: .center, - children: [ - Column( - spacing: 16.0, - children: [ - SvgPicture.asset( - 'assets/images/coins/REALU.svg', - width: 70, - height: 70, - ), - Text( - '${S.of(context).realunitWallet} ${S.of(context).address}', - style: Theme.of(context).textTheme.headlineSmall, - ), - ], - ), - QRAddressWidget( - uri: EthereumURI(address: walletAddress, amount: '').toString(), - subtitle: walletAddress, - ), - Padding( - padding: const .all(20.0), - child: Text( - S.of(context).walletAddressDisclaimer, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: RealUnitColors.neutral500, - ), - textAlign: .center, + child: ScrollableActionsLayout( + // QR + disclaimer is taller than a small phone at large text + // scale; do not pin the body to the leftover viewport height + // (centerBody) or the Column overflows instead of scrolling. + body: Column( + spacing: 40.0, + children: [ + Column( + spacing: 16.0, + children: [ + SvgPicture.asset( + 'assets/images/coins/REALU.svg', + width: 70, + height: 70, ), + Text( + '${S.of(context).realunitWallet} ${S.of(context).address}', + style: Theme.of(context).textTheme.headlineSmall, + ), + ], + ), + QRAddressWidget( + uri: EthereumURI(address: walletAddress, amount: '').toString(), + subtitle: walletAddress, + ), + Padding( + padding: const .all(20.0), + child: Text( + S.of(context).walletAddressDisclaimer, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: RealUnitColors.neutral500, + ), + textAlign: .center, ), - ], - ), + ), + ], ), + actions: [ + AppFilledButton( + label: S.of(context).send, + onPressed: () => context.pushNamed(AppRoutes.send), + ), + ], ), ), ), diff --git a/lib/setup/account_currency_sync.dart b/lib/setup/account_currency_sync.dart new file mode 100644 index 000000000..73c26bcf3 --- /dev/null +++ b/lib/setup/account_currency_sync.dart @@ -0,0 +1,68 @@ +import 'dart:async'; + +import 'package:realunit_wallet/packages/service/dfx/dfx_kyc_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/user/dto/user_dto.dart'; +import 'package:realunit_wallet/packages/wallet/wallet.dart'; +import 'package:realunit_wallet/screens/settings/bloc/settings_bloc.dart'; + +/// Applies GET /v2/user.currency when a wallet opens or is replaced, and +/// clears a non-persisted account default when it closes. +/// +/// Extracted from [WalletApp] so open / close / A→B can be tested without +/// pumping the full app (same seam idea as boot_navigation). +class AccountCurrencySync { + AccountCurrencySync({ + required SettingsBloc settings, + required DfxKycService kyc, + required AWallet? Function() currentWallet, + }) : _settings = settings, + _kyc = kyc, + _currentWallet = currentWallet; + + final SettingsBloc _settings; + final DfxKycService _kyc; + final AWallet? Function() _currentWallet; + int _generation = 0; + + AWallet? get currentWallet => _currentWallet(); + + /// Applies an already-fetched GET /v2/user currency (KYC path) only if + /// [captured] is still the open wallet. + void applyFromUser(UserDto user, {required AWallet? captured}) { + final currency = user.currency; + if (currency == null) return; + final open = _currentWallet(); + if (open == null || !identical(open, captured)) return; + _generation++; + _settings.add(ApplyAccountCurrencyEvent(currency)); + } + + void onOpened(AWallet? wallet) { + final generation = ++_generation; + unawaited(_apply(generation, wallet)); + } + + void onClosed() { + _generation++; + _settings.add(const ClearAccountCurrencyEvent()); + } + + void onSwitched(AWallet? wallet) { + _settings.add(const ClearAccountCurrencyEvent()); + onOpened(wallet); + } + + Future _apply(int generation, AWallet? captured) async { + try { + final user = await _kyc.getUser(); + if (generation != _generation) return; + if (!identical(_currentWallet(), captured)) return; + final currency = user.currency; + if (currency == null) return; + _settings.add(ApplyAccountCurrencyEvent(currency)); + } catch (_) { + // Account currency is a display default; boot must not fail if /v2/user + // is unavailable. + } + } +} diff --git a/lib/setup/di.dart b/lib/setup/di.dart index 77f3f36b2..08fd95634 100644 --- a/lib/setup/di.dart +++ b/lib/setup/di.dart @@ -45,6 +45,7 @@ import 'package:realunit_wallet/packages/storage/secure_storage.dart'; import 'package:realunit_wallet/screens/home/bloc/home_bloc.dart'; import 'package:realunit_wallet/screens/pin/bloc/auth/pin_auth_cubit.dart'; import 'package:realunit_wallet/screens/settings/bloc/settings_bloc.dart'; +import 'package:realunit_wallet/setup/account_currency_sync.dart'; import 'package:realunit_wallet/setup/database.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -246,6 +247,13 @@ Future setupBlocs() async { getIt(), ), ); + getIt.registerSingleton( + AccountCurrencySync( + settings: getIt(), + kyc: getIt(), + currentWallet: () => getIt().state.openWallet, + ), + ); final pinAuthCubit = PinAuthCubit(getIt()); await pinAuthCubit.initialize(); diff --git a/lib/setup/routing/router_config.dart b/lib/setup/routing/router_config.dart index 72c9d050f..237682a3b 100644 --- a/lib/setup/routing/router_config.dart +++ b/lib/setup/routing/router_config.dart @@ -14,14 +14,14 @@ import 'package:realunit_wallet/screens/kyc/kyc_page_manager.dart'; import 'package:realunit_wallet/screens/legal/legal_disclaimer_page.dart'; import 'package:realunit_wallet/screens/legal/subpages/legal_document_page.dart'; import 'package:realunit_wallet/screens/onboarding/onboarding_completed_page.dart'; -import 'package:realunit_wallet/screens/pay/pay_scan_page.dart'; +import 'package:realunit_wallet/screens/pay/pay_info_page.dart'; import 'package:realunit_wallet/screens/pin/setup_pin_page.dart'; import 'package:realunit_wallet/screens/pin/verify_pin_page.dart'; import 'package:realunit_wallet/screens/receive/receive_page.dart'; import 'package:realunit_wallet/screens/restore_wallet/restore_wallet_page.dart'; import 'package:realunit_wallet/screens/sell/sell_page.dart'; import 'package:realunit_wallet/screens/sell_bitbox/sell_bitbox_page.dart'; -import 'package:realunit_wallet/screens/send/send_recipient_page.dart'; +import 'package:realunit_wallet/screens/send/send_info_page.dart'; import 'package:realunit_wallet/screens/settings/settings_page.dart'; import 'package:realunit_wallet/screens/settings_contact/settings_contact_page.dart'; import 'package:realunit_wallet/screens/settings_currencies/settings_currencies_page.dart'; @@ -180,7 +180,7 @@ final GoRouter routerConfig = GoRouter( GoRoute( name: AppRoutes.pay, path: '/pay', - builder: (_, state) => PayScanPage( + builder: (_, state) => PayInfoPage( initialPayload: state.extra is String ? state.extra as String : null, ), ), @@ -188,7 +188,7 @@ final GoRouter routerConfig = GoRouter( GoRoute( name: AppRoutes.send, path: '/send', - builder: (_, _) => const SendRecipientPage(), + builder: (_, _) => const SendInfoPage(), ), GoRoute( diff --git a/lib/styles/currency.dart b/lib/styles/currency.dart index 88bd724f0..7985c06e3 100644 --- a/lib/styles/currency.dart +++ b/lib/styles/currency.dart @@ -9,6 +9,15 @@ enum Currency { factory Currency.fromCode(String code) => Currency.values.firstWhere((e) => e.code == code.toUpperCase()); + static Currency? tryFromCode(String? code) { + if (code == null || code.isEmpty) return null; + try { + return Currency.fromCode(code); + } catch (_) { + return null; + } + } + final String code; String get name { diff --git a/lib/widgets/route_animation_gate.dart b/lib/widgets/route_animation_gate.dart new file mode 100644 index 000000000..0bc7718a4 --- /dev/null +++ b/lib/widgets/route_animation_gate.dart @@ -0,0 +1,175 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +/// Calls [onSettled] once the incoming [ModalRoute] animation is the real +/// controller and has completed. Ignores the first-frame +/// [kAlwaysCompleteAnimation] placeholder. A first/home route fires after +/// one frame. +class RouteAnimationGate extends StatefulWidget { + const RouteAnimationGate({ + super.key, + required this.onSettled, + required this.child, + }); + + final void Function(BuildContext context) onSettled; + final Widget child; + + @override + State createState() => _RouteAnimationGateState(); +} + +class _RouteAnimationGateState extends State { + bool _started = false; + bool _postedFrame = false; + Animation? _animation; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_started) { + return; + } + + final animation = ModalRoute.of(context)?.animation; + if (_animation != null && identical(animation, _animation)) { + return; + } + + _detach(); + _animation = animation; + _arm(animation); + } + + /// [ModalRoute.animation] is a [ProxyAnimation] that starts as + /// [kAlwaysCompleteAnimation] until the navigator attaches the real + /// controller. Treating that placeholder as "already completed" would + /// fire [onSettled] on the first frame of a push (split-screen + error sheet). + bool _isUnresolved(Animation? animation) { + if (animation == null) { + return true; + } + if (identical(animation, kAlwaysCompleteAnimation)) { + return true; + } + if (animation is ProxyAnimation) { + final parent = animation.parent; + if (parent == null || identical(parent, kAlwaysCompleteAnimation)) { + return true; + } + } + return false; + } + + void _arm(Animation? animation) { + if (animation == null || _isUnresolved(animation)) { + animation?.addListener(_onProxyChanged); + animation?.addStatusListener(_onAnimationStatus); + if (_postedFrame) { + return; + } + _postedFrame = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || _started) { + return; + } + final next = ModalRoute.of(context)?.animation; + if (identical(next, _animation) && _isUnresolved(next)) { + // Home / first route: there is no incoming slide to wait for. + if (ModalRoute.of(context)?.isFirst ?? true) { + _startOnce(); + } + return; + } + _detach(); + _animation = next; + _arm(next); + }); + return; + } + + animation.addStatusListener(_onAnimationStatus); + if (animation.status == AnimationStatus.completed || animation.value >= 1.0) { + _startOnce(); + } + } + + void _onProxyChanged() { + if (_started || !mounted) { + return; + } + final animation = _animation; + if (animation == null || _isUnresolved(animation)) { + return; + } + _detach(); + _animation = animation; + _arm(animation); + } + + void _onAnimationStatus(AnimationStatus status) { + if (status == AnimationStatus.completed) { + _startOnce(); + } + } + + void _startOnce() { + if (_started || !mounted) { + return; + } + _started = true; + _detach(); + widget.onSettled(context); + } + + void _detach() { + _animation?.removeListener(_onProxyChanged); + _animation?.removeStatusListener(_onAnimationStatus); + } + + @override + void dispose() { + _detach(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return widget.child; + } +} + +/// Completes when the route animation is null or [AnimationStatus.completed] +/// (including the placeholder). Otherwise waits for completed/dismissed. +/// Used before sheets; the gate is what delays *start*. +Future waitForIncomingRouteAnimation(BuildContext context) { + final animation = ModalRoute.of(context)?.animation; + if (animation == null || animation.status == AnimationStatus.completed) { + return Future.value(); + } + + final completer = Completer(); + + void listener(AnimationStatus status) { + if (status == AnimationStatus.completed || status == AnimationStatus.dismissed) { + animation.removeStatusListener(listener); + if (!completer.isCompleted) { + completer.complete(); + } + } + } + + animation.addStatusListener(listener); + + // Race: status may flip between the first check and addStatusListener. + if (animation.status == AnimationStatus.completed || + animation.status == AnimationStatus.dismissed) { + animation.removeStatusListener(listener); + if (!completer.isCompleted) { + completer.complete(); + } + } + + return completer.future; +} diff --git a/lib/widgets/scanner/push_then_rearm.dart b/lib/widgets/scanner/push_then_rearm.dart new file mode 100644 index 000000000..d8c31101c --- /dev/null +++ b/lib/widgets/scanner/push_then_rearm.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; + +/// Pushes [page] and calls [rearm] after that route is fully gone — or +/// immediately if the push throws — never in the same listener turn. +/// +/// [Navigator.push] completes when pop is *started*. Rearming then would let +/// a still-visible QR fire during the outgoing animation and push again. +/// Waiting on [Route.completed] closes that window. +/// +/// [QrScannerView] forwards every camera frame. Resetting a scanner cubit in +/// the same turn as the push drops the "already decoded" guard, so the next +/// frame pushes a second copy of [page]. +Future pushThenRearm( + BuildContext context, { + required Widget page, + required VoidCallback rearm, +}) async { + final route = MaterialPageRoute(builder: (_) => page); + try { + final result = await Navigator.of(context).push(route); + await route.completed; + return result; + } finally { + if (context.mounted) { + rearm(); + } + } +} diff --git a/lib/widgets/scanner/qr_scanner_view.dart b/lib/widgets/scanner/qr_scanner_view.dart index 6d5e9c384..84bb908bf 100644 --- a/lib/widgets/scanner/qr_scanner_view.dart +++ b/lib/widgets/scanner/qr_scanner_view.dart @@ -29,9 +29,17 @@ Widget _defaultErrorBuilder(BuildContext context, MobileScannerException error) /// value of each capture via [onDetect]. Keeping the camera/MethodChannel /// wiring in one widget lets every flow reuse the scanner without duplicating /// it — each flow decides what the scanned payload means in its own cubit. +/// +/// **Every camera frame is forwarded** while a barcode is in view — this is +/// not a one-shot capture. Consumers that [Navigator.push] from a scan result +/// **must** use [pushThenRearm] so the scanner cubit is re-armed after +/// [Route.completed] (or immediately if the push throws), never in the same +/// listener turn; resetting in the same turn as the push drops the cubit's +/// "already decoded" guard and double-pushes the next page. class QrScannerView extends StatelessWidget { /// Invoked with the raw string value of the first detected barcode in a - /// capture. Null raw values are filtered out before this is called. + /// capture. Null raw values are filtered out before this is called. Called + /// on every frame while a barcode remains visible — not once. final ValueChanged onDetect; /// Optional error UI builder forwarded to [MobileScanner.errorBuilder]. diff --git a/scripts/assemble-handbook-screenshots.sh b/scripts/assemble-handbook-screenshots.sh index 19f173b7d..a20ad2d89 100755 --- a/scripts/assemble-handbook-screenshots.sh +++ b/scripts/assemble-handbook-screenshots.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # -# Assemble the 278 handbook screenshots from the visual-regression Golden +# Assemble the 284 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`). @@ -316,6 +316,12 @@ 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-handbook-persona-dca=screens/dashboard/goldens/macos/handbook_persona_dca.png" + "271-handbook-persona-lump=screens/dashboard/goldens/macos/handbook_persona_lump.png" + "272-handbook-persona-exit=screens/dashboard/goldens/macos/handbook_persona_exit.png" + "273-handbook-persona-mix=screens/dashboard/goldens/macos/handbook_persona_mix.png" + "274-handbook-persona-scale=screens/dashboard/goldens/macos/handbook_persona_scale.png" + "275-handbook-persona-exit-1j=screens/dashboard/goldens/macos/handbook_persona_exit_1j.png" ) missing=() diff --git a/scripts/run-handbook-flows-matcher-test.sh b/scripts/run-handbook-flows-matcher-test.sh new file mode 100755 index 000000000..2496cdeb9 --- /dev/null +++ b/scripts/run-handbook-flows-matcher-test.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# Thin wrapper so local/CI can run the matcher without a simulator. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +exec "$ROOT/scripts/run-handbook-flows.sh" --matcher-self-test diff --git a/scripts/run-handbook-flows.sh b/scripts/run-handbook-flows.sh index c4546552b..28182f303 100755 --- a/scripts/run-handbook-flows.sh +++ b/scripts/run-handbook-flows.sh @@ -40,24 +40,30 @@ # Silicon + iOS 26.x (see mobile-dev-inc/maestro#3137); the pin # targets a known-good release. The retry remains as a safety net # for residual Apple-XCTest crashes (~10 % per the #3137 thread): -# each flow is retried up to MAESTRO_MAX_ATTEMPTS times (default 3) +# each flow is retried up to MAESTRO_MAX_ATTEMPTS times (default 3); +# a hung JVM is killed after MAESTRO_ATTEMPT_TIMEOUT_SEC (default 480) # when the CLI tee-log or `--debug-output` maestro.log matches the # driver hang/death class. `IOSDriverTimeoutException` matches # anywhere in the file. ConnectException / `Failed to connect to -# /127.0.0.1:7001` / `Connection refused` / `Connection reset` only +# /127.0.0.1:7001` / `Connection refused` / `Connection reset` / +# `UnknownFailure` HTTP 500 on `:7001/deviceInfo` only # count from the first `Running flow ` line onward (inclusive) — # every iOS start logs those strings during the XCTest installer # status-check *before* the flow. If `Running flow ` is absent the # whole file is searched (driver never came up). Maestro often # disguises XCUITest-driver death as a later assertion failure on # stdout while the ConnectException lives only in the debug log. -# Assertion failures without those patterns are NEVER retried — -# those are real regressions and must surface as red CI checks. +# A hung JVM after that 500 is killed per attempt +# (`MAESTRO_ATTEMPT_TIMEOUT_SEC`) so the job does not sit until +# GitHub cancels the workflow. Assertion failures without those +# patterns are NEVER retried — those are real regressions and +# must surface as red CI checks. # # Usage: # scripts/run-handbook-flows.sh # run ALL handbook flows # scripts/run-handbook-flows.sh 25-restore-wallet # run only matching flows # scripts/run-handbook-flows.sh '2*' 12-settings # multiple glob patterns +# scripts/run-handbook-flows.sh --matcher-self-test # # With no arguments every flow in .maestro/handbook/*.yaml runs (the # default, full-suite behaviour). With one or more positional arguments, @@ -81,6 +87,81 @@ MAESTRO="${MAESTRO:-$HOME/.maestro/bin/maestro}" FLOWS_DIR="$REPO_ROOT/.maestro/handbook" CAPTURES_DIR="$REPO_ROOT/build/handbook-captures" +# True when $1 is a readable file that matches the XCUITest-driver +# hang/death class. Maestro often disguises driver death as a later +# assertion failure on CLI stdout while the ConnectException lives +# only in `--debug-output` maestro.log — callers must pass both. +# ConnectException-class strings before `Running flow ` are installer +# status-check noise, not driver death; slice them out. Do not pipe +# awk into grep under pipefail (SIGPIPE would false-negative). +is_driver_hang_or_death() { + local log="$1" + [ -f "$log" ] || return 1 + + if grep -qF 'IOSDriverTimeoutException' "$log"; then + return 0 + fi + + local haystack="$log" + local tmp="" + if grep -qF 'Running flow ' "$log"; then + tmp="$(mktemp)" + awk 'p || /Running flow / { p=1; print }' "$log" > "$tmp" + haystack="$tmp" + fi + + local rc=0 + grep -qF \ + -e 'Failed to connect to /127.0.0.1:7001' \ + -e 'java.net.ConnectException' \ + -e 'Connection refused' \ + -e 'Connection reset' \ + -e 'deviceInfo failed, code: 500' \ + "$haystack" || rc=$? + if [ -n "$tmp" ]; then + rm -f "$tmp" + fi + return "$rc" +} + +if [ "${1:-}" = '--matcher-self-test' ]; then + tmp="$(mktemp)" + trap 'rm -f "$tmp"' EXIT + printf '%s\n' \ + 'Running flow 01-welcome' \ + 'Assert that "Start" is visible...Exception in thread "main" UnknownFailure(errorResponse=Request for http://127.0.0.1:7001/deviceInfo failed, code: 500, body: )' \ + > "$tmp" + is_driver_hang_or_death "$tmp" || { + echo 'expected HTTP 500 deviceInfo after Running flow to match' >&2 + exit 1 + } + printf '%s\n' 'Running flow 01-welcome' 'Assert that "Start" is visible' > "$tmp" + if is_driver_hang_or_death "$tmp"; then + echo 'bare assertion must not match' >&2 + exit 1 + fi + printf '%s\n' \ + 'Running flow 01-welcome' \ + 'GET http://127.0.0.1:7001/deviceInfo' \ + > "$tmp" + if is_driver_hang_or_death "$tmp"; then + echo 'deviceInfo URL without failure must not match' >&2 + exit 1 + fi + printf '%s\n' 'Running flow 01-welcome' 'UnknownFailure(errorResponse=unrelated)' > "$tmp" + if is_driver_hang_or_death "$tmp"; then + echo 'UnknownFailure without deviceInfo 500 must not match' >&2 + exit 1 + fi + printf '%s\n' 'java.net.ConnectException: Connection refused' > "$tmp" + is_driver_hang_or_death "$tmp" || { + echo 'expected ConnectException without Running flow to match' >&2 + exit 1 + } + echo ok + exit 0 +fi + mkdir -p "$CAPTURES_DIR" if [ ! -x "$MAESTRO" ] && ! command -v maestro >/dev/null 2>&1; then @@ -187,10 +268,15 @@ trap 'rm -rf "$TMP_DIR"' EXIT # Per-attempt retry budget for the upstream driver-hang class. The per-flow # / per-attempt timing logged below is the data to size the workflow's # `timeout-minutes` (see tier3-handbook.yaml) and to target a real speed-up. -# Worst-case the entire suite is 3 × 26 × ~1 min plus 3 × ~6 min -# driver-startup-timeout per failed attempt, which still fits inside -# the workflow's 60 min envelope. +# Happy-path the suite is ~26 × ~1–3 min plus driver startup and still +# fits the workflow's 60 min envelope. Per-attempt 480s timeouts are +# the rare hang class, not the budget for every flow. MAESTRO_MAX_ATTEMPTS="${MAESTRO_MAX_ATTEMPTS:-3}" +# Cap a single `maestro test` so a JVM that prints Exception-in-main +# and then never exits cannot consume the remaining job budget. +# 8 min is above a healthy flow (typically 1–3 min) and below the +# 40 min hang seen when :7001/deviceInfo returns HTTP 500. +MAESTRO_ATTEMPT_TIMEOUT_SEC="${MAESTRO_ATTEMPT_TIMEOUT_SEC:-480}" # Maestro's `--debug-output` writes per-attempt view-hierarchy.json + # screenshot + maestro.log into the given directory. On flow failure @@ -233,40 +319,85 @@ fmt_duration() { printf '%dm %02ds' $(( $1 / 60 )) $(( $1 % 60 )) } -# True when $1 is a readable file that matches the XCUITest-driver -# hang/death class. Maestro often disguises driver death as a later -# assertion failure on CLI stdout while the ConnectException lives -# only in `--debug-output` maestro.log — callers must pass both. -# ConnectException-class strings before `Running flow ` are installer -# status-check noise, not driver death; slice them out. Do not pipe -# awk into grep under pipefail (SIGPIPE would false-negative). -is_driver_hang_or_death() { +# Run one `maestro test`, tee to $1, kill the process group if it +# outlives MAESTRO_ATTEMPT_TIMEOUT_SEC. Exit 124 on timeout so the +# retry path can treat it as driver hang/death. +run_maestro_attempt() { local log="$1" - [ -f "$log" ] || return 1 + shift + MAESTRO_ATTEMPT_TIMEOUT_SEC="$MAESTRO_ATTEMPT_TIMEOUT_SEC" \ + /usr/bin/python3 - "$log" "$@" <<'PY' +import os, select, signal, subprocess, sys, threading - if grep -qF 'IOSDriverTimeoutException' "$log"; then - return 0 - fi +log_path = sys.argv[1] +cmd = sys.argv[2:] +timeout = int(os.environ.get("MAESTRO_ATTEMPT_TIMEOUT_SEC", "480")) +log = open(log_path, "wb") +p = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + stdin=subprocess.DEVNULL, + start_new_session=True, +) +timed_out = {"v": False} - local haystack="$log" - local tmp="" - if grep -qF 'Running flow ' "$log"; then - tmp="$(mktemp)" - awk 'p || /Running flow / { p=1; print }' "$log" > "$tmp" - haystack="$tmp" - fi +def kill_pg(): + timed_out["v"] = True + try: + os.killpg(p.pid, signal.SIGKILL) + except ProcessLookupError: + pass - local rc=0 - grep -qF \ - -e 'Failed to connect to /127.0.0.1:7001' \ - -e 'java.net.ConnectException' \ - -e 'Connection refused' \ - -e 'Connection reset' \ - "$haystack" || rc=$? - if [ -n "$tmp" ]; then - rm -f "$tmp" - fi - return "$rc" +def on_signal(signum, _frame): + kill_pg() + try: + p.wait() + except Exception: + pass + log.close() + sys.exit(128 + signum) + +signal.signal(signal.SIGTERM, on_signal) +signal.signal(signal.SIGINT, on_signal) +signal.signal(signal.SIGHUP, on_signal) + +timer = threading.Timer(timeout, kill_pg) +timer.daemon = True +timer.start() +try: + fd = p.stdout.fileno() + while True: + if p.poll() is not None: + rest = os.read(fd, 65536) if p.stdout is not None else b"" + while rest: + sys.stdout.buffer.write(rest) + log.write(rest) + rest = os.read(fd, 65536) + break + ready, _, _ = select.select([p.stdout], [], [], 1.0) + if not ready: + continue + try: + chunk = os.read(fd, 4096) + except OSError: + break + if not chunk: + break + sys.stdout.buffer.write(chunk) + sys.stdout.buffer.flush() + log.write(chunk) +finally: + timer.cancel() + if p.poll() is None: + kill_pg() + p.wait() +log.close() +if timed_out["v"]: + sys.stdout.write("\nmaestro attempt timed out after %ss\n" % timeout) + sys.exit(124) +sys.exit(p.returncode or 0) +PY } suite_start=$(date +%s) @@ -286,8 +417,11 @@ for flow in "${flows[@]}"; do attempt_start=$(date +%s) flow_log="$TMP_DIR/$base.attempt-$attempt.log" debug_dir="$MAESTRO_DEBUG_ROOT/$base-attempt-$attempt" - if "$MAESTRO" test --reinstall-driver="$reinstall_driver" \ - --debug-output "$debug_dir" --flatten-debug-output "$flow" 2>&1 | tee "$flow_log"; then + maestro_rc=0 + run_maestro_attempt "$flow_log" \ + "$MAESTRO" test --reinstall-driver="$reinstall_driver" \ + --debug-output "$debug_dir" --flatten-debug-output "$flow" || maestro_rc=$? + if [ "$maestro_rc" -eq 0 ]; then echo " attempt $attempt passed in $(fmt_duration $(( $(date +%s) - attempt_start )))" # Driver is up after a successful invocation — keep reusing it # (a prior retry may have flipped this to true for a reinstall). @@ -298,9 +432,11 @@ for flow in "${flows[@]}"; do # tee-log AND `--debug-output` maestro.log: driver death is often # reported on stdout as a later assertion failure (`Assert that # "Start" is visible`) while ConnectException lives only in the - # debug log. Assertion failures without these patterns are real + # debug log. A timed-out hung JVM (exit 124) is the same class. + # Assertion failures without these patterns are real # regressions and must surface red — never retry them. - if { is_driver_hang_or_death "$flow_log" || \ + if { [ "$maestro_rc" -eq 124 ] || \ + is_driver_hang_or_death "$flow_log" || \ is_driver_hang_or_death "$debug_dir/maestro.log"; } && \ [ "$attempt" -lt "$MAESTRO_MAX_ATTEMPTS" ]; then echo " driver hang/death on attempt $attempt of $MAESTRO_MAX_ATTEMPTS after $(fmt_duration $(( $(date +%s) - attempt_start ))); restarting simulator and retrying" diff --git a/test/goldens/screens/buy/buy_golden_test.dart b/test/goldens/screens/buy/buy_golden_test.dart index 25791c972..ba158b1ea 100644 --- a/test/goldens/screens/buy/buy_golden_test.dart +++ b/test/goldens/screens/buy/buy_golden_test.dart @@ -21,16 +21,14 @@ import 'package:realunit_wallet/styles/currency.dart'; import '../../../helper/helper.dart'; -class _MockBuyConverterCubit extends MockCubit - implements BuyConverterCubit {} +class _MockBuyConverterCubit extends MockCubit implements BuyConverterCubit {} class _MockBuyPaymentInfoCubit extends MockCubit implements BuyPaymentInfoCubit {} class _MockDfxBrokerbotService extends Mock implements DfxBrokerbotService {} -class _MockRealUnitBuyPaymentInfoService extends Mock - implements RealUnitBuyPaymentInfoService {} +class _MockRealUnitBuyPaymentInfoService extends Mock implements RealUnitBuyPaymentInfoService {} class _MockDfxPriceService extends Mock implements DFXPriceService {} @@ -69,23 +67,56 @@ void main() { ); getIt.registerSingleton(_MockDfxPriceService()); final fiatRepo = _MockSupportedFiatRepository(); - when(() => fiatRepo.getBuyable()) - .thenAnswer((_) async => const [Currency.chf, Currency.eur]); + when(() => fiatRepo.getBuyable()).thenAnswer((_) async => const [Currency.chf, Currency.eur]); when(() => fiatRepo.getSellable()).thenAnswer((_) async => const [Currency.chf]); - when(() => fiatRepo.getAll()) - .thenAnswer((_) async => const [Currency.chf, Currency.eur]); + when(() => fiatRepo.getAll()).thenAnswer((_) async => const [Currency.chf, Currency.eur]); getIt.registerSingleton(fiatRepo); }); tearDownAll(() async => GetIt.instance.reset()); Widget buildSubject() => MultiBlocProvider( - providers: [ - BlocProvider.value(value: converterCubit), - BlocProvider.value(value: paymentInfoCubit), - ], - child: const BuyView(), - ); + providers: [ + BlocProvider.value(value: converterCubit), + BlocProvider.value(value: paymentInfoCubit), + ], + child: const BuyView(), + ); + + /// BuyView copies fiat/shares into the TextFields only when converter + /// `loading` flips true → false (the live conversion just settled). + void stubTypedConverter({ + required String fiatText, + required String sharesText, + String payableText = '', + Currency currency = Currency.chf, + }) { + whenListen( + converterCubit, + Stream.fromIterable([ + BuyConverterState( + fiatText: fiatText, + payableText: payableText, + sharesText: sharesText, + currency: currency, + loading: true, + ), + BuyConverterState( + fiatText: fiatText, + payableText: payableText, + sharesText: sharesText, + currency: currency, + ), + ]), + initialState: BuyConverterState( + fiatText: fiatText, + payableText: payableText, + sharesText: sharesText, + currency: currency, + loading: true, + ), + ); + } group('$BuyView', () { goldenTest( @@ -117,13 +148,38 @@ void main() { ), ), ); - when(() => converterCubit.state).thenReturn( - const BuyConverterState( - fiatText: '100', - sharesText: '1.00', - currency: Currency.chf, + stubTypedConverter(fiatText: '100', sharesText: '1.00'); + return wrapForGolden(buildSubject()); + }, + ); + + goldenTest( + 'typed amount stays and exact charge shows under the field', + fileName: 'buy_charged_amount', + constraints: const BoxConstraints.tightFor(width: 390, height: 844), + builder: () { + when(() => paymentInfoCubit.state).thenReturn( + const BuyPaymentInfoSuccess( + BuyPaymentInfo( + amount: 299.46, + id: 1, + iban: 'CH00 0000 0000 0000 0000 0', + bic: 'BICCBIC', + name: 'RealUnit AG', + street: 'Bahnhofstrasse', + number: '1', + zip: '8001', + city: 'Zurich', + country: 'Switzerland', + currency: Currency.chf, + ), ), ); + stubTypedConverter( + fiatText: '300', + payableText: '299.46', + sharesText: '217', + ); return wrapForGolden(buildSubject()); }, ); @@ -132,20 +188,15 @@ void main() { 'payment info loading', fileName: 'buy_payment_info_loading', constraints: const BoxConstraints.tightFor(width: 390, height: 844), - // Loading state renders a CircularProgressIndicator — its animation - // never settles, so pumpAndSettle (default in precacheImages) hits - // its timeout. pumpOnce captures the first frame without waiting. - pumpBeforeTest: pumpOnce, + // Loading spinner never settles, so skip pumpAndSettle. One extra + // pump delivers the converter loading→done flip so the typed amount + // is in the fields, matching a quote in flight after conversion. + pumpBeforeTest: (tester) async { + await tester.pump(); + }, builder: () { - when(() => paymentInfoCubit.state) - .thenReturn(const BuyPaymentInfoLoading()); - when(() => converterCubit.state).thenReturn( - const BuyConverterState( - fiatText: '100', - sharesText: '1.00', - currency: Currency.chf, - ), - ); + when(() => paymentInfoCubit.state).thenReturn(const BuyPaymentInfoLoading()); + stubTypedConverter(fiatText: '100', sharesText: '1.00'); return wrapForGolden(buildSubject()); }, ); @@ -158,13 +209,7 @@ void main() { when(() => paymentInfoCubit.state).thenReturn( const BuyPaymentInfoFailure(PaymentInfoError.kycRequired), ); - when(() => converterCubit.state).thenReturn( - const BuyConverterState( - fiatText: '100', - sharesText: '1.00', - currency: Currency.chf, - ), - ); + stubTypedConverter(fiatText: '100', sharesText: '1.00'); return wrapForGolden(buildSubject()); }, ); @@ -177,13 +222,7 @@ void main() { when(() => paymentInfoCubit.state).thenReturn( const BuyPaymentInfoFailure(PaymentInfoError.registrationRequired), ); - when(() => converterCubit.state).thenReturn( - const BuyConverterState( - fiatText: '100', - sharesText: '1.00', - currency: Currency.chf, - ), - ); + stubTypedConverter(fiatText: '100', sharesText: '1.00'); return wrapForGolden(buildSubject()); }, ); @@ -196,13 +235,7 @@ void main() { when(() => paymentInfoCubit.state).thenReturn( const BuyPaymentInfoFailure(PaymentInfoError.primaryEmailRequired), ); - when(() => converterCubit.state).thenReturn( - const BuyConverterState( - fiatText: '100', - sharesText: '1.00', - currency: Currency.chf, - ), - ); + stubTypedConverter(fiatText: '100', sharesText: '1.00'); return wrapForGolden(buildSubject()); }, ); @@ -215,13 +248,7 @@ void main() { when(() => paymentInfoCubit.state).thenReturn( const BuyPaymentInfoFailure(PaymentInfoError.primaryEmailNotConfirmed), ); - when(() => converterCubit.state).thenReturn( - const BuyConverterState( - fiatText: '100', - sharesText: '1.00', - currency: Currency.chf, - ), - ); + stubTypedConverter(fiatText: '100', sharesText: '1.00'); return wrapForGolden(buildSubject()); }, ); @@ -237,13 +264,23 @@ void main() { minAmount: 10, ), ); - when(() => converterCubit.state).thenReturn( - const BuyConverterState( - fiatText: '1', - sharesText: '0.01', - currency: Currency.chf, + stubTypedConverter(fiatText: '1', sharesText: '0.01'); + return wrapForGolden(buildSubject()); + }, + ); + + goldenTest( + 'max amount exceeded failure', + fileName: 'buy_max_amount_exceeded', + constraints: const BoxConstraints.tightFor(width: 390, height: 844), + builder: () { + when(() => paymentInfoCubit.state).thenReturn( + const BuyPaymentInfoMaxAmountExceededFailure( + PaymentInfoError.maxAmountExceeded, + maxAmount: 90000, ), ); + stubTypedConverter(fiatText: '105000', sharesText: '76086'); return wrapForGolden(buildSubject()); }, ); @@ -259,13 +296,7 @@ void main() { message: 'The purchase could not be quoted. Please try again later.', ), ); - when(() => converterCubit.state).thenReturn( - const BuyConverterState( - fiatText: '100', - sharesText: '1.00', - currency: Currency.chf, - ), - ); + stubTypedConverter(fiatText: '100', sharesText: '1.00'); return wrapForGolden(buildSubject()); }, ); @@ -281,10 +312,25 @@ void main() { message: 'RealUnit price source (Aktionariat) is currently unavailable', ), ); + stubTypedConverter(fiatText: '100', sharesText: '1.00'); + return wrapForGolden(buildSubject()); + }, + ); + + goldenTest( + 'price source unavailable with empty message shows retry only', + fileName: 'buy_price_source_unavailable_empty', + constraints: const BoxConstraints.tightFor(width: 390, height: 844), + builder: () { + when(() => paymentInfoCubit.state).thenReturn( + const BuyPaymentInfoFailure( + PaymentInfoError.priceSourceUnavailable, + ), + ); when(() => converterCubit.state).thenReturn( const BuyConverterState( - fiatText: '100', - sharesText: '1.00', + fiatText: '', + sharesText: '217', currency: Currency.chf, ), ); diff --git a/test/goldens/screens/buy/goldens/macos/buy_charged_amount.png b/test/goldens/screens/buy/goldens/macos/buy_charged_amount.png new file mode 100644 index 000000000..8055185f8 Binary files /dev/null and b/test/goldens/screens/buy/goldens/macos/buy_charged_amount.png differ diff --git a/test/goldens/screens/buy/goldens/macos/buy_kyc_required.png b/test/goldens/screens/buy/goldens/macos/buy_kyc_required.png index 1706df4ba..7c3dd362d 100644 Binary files a/test/goldens/screens/buy/goldens/macos/buy_kyc_required.png and b/test/goldens/screens/buy/goldens/macos/buy_kyc_required.png differ diff --git a/test/goldens/screens/buy/goldens/macos/buy_max_amount_exceeded.png b/test/goldens/screens/buy/goldens/macos/buy_max_amount_exceeded.png new file mode 100644 index 000000000..338e981c3 Binary files /dev/null and b/test/goldens/screens/buy/goldens/macos/buy_max_amount_exceeded.png differ diff --git a/test/goldens/screens/buy/goldens/macos/buy_min_amount_not_met.png b/test/goldens/screens/buy/goldens/macos/buy_min_amount_not_met.png index 97eb5f00d..d75a42ab8 100644 Binary files a/test/goldens/screens/buy/goldens/macos/buy_min_amount_not_met.png and b/test/goldens/screens/buy/goldens/macos/buy_min_amount_not_met.png differ diff --git a/test/goldens/screens/buy/goldens/macos/buy_payment_info_loaded.png b/test/goldens/screens/buy/goldens/macos/buy_payment_info_loaded.png index 043740daf..c8d7a6031 100644 Binary files a/test/goldens/screens/buy/goldens/macos/buy_payment_info_loaded.png and b/test/goldens/screens/buy/goldens/macos/buy_payment_info_loaded.png differ diff --git a/test/goldens/screens/buy/goldens/macos/buy_payment_info_loading.png b/test/goldens/screens/buy/goldens/macos/buy_payment_info_loading.png index ef15de739..1ef853466 100644 Binary files a/test/goldens/screens/buy/goldens/macos/buy_payment_info_loading.png and b/test/goldens/screens/buy/goldens/macos/buy_payment_info_loading.png differ diff --git a/test/goldens/screens/buy/goldens/macos/buy_price_source_unavailable.png b/test/goldens/screens/buy/goldens/macos/buy_price_source_unavailable.png index 2a37b9f07..68be7b14d 100644 Binary files a/test/goldens/screens/buy/goldens/macos/buy_price_source_unavailable.png and b/test/goldens/screens/buy/goldens/macos/buy_price_source_unavailable.png differ diff --git a/test/goldens/screens/buy/goldens/macos/buy_price_source_unavailable_empty.png b/test/goldens/screens/buy/goldens/macos/buy_price_source_unavailable_empty.png new file mode 100644 index 000000000..01d68f78a Binary files /dev/null and b/test/goldens/screens/buy/goldens/macos/buy_price_source_unavailable_empty.png differ diff --git a/test/goldens/screens/buy/goldens/macos/buy_primary_email_not_confirmed.png b/test/goldens/screens/buy/goldens/macos/buy_primary_email_not_confirmed.png index bb9a2e41e..870245426 100644 Binary files a/test/goldens/screens/buy/goldens/macos/buy_primary_email_not_confirmed.png and b/test/goldens/screens/buy/goldens/macos/buy_primary_email_not_confirmed.png differ diff --git a/test/goldens/screens/buy/goldens/macos/buy_primary_email_required.png b/test/goldens/screens/buy/goldens/macos/buy_primary_email_required.png index bb9a2e41e..870245426 100644 Binary files a/test/goldens/screens/buy/goldens/macos/buy_primary_email_required.png and b/test/goldens/screens/buy/goldens/macos/buy_primary_email_required.png differ diff --git a/test/goldens/screens/buy/goldens/macos/buy_registration_required.png b/test/goldens/screens/buy/goldens/macos/buy_registration_required.png index 0d7c27de6..34cffc5f0 100644 Binary files a/test/goldens/screens/buy/goldens/macos/buy_registration_required.png and b/test/goldens/screens/buy/goldens/macos/buy_registration_required.png differ diff --git a/test/goldens/screens/buy/goldens/macos/buy_unknown_error.png b/test/goldens/screens/buy/goldens/macos/buy_unknown_error.png index a8250c286..48aef8d3d 100644 Binary files a/test/goldens/screens/buy/goldens/macos/buy_unknown_error.png and b/test/goldens/screens/buy/goldens/macos/buy_unknown_error.png differ diff --git a/test/goldens/screens/dashboard/dashboard_states_golden_test.dart b/test/goldens/screens/dashboard/dashboard_states_golden_test.dart index eabea2a80..422153a04 100644 --- a/test/goldens/screens/dashboard/dashboard_states_golden_test.dart +++ b/test/goldens/screens/dashboard/dashboard_states_golden_test.dart @@ -18,6 +18,7 @@ import 'package:realunit_wallet/screens/dashboard/bloc/balance_cubit.dart'; import 'package:realunit_wallet/screens/dashboard/bloc/dashboard_bloc.dart'; import 'package:realunit_wallet/screens/dashboard/bloc/pending_transactions_cubit.dart'; import 'package:realunit_wallet/screens/dashboard/dashboard_page.dart'; +import 'package:realunit_wallet/screens/dashboard/widgets/time_period_selection_button.dart'; import 'package:realunit_wallet/screens/settings/bloc/settings_bloc.dart'; import 'package:realunit_wallet/styles/currency.dart'; @@ -34,9 +35,9 @@ import '../../../helper/helper.dart'; // `DateTime.now()`, but the value is only consumed by the non-`all` period // branches of their `switch`. The default `selectedPeriod` is `TimePeriod.all` // (`price_chart_cubit.dart:12`), whose `minX`/`maxX` come purely from the first -// / last fixture `PricePoint.time`. No period selector is tapped, so `now()` -// never reaches the rendered spots — the curve, gradient and axis labels are a -// pure function of the fixed fixtures below. +// / last fixture `PricePoint.time`. The customer-report goldens below tap 1W/ +// 1M/3M/1J; those windows use offsets from `now()`, so a flat zero-line plot +// stays stable (no date axis) and MAX still shows the pre-1Y curve. class _MockDashboardBloc extends MockBloc implements DashboardBloc {} @@ -88,6 +89,35 @@ void main() { PortfolioValuePoint(value: BigInt.from(15300), balance: BigInt.from(100), time: DateTime.utc(2026, 5)), ]; + // Customer report: older non-zero holdings (outside 1Y so MAX shows a + // curve) then zeros through now (1W/1M/3M/1J keep a visible zero line). + // Times are local midnight so they share the cubit's period window + // (`DateTime(now.year, now.month, now.day - N)`); a clock-time remainder + // would shift the zero-line by a few pixels between CI runs. + List customerReportHistory() { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + PortfolioValuePoint pt(int daysAgo, int valueRappen, int shares) => + PortfolioValuePoint( + value: BigInt.from(valueRappen), + balance: BigInt.from(shares), + time: today.subtract(Duration(days: daysAgo)), + ); + return [ + pt(500, 10000, 100), + pt(470, 18000, 180), + pt(440, 35000, 350), + pt(410, 50000, 500), + pt(370, 0, 0), + pt(180, 0, 0), + pt(90, 0, 0), + pt(30, 0, 0), + pt(6, 0, 0), + pt(1, 0, 0), + pt(0, 0, 0), + ]; + } + Balance zeroBalance() => Balance( chainId: realUnitAsset.chainId, contractAddress: realUnitAsset.address, @@ -227,6 +257,106 @@ void main() { }, ); + goldenTest( + 'portfolio history all-zero window keeps a visible chart', + fileName: 'dashboard_portfolio_chart_zero', + constraints: phoneConstraints, + builder: () { + when(() => dashboardBloc.state).thenReturn( + dashboardState( + history: [ + for (final p in portfolioHistory) + PortfolioValuePoint( + value: BigInt.zero, + balance: BigInt.zero, + time: p.time, + ), + ], + ), + ); + return wrapForGolden(buildSubject()); + }, + ); + + goldenTest( + 'customer report MAX — older holdings draw a curve then drop to zero', + fileName: 'dashboard_portfolio_chart_zero_collapsed', + constraints: phoneConstraints, + builder: () { + when(() => dashboardBloc.state) + .thenReturn(dashboardState(history: customerReportHistory())); + return wrapForGolden(buildSubject()); + }, + ); + + goldenTest( + 'customer report 1W — recent zeros keep a visible zero-line chart', + fileName: 'dashboard_portfolio_chart_zero_collapsed_1w', + constraints: phoneConstraints, + whilePerforming: (tester) async { + await tester.tap(find.widgetWithText(TimePeriodSelectionButton, '1W')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + return null; + }, + builder: () { + when(() => dashboardBloc.state) + .thenReturn(dashboardState(history: customerReportHistory())); + return wrapForGolden(buildSubject()); + }, + ); + + goldenTest( + 'customer report 1M — recent zeros keep a visible zero-line chart', + fileName: 'dashboard_portfolio_chart_zero_collapsed_1m', + constraints: phoneConstraints, + whilePerforming: (tester) async { + await tester.tap(find.widgetWithText(TimePeriodSelectionButton, '1M')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + return null; + }, + builder: () { + when(() => dashboardBloc.state) + .thenReturn(dashboardState(history: customerReportHistory())); + return wrapForGolden(buildSubject()); + }, + ); + + goldenTest( + 'customer report 3M — recent zeros keep a visible zero-line chart', + fileName: 'dashboard_portfolio_chart_zero_collapsed_3m', + constraints: phoneConstraints, + whilePerforming: (tester) async { + await tester.tap(find.widgetWithText(TimePeriodSelectionButton, '3M')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + return null; + }, + builder: () { + when(() => dashboardBloc.state) + .thenReturn(dashboardState(history: customerReportHistory())); + return wrapForGolden(buildSubject()); + }, + ); + + goldenTest( + 'customer report 1J — recent zeros keep a visible zero-line chart', + fileName: 'dashboard_portfolio_chart_zero_collapsed_1j', + constraints: phoneConstraints, + whilePerforming: (tester) async { + await tester.tap(find.widgetWithText(TimePeriodSelectionButton, '1J')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + return null; + }, + builder: () { + when(() => dashboardBloc.state) + .thenReturn(dashboardState(history: customerReportHistory())); + return wrapForGolden(buildSubject()); + }, + ); + goldenTest( 'pending transactions section', fileName: 'dashboard_pending_transactions', diff --git a/test/goldens/screens/dashboard/goldens/macos/dashboard_insider_unlocked.png b/test/goldens/screens/dashboard/goldens/macos/dashboard_insider_unlocked.png index 2d41dd005..970c6ffe7 100644 Binary files a/test/goldens/screens/dashboard/goldens/macos/dashboard_insider_unlocked.png and b/test/goldens/screens/dashboard/goldens/macos/dashboard_insider_unlocked.png differ diff --git a/test/goldens/screens/dashboard/goldens/macos/dashboard_portfolio_chart_zero.png b/test/goldens/screens/dashboard/goldens/macos/dashboard_portfolio_chart_zero.png new file mode 100644 index 000000000..6b1a76984 Binary files /dev/null and b/test/goldens/screens/dashboard/goldens/macos/dashboard_portfolio_chart_zero.png differ diff --git a/test/goldens/screens/dashboard/goldens/macos/dashboard_portfolio_chart_zero_collapsed.png b/test/goldens/screens/dashboard/goldens/macos/dashboard_portfolio_chart_zero_collapsed.png new file mode 100644 index 000000000..7c829fcbc Binary files /dev/null and b/test/goldens/screens/dashboard/goldens/macos/dashboard_portfolio_chart_zero_collapsed.png differ diff --git a/test/goldens/screens/dashboard/goldens/macos/dashboard_portfolio_chart_zero_collapsed_1j.png b/test/goldens/screens/dashboard/goldens/macos/dashboard_portfolio_chart_zero_collapsed_1j.png new file mode 100644 index 000000000..52c21eb31 Binary files /dev/null and b/test/goldens/screens/dashboard/goldens/macos/dashboard_portfolio_chart_zero_collapsed_1j.png differ diff --git a/test/goldens/screens/dashboard/goldens/macos/dashboard_portfolio_chart_zero_collapsed_1m.png b/test/goldens/screens/dashboard/goldens/macos/dashboard_portfolio_chart_zero_collapsed_1m.png new file mode 100644 index 000000000..2c5f6a4f5 Binary files /dev/null and b/test/goldens/screens/dashboard/goldens/macos/dashboard_portfolio_chart_zero_collapsed_1m.png differ diff --git a/test/goldens/screens/dashboard/goldens/macos/dashboard_portfolio_chart_zero_collapsed_1w.png b/test/goldens/screens/dashboard/goldens/macos/dashboard_portfolio_chart_zero_collapsed_1w.png new file mode 100644 index 000000000..a89874fc1 Binary files /dev/null and b/test/goldens/screens/dashboard/goldens/macos/dashboard_portfolio_chart_zero_collapsed_1w.png differ diff --git a/test/goldens/screens/dashboard/goldens/macos/dashboard_portfolio_chart_zero_collapsed_3m.png b/test/goldens/screens/dashboard/goldens/macos/dashboard_portfolio_chart_zero_collapsed_3m.png new file mode 100644 index 000000000..d3c0ea1e8 Binary files /dev/null and b/test/goldens/screens/dashboard/goldens/macos/dashboard_portfolio_chart_zero_collapsed_3m.png differ diff --git a/test/goldens/screens/dashboard/goldens/macos/handbook_persona_dca.png b/test/goldens/screens/dashboard/goldens/macos/handbook_persona_dca.png new file mode 100644 index 000000000..ee1c22e4b Binary files /dev/null and b/test/goldens/screens/dashboard/goldens/macos/handbook_persona_dca.png differ diff --git a/test/goldens/screens/dashboard/goldens/macos/handbook_persona_exit.png b/test/goldens/screens/dashboard/goldens/macos/handbook_persona_exit.png new file mode 100644 index 000000000..e4a5a2b37 Binary files /dev/null and b/test/goldens/screens/dashboard/goldens/macos/handbook_persona_exit.png differ diff --git a/test/goldens/screens/dashboard/goldens/macos/handbook_persona_exit_1j.png b/test/goldens/screens/dashboard/goldens/macos/handbook_persona_exit_1j.png new file mode 100644 index 000000000..9e87c4978 Binary files /dev/null and b/test/goldens/screens/dashboard/goldens/macos/handbook_persona_exit_1j.png differ diff --git a/test/goldens/screens/dashboard/goldens/macos/handbook_persona_lump.png b/test/goldens/screens/dashboard/goldens/macos/handbook_persona_lump.png new file mode 100644 index 000000000..0f089fe28 Binary files /dev/null and b/test/goldens/screens/dashboard/goldens/macos/handbook_persona_lump.png differ diff --git a/test/goldens/screens/dashboard/goldens/macos/handbook_persona_mix.png b/test/goldens/screens/dashboard/goldens/macos/handbook_persona_mix.png new file mode 100644 index 000000000..91b0e4e33 Binary files /dev/null and b/test/goldens/screens/dashboard/goldens/macos/handbook_persona_mix.png differ diff --git a/test/goldens/screens/dashboard/goldens/macos/handbook_persona_scale.png b/test/goldens/screens/dashboard/goldens/macos/handbook_persona_scale.png new file mode 100644 index 000000000..6aa7b2e2d Binary files /dev/null and b/test/goldens/screens/dashboard/goldens/macos/handbook_persona_scale.png differ diff --git a/test/goldens/screens/dashboard/handbook_persona_as_of_test.dart b/test/goldens/screens/dashboard/handbook_persona_as_of_test.dart new file mode 100644 index 000000000..d7d7d238e --- /dev/null +++ b/test/goldens/screens/dashboard/handbook_persona_as_of_test.dart @@ -0,0 +1,10 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'handbook_persona_fixtures.dart'; + +void main() { + test('handbook persona timestamps stay on 2026-08-28', () { + expect(personaDcaHistory().last.time, DateTime(2026, 8, 28)); + expect(personaDcaHistory()[11].time, DateTime(2026, 8, 23)); + }); +} diff --git a/test/goldens/screens/dashboard/handbook_persona_chart_golden_test.dart b/test/goldens/screens/dashboard/handbook_persona_chart_golden_test.dart new file mode 100644 index 000000000..ff3a8201e --- /dev/null +++ b/test/goldens/screens/dashboard/handbook_persona_chart_golden_test.dart @@ -0,0 +1,218 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/models/balance.dart'; +import 'package:realunit_wallet/models/portfolio_value_point.dart'; +import 'package:realunit_wallet/models/price_point.dart'; +import 'package:realunit_wallet/models/transaction.dart'; +import 'package:realunit_wallet/packages/config/api_config.dart'; +import 'package:realunit_wallet/packages/repository/transaction_repository.dart'; +import 'package:realunit_wallet/packages/service/app_store.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/transactions/dto/transactions_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_pdf_service.dart'; +import 'package:realunit_wallet/packages/utils/default_assets.dart'; +import 'package:realunit_wallet/screens/dashboard/bloc/balance_cubit.dart'; +import 'package:realunit_wallet/screens/dashboard/bloc/dashboard_bloc.dart'; +import 'package:realunit_wallet/screens/dashboard/bloc/pending_transactions_cubit.dart'; +import 'package:realunit_wallet/screens/dashboard/dashboard_page.dart'; +import 'package:realunit_wallet/screens/dashboard/widgets/time_period_selection_button.dart'; +import 'package:realunit_wallet/screens/settings/bloc/settings_bloc.dart'; +import 'package:realunit_wallet/styles/currency.dart'; + +import '../../../helper/helper.dart'; +import 'handbook_persona_fixtures.dart'; + +class _MockDashboardBloc extends MockBloc + implements DashboardBloc {} + +class _MockBalanceCubit extends MockCubit implements BalanceCubit {} + +class _MockPendingTransactionsCubit extends MockCubit> + implements PendingTransactionsCubit {} + +class _MockTransactionRepository extends Mock implements TransactionRepository {} + +class _MockRealUnitPdfService extends Mock implements RealUnitPdfService {} + +class _MockApiConfig extends Mock implements ApiConfig {} + +void main() { + const walletAddress = '0xcabd3f4b10a7089986e708d19140bfc98e5880c0'; + const counterparty = '0x00000000000000000000000000000000000000aa'; + + late _MockDashboardBloc dashboardBloc; + late _MockBalanceCubit balanceCubit; + late _MockPendingTransactionsCubit pendingTxCubit; + late MockSettingsBloc settingsBloc; + final transactionRepository = _MockTransactionRepository(); + + final price = BigInt.from(153); + final priceChart = [ + PricePoint(asset: realUnitAsset, price: BigInt.from(148), time: DateTime.utc(2025, 11)), + PricePoint(asset: realUnitAsset, price: BigInt.from(153), time: DateTime.utc(2026, 5)), + ]; + + Balance balanceFor(List history) { + final shares = history.isEmpty ? BigInt.zero : history.last.balance; + return Balance( + chainId: realUnitAsset.chainId, + contractAddress: realUnitAsset.address, + walletAddress: walletAddress, + balance: shares, + asset: realUnitAsset, + ); + } + + DashboardState dashboardState(List history) => + DashboardState( + price: price, + priceChart: priceChart, + portfolioHistory: history, + currency: Currency.chf, + ); + + setUpAll(() { + final getIt = GetIt.instance; + final apiConfig = _MockApiConfig(); + final appStore = MockAppStore(); + when(() => apiConfig.asset).thenReturn(realUnitAsset); + when(() => appStore.apiConfig).thenReturn(apiConfig); + when(() => appStore.primaryAddress).thenReturn(walletAddress); + getIt.registerSingleton(appStore); + getIt.registerSingleton(_MockRealUnitPdfService()); + getIt.registerSingleton(transactionRepository); + }); + + tearDownAll(() async => GetIt.instance.reset()); + + setUp(() { + dashboardBloc = _MockDashboardBloc(); + balanceCubit = _MockBalanceCubit(); + pendingTxCubit = _MockPendingTransactionsCubit(); + settingsBloc = MockSettingsBloc(); + when(() => pendingTxCubit.state).thenReturn(const []); + when(() => settingsBloc.state).thenReturn(const SettingsState()); + when(() => balanceCubit.asset).thenReturn(realUnitAsset); + when(() => transactionRepository.watchTransactionsOfAssets(any(), any(), any())) + .thenAnswer((_) => const Stream>.empty()); + }); + + Widget buildSubject() => MultiBlocProvider( + providers: [ + BlocProvider.value(value: settingsBloc), + BlocProvider.value(value: dashboardBloc), + BlocProvider.value(value: balanceCubit), + BlocProvider.value(value: pendingTxCubit), + ], + child: const DashboardView(), + ); + + /// Dashboard shows the three newest on-chain transfers under Bestand + /// (`watchTransfersOfAssetsLimit(..., 3)`), newest first. Zero-delta + /// hold points are not trades. + List latestDashboardTxs(List history) { + var prev = BigInt.zero; + final trades = []; + for (var i = 0; i < history.length; i++) { + final point = history[i]; + final delta = point.balance - prev; + prev = point.balance; + if (delta == BigInt.zero) continue; + final inbound = delta > BigInt.zero; + trades.add( + Transaction( + height: 100 + i, + txId: '0x${i.toRadixString(16).padLeft(8, '0')}', + chainId: 1, + senderAddress: inbound ? counterparty : walletAddress, + receiverAddress: inbound ? walletAddress : counterparty, + amount: delta.abs(), + asset: realUnitAsset, + type: TransactionTypes.tokenTransfer, + note: null, + data: null, + timestamp: point.time, + ), + ); + } + return trades.reversed.take(3).toList(); + } + + void stubHistory(List history) { + when(() => dashboardBloc.state).thenReturn(dashboardState(history)); + when(() => balanceCubit.state).thenReturn(balanceFor(history)); + when(() => transactionRepository.watchTransactionsOfAssets(any(), any(), any())) + .thenAnswer((_) => Stream.value(latestDashboardTxs(history))); + } + + group('handbook personas', () { + goldenTest( + 'K1 Monatskäufer — MAX staircase of twelve equal buys', + fileName: 'handbook_persona_dca', + constraints: phoneConstraints, + builder: () { + stubHistory(personaDcaHistory()); + return wrapForGolden(buildSubject()); + }, + ); + + goldenTest( + 'K2 Einmalkauf — MAX lump then small top-ups', + fileName: 'handbook_persona_lump', + constraints: phoneConstraints, + builder: () { + stubHistory(personaLumpHistory()); + return wrapForGolden(buildSubject()); + }, + ); + + goldenTest( + 'K3 Verkauf auf 0 — MAX rise then drop to a visible zero line', + fileName: 'handbook_persona_exit', + constraints: phoneConstraints, + builder: () { + stubHistory(personaExitHistory()); + return wrapForGolden(buildSubject()); + }, + ); + + goldenTest( + 'K3 Verkauf auf 0 — 1J still shows the sell-down inside the year', + fileName: 'handbook_persona_exit_1j', + constraints: phoneConstraints, + whilePerforming: (tester) async { + await tester.tap(find.widgetWithText(TimePeriodSelectionButton, '1J')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + return null; + }, + builder: () { + stubHistory(personaExitHistory()); + return wrapForGolden(buildSubject()); + }, + ); + + goldenTest( + 'K4 Mix — MAX interleaved buys and partial sells', + fileName: 'handbook_persona_mix', + constraints: phoneConstraints, + builder: () { + stubHistory(personaMixHistory()); + return wrapForGolden(buildSubject()); + }, + ); + + goldenTest( + 'K5 Aufstocken — MAX rising buys', + fileName: 'handbook_persona_scale', + constraints: phoneConstraints, + builder: () { + stubHistory(personaScaleHistory()); + return wrapForGolden(buildSubject()); + }, + ); + }); +} diff --git a/test/goldens/screens/dashboard/handbook_persona_fixtures.dart b/test/goldens/screens/dashboard/handbook_persona_fixtures.dart new file mode 100644 index 000000000..7fe05fa0c --- /dev/null +++ b/test/goldens/screens/dashboard/handbook_persona_fixtures.dart @@ -0,0 +1,121 @@ +import 'dart:math' as math; + +import 'package:realunit_wallet/models/portfolio_value_point.dart'; + +/// Frozen handbook personas — synthetic, not live accounts. +/// +/// Each history has ≥10 balance changes inside a 1-year window. Times are +/// local midnight so MAX (first/last fixture) and 1J (`DateTime.now()` +/// window) stay pixel-stable. +/// +/// Chart Y is mark-to-market CHF (shares × REALU price at that date), not +/// share count. The spot price path ends at 153 rappen so the last point +/// matches `balance * DashboardState.price`. +/// +/// Trade-table labels are derived from these timestamps. They must not +/// follow `DateTime.now()` or the goldens go red at midnight. +DateTime _today() => DateTime(2026, 8, 28); + +/// REALU price in rappen. 153 today, ~118 a year ago, with a mid-year +/// peak and dip so a flat holding is not a straight line. +int _priceRappen(int daysAgo) { + final t = daysAgo / 360.0; + final trend = 153 - 35 * t; + final wave = 28 * math.sin(t * 2 * math.pi); + return (trend + wave).round().clamp(90, 200); +} + +PortfolioValuePoint _pt(int daysAgo, int shares) { + return PortfolioValuePoint( + value: BigInt.from(shares * _priceRappen(daysAgo)), + balance: BigInt.from(shares), + time: _today().subtract(Duration(days: daysAgo)), + ); +} + +/// K1 Monatskäufer — twelve equal +80 REALU buys, still holding 960. +List personaDcaHistory() => [ + _pt(335, 80), + _pt(305, 160), + _pt(275, 240), + _pt(245, 320), + _pt(215, 400), + _pt(185, 480), + _pt(155, 560), + _pt(125, 640), + _pt(95, 720), + _pt(65, 800), + _pt(35, 880), + _pt(5, 960), + _pt(0, 960), + ]; + +/// K2 Einmalkauf — one +5000 lump, then nine +50 top-ups, holding 5450. +List personaLumpHistory() => [ + _pt(350, 0), + _pt(340, 5000), + _pt(300, 5050), + _pt(270, 5100), + _pt(240, 5150), + _pt(210, 5200), + _pt(180, 5250), + _pt(150, 5300), + _pt(120, 5350), + _pt(90, 5400), + _pt(30, 5450), + _pt(0, 5450), + ]; + +/// K3 Verkauf auf 0 — eight buys then four sells to zero. +/// 1J still contains the rise; 1W is a flat zero line. +List personaExitHistory() => [ + _pt(320, 200), + _pt(290, 400), + _pt(260, 550), + _pt(230, 700), + _pt(200, 800), + _pt(170, 900), + _pt(140, 980), + _pt(110, 1060), + _pt(80, 860), + _pt(50, 560), + _pt(25, 260), + _pt(10, 0), + _pt(6, 0), + _pt(1, 0), + _pt(0, 0), + ]; + +/// K4 Mix — interleaved buys and partial sells, holding 600. +List personaMixHistory() => [ + _pt(330, 150), + _pt(300, 300), + _pt(270, 250), + _pt(240, 450), + _pt(210, 550), + _pt(180, 450), + _pt(150, 530), + _pt(120, 610), + _pt(90, 550), + _pt(60, 590), + _pt(30, 630), + _pt(5, 600), + _pt(0, 600), + ]; + +/// K5 Aufstocken — twelve rising buys, holding 1800. +List personaScaleHistory() => [ + _pt(335, 40), + _pt(305, 100), + _pt(275, 180), + _pt(245, 280), + _pt(215, 400), + _pt(185, 540), + _pt(155, 700), + _pt(125, 880), + _pt(95, 1080), + _pt(65, 1300), + _pt(35, 1540), + _pt(5, 1800), + _pt(0, 1800), + ]; diff --git a/test/goldens/screens/pay/goldens/macos/pay_info_page.png b/test/goldens/screens/pay/goldens/macos/pay_info_page.png new file mode 100644 index 000000000..2b9aaf9a9 Binary files /dev/null and b/test/goldens/screens/pay/goldens/macos/pay_info_page.png differ diff --git a/test/goldens/screens/pay/pay_info_golden_test.dart b/test/goldens/screens/pay/pay_info_golden_test.dart new file mode 100644 index 000000000..fd47c0d40 --- /dev/null +++ b/test/goldens/screens/pay/pay_info_golden_test.dart @@ -0,0 +1,12 @@ +import 'package:realunit_wallet/screens/pay/pay_info_page.dart'; + +import '../../../helper/helper.dart'; + +void main() { + goldenTest( + 'pay info disclosure', + fileName: 'pay_info_page', + constraints: phoneConstraints, + builder: () => wrapForGolden(const PayInfoPage()), + ); +} diff --git a/test/goldens/screens/receive/goldens/macos/receive_page_default.png b/test/goldens/screens/receive/goldens/macos/receive_page_default.png index 5333c0ada..4120ef18b 100644 Binary files a/test/goldens/screens/receive/goldens/macos/receive_page_default.png and b/test/goldens/screens/receive/goldens/macos/receive_page_default.png differ diff --git a/test/goldens/screens/receive/goldens/macos/receive_page_full_page.png b/test/goldens/screens/receive/goldens/macos/receive_page_full_page.png index ab6ae0bd2..8fb913a67 100644 Binary files a/test/goldens/screens/receive/goldens/macos/receive_page_full_page.png and b/test/goldens/screens/receive/goldens/macos/receive_page_full_page.png differ diff --git a/test/goldens/screens/sell_bitbox/sell_bitbox_states_golden_test.dart b/test/goldens/screens/sell_bitbox/sell_bitbox_states_golden_test.dart index ee6863507..cf0b2fdc7 100644 --- a/test/goldens/screens/sell_bitbox/sell_bitbox_states_golden_test.dart +++ b/test/goldens/screens/sell_bitbox/sell_bitbox_states_golden_test.dart @@ -13,7 +13,7 @@ import 'package:realunit_wallet/styles/currency.dart'; import '../../../helper/helper.dart'; -// SellBitboxCubit fires `scheduleMicrotask(_checkEthBalance)` in its constructor +// SellBitboxCubit.start() schedules `_checkEthBalance` via scheduleMicrotask // and installs a periodic ETH-polling Timer, so the real page can't be pumped // deterministically (learned in #815). We render the shared [SellBitboxView] // with a mocked cubit whose `state` getter is stubbed to the exact target diff --git a/test/goldens/screens/send/goldens/macos/send_info_page.png b/test/goldens/screens/send/goldens/macos/send_info_page.png new file mode 100644 index 000000000..d4ef118ab Binary files /dev/null and b/test/goldens/screens/send/goldens/macos/send_info_page.png differ diff --git a/test/goldens/screens/send/goldens/macos/send_process_page_signing.png b/test/goldens/screens/send/goldens/macos/send_process_page_signing.png index fc553f688..b16b524b3 100644 Binary files a/test/goldens/screens/send/goldens/macos/send_process_page_signing.png and b/test/goldens/screens/send/goldens/macos/send_process_page_signing.png differ diff --git a/test/goldens/screens/send/send_golden_test.dart b/test/goldens/screens/send/send_golden_test.dart index 71c63cbff..fe1349233 100644 --- a/test/goldens/screens/send/send_golden_test.dart +++ b/test/goldens/screens/send/send_golden_test.dart @@ -10,6 +10,7 @@ import 'package:realunit_wallet/screens/send/cubits/send_process/send_process_cu import 'package:realunit_wallet/screens/send/cubits/send_recipient/send_recipient_cubit.dart'; import 'package:realunit_wallet/screens/send/send_amount_page.dart'; import 'package:realunit_wallet/screens/send/send_confirm_page.dart'; +import 'package:realunit_wallet/screens/send/send_info_page.dart'; import 'package:realunit_wallet/screens/send/send_process_page.dart'; import 'package:realunit_wallet/screens/send/send_recipient_page.dart'; @@ -37,6 +38,15 @@ void main() { stubMobileScannerChannel(); }); + group('$SendInfoPage', () { + goldenTest( + 'shareholder transfer disclosure', + fileName: 'send_info_page', + constraints: phoneConstraints, + builder: () => wrapForGolden(const SendInfoPage()), + ); + }); + group('$SendRecipientView', () { late _MockSendRecipientCubit recipientCubit; diff --git a/test/goldens/screens/settings_wallet_address/goldens/macos/settings_wallet_address_page_default.png b/test/goldens/screens/settings_wallet_address/goldens/macos/settings_wallet_address_page_default.png index 135a1226c..5f550679d 100644 Binary files a/test/goldens/screens/settings_wallet_address/goldens/macos/settings_wallet_address_page_default.png and b/test/goldens/screens/settings_wallet_address/goldens/macos/settings_wallet_address_page_default.png differ diff --git a/test/goldens/screens/settings_wallet_address/settings_wallet_address_golden_test.dart b/test/goldens/screens/settings_wallet_address/settings_wallet_address_golden_test.dart index 15bc461d8..240a6363d 100644 --- a/test/goldens/screens/settings_wallet_address/settings_wallet_address_golden_test.dart +++ b/test/goldens/screens/settings_wallet_address/settings_wallet_address_golden_test.dart @@ -7,7 +7,6 @@ import 'package:realunit_wallet/screens/settings_wallet_address/settings_wallet_ import '../../../helper/helper.dart'; void main() { - final MockAppStore appStore = MockAppStore(); setUp(() { diff --git a/test/helper/helper.dart b/test/helper/helper.dart index e94830116..917a06a82 100644 --- a/test/helper/helper.dart +++ b/test/helper/helper.dart @@ -8,3 +8,5 @@ export 'pump_app.dart'; export 'pump_golden_app.dart'; export 'responsive_matrix.dart'; export 'responsive_surface_catalog.dart'; +export 'scanner_navigation_catalog.dart'; +export 'timed_material_page_route.dart'; diff --git a/test/helper/responsive_surface_catalog.dart b/test/helper/responsive_surface_catalog.dart index 8c35aa01c..a8341da25 100644 --- a/test/helper/responsive_surface_catalog.dart +++ b/test/helper/responsive_surface_catalog.dart @@ -144,6 +144,13 @@ const kResponsiveSurfaceCatalog = [ productionPath: 'lib/screens/settings_user_data/subpages/edit_address/settings_edit_address_page.dart', ), + ResponsiveSurface( + id: 'settings_wallet_address_page', + description: 'Wallet address QR page (sticky Send CTA)', + matrixTestPath: + 'test/screens/settings_wallet_address/settings_wallet_address_responsive_matrix_test.dart', + productionPath: 'lib/screens/settings_wallet_address/settings_wallet_address_page.dart', + ), ResponsiveSurface( id: 'settings_edit_name_page', description: 'Settings edit name form', @@ -200,6 +207,18 @@ const kResponsiveSurfaceCatalog = [ matrixTestPath: 'test/screens/pay/pay_quote_responsive_matrix_test.dart', productionPath: 'lib/screens/pay/pay_quote_page.dart', ), + ResponsiveSurface( + id: 'pay_info_page', + description: 'Pay info disclosure (Continue CTA)', + matrixTestPath: 'test/screens/pay/pay_info_responsive_matrix_test.dart', + productionPath: 'lib/screens/pay/pay_info_page.dart', + ), + ResponsiveSurface( + id: 'receive_page', + description: 'Receive QR page (sticky Send CTA)', + matrixTestPath: 'test/screens/receive/receive_responsive_matrix_test.dart', + productionPath: 'lib/screens/receive/receive_page.dart', + ), ResponsiveSurface( id: 'sell_page', description: 'Sell page (primary CTA)', @@ -212,6 +231,12 @@ const kResponsiveSurfaceCatalog = [ matrixTestPath: 'test/screens/send/send_amount_responsive_matrix_test.dart', productionPath: 'lib/screens/send/send_amount_page.dart', ), + ResponsiveSurface( + id: 'send_info_page', + description: 'Send info disclosure (Continue CTA)', + matrixTestPath: 'test/screens/send/send_info_responsive_matrix_test.dart', + productionPath: 'lib/screens/send/send_info_page.dart', + ), ResponsiveSurface( id: 'send_confirm_page', description: 'Send confirmation page (confirm CTA)', @@ -273,7 +298,7 @@ const kResponsiveSurfaceCatalog = [ matrixTestPath: 'test/screens/pin/pin_sheets_responsive_matrix_test.dart', productionPath: 'lib/screens/pin/widgets/enable_biometric_bottom_sheet.dart', ), - // Migration covers 36 surfaces total (bitbox_connect_sheet + 35 above). No + // Migration covers 40 surfaces total (bitbox_connect_sheet + 39 above). No // further known candidates remain from the prior sweep. welcome_page was // reviewed and found safe (scrolls end-to-end, no separate sticky CTA) — not // a migration candidate. Not exhaustive — review responsibility for every diff --git a/test/helper/scanner_navigation_catalog.dart b/test/helper/scanner_navigation_catalog.dart new file mode 100644 index 000000000..d4bf815b6 --- /dev/null +++ b/test/helper/scanner_navigation_catalog.dart @@ -0,0 +1,50 @@ +/// Catalog of production surfaces that construct [QrScannerView] and navigate +/// from a scan result. Adding a new scanner consumer without a catalog entry, +/// [pushThenRearm], and a real-cubit double-capture regression test is a +/// review-blocking gap. +/// +/// Unlike the sticky-CTA catalog, the self-test in +/// `scanner_navigation_catalog_test.dart` also **discovers** every +/// `QrScannerView(` under `lib/` and fails if any consumer is missing here. +library; + +/// One scanner surface under the push-then-rearm navigation contract. +class ScannerNavigationSurface { + const ScannerNavigationSurface({ + required this.id, + required this.description, + required this.productionPath, + required this.regressionTestPath, + required this.destinationWidgetName, + }); + + final String id; + final String description; + + /// Path under `lib/` of the production file that constructs [QrScannerView]. + final String productionPath; + + /// Path under `test/` of the real-cubit double-capture regression test. + final String regressionTestPath; + + /// Public widget type the regression test must `findsOne` (e.g. `SendAmountView`). + final String destinationWidgetName; +} + +/// Living catalog — extend when adding a new [QrScannerView] consumer. +const kScannerNavigationCatalog = [ + ScannerNavigationSurface( + id: 'send_recipient', + description: 'Wallet-to-wallet send recipient scan', + productionPath: 'lib/screens/send/send_recipient_page.dart', + regressionTestPath: 'test/screens/send/send_recipient_scanner_navigation_test.dart', + destinationWidgetName: 'SendAmountView', + ), + ScannerNavigationSurface( + id: 'pay_scan', + description: 'Open CryptoPay scan', + productionPath: 'lib/screens/pay/pay_scan_page.dart', + regressionTestPath: 'test/screens/pay/pay_scan_scanner_navigation_test.dart', + destinationWidgetName: 'PayQuoteView', + ), +]; diff --git a/test/helper/scanner_navigation_catalog_test.dart b/test/helper/scanner_navigation_catalog_test.dart new file mode 100644 index 000000000..abda29abd --- /dev/null +++ b/test/helper/scanner_navigation_catalog_test.dart @@ -0,0 +1,130 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'scanner_navigation_catalog.dart'; + +/// Widget definition file — constructs [QrScannerView] for others; not a +/// navigation consumer. +const _qrScannerViewDefinitionPath = 'lib/widgets/scanner/qr_scanner_view.dart'; + +void main() { + test('every catalogued production and regression path exists on disk', () { + for (final surface in kScannerNavigationCatalog) { + expect( + File(surface.productionPath).existsSync(), + isTrue, + reason: + 'Surface "${surface.id}" lists production file ' + '${surface.productionPath} but the file is missing', + ); + expect( + File(surface.regressionTestPath).existsSync(), + isTrue, + reason: + 'Surface "${surface.id}" lists regression test ' + '${surface.regressionTestPath} but the file is missing', + ); + } + }); + + test( + 'every catalogued production file constructs QrScannerView and uses pushThenRearm', + () { + for (final surface in kScannerNavigationCatalog) { + final contents = File(surface.productionPath).readAsStringSync(); + expect( + contents.contains('QrScannerView('), + isTrue, + reason: + 'Surface "${surface.id}" (${surface.productionPath}) no longer ' + 'constructs QrScannerView — remove it from the catalog or restore ' + 'the scanner', + ); + expect( + contents.contains('pushThenRearm('), + isTrue, + reason: + 'Surface "${surface.id}" (${surface.productionPath}) constructs ' + 'QrScannerView but no longer calls pushThenRearm( — the ' + 'scanner-navigation contract regressed', + ); + } + }, + ); + + test( + 'every catalogued regression test fires BarcodeCapture and asserts a single destination', + () { + for (final surface in kScannerNavigationCatalog) { + final contents = File(surface.regressionTestPath).readAsStringSync(); + expect( + contents.contains('BarcodeCapture'), + isTrue, + reason: + 'Surface "${surface.id}" regression test ' + '(${surface.regressionTestPath}) must fire BarcodeCapture', + ); + expect( + contents.contains(surface.destinationWidgetName), + isTrue, + reason: + 'Surface "${surface.id}" regression test ' + '(${surface.regressionTestPath}) must assert ' + '${surface.destinationWidgetName}', + ); + expect( + contents.contains('findsOne'), + isTrue, + reason: + 'Surface "${surface.id}" regression test ' + '(${surface.regressionTestPath}) must assert findsOne on the ' + 'destination (double-push gate)', + ); + } + }, + ); + + test('catalog is non-empty (scanner-navigation gate is active)', () { + expect(kScannerNavigationCatalog, isNotEmpty); + }); + + test( + 'every QrScannerView( under lib/ is catalogued (discovery)', + () { + final catalogued = { + for (final surface in kScannerNavigationCatalog) surface.productionPath, + }; + final unlisted = []; + + for (final entity in Directory('lib').listSync(recursive: true)) { + if (entity is! File || !entity.path.endsWith('.dart')) { + continue; + } + // Normalize to forward-slash repo-relative path (macOS/Linux cwd = + // package root under `flutter test`). + final path = entity.path.replaceAll(r'\', '/'); + if (path == _qrScannerViewDefinitionPath) { + continue; + } + final contents = entity.readAsStringSync(); + if (!contents.contains('QrScannerView(')) { + continue; + } + if (!catalogued.contains(path)) { + unlisted.add(path); + } + } + + expect( + unlisted, + isEmpty, + reason: + 'QrScannerView( consumers under lib/ missing from ' + 'kScannerNavigationCatalog:\n${unlisted.join('\n')}\n' + 'Add a catalog entry, use pushThenRearm, and add a real-cubit ' + 'double-capture regression test', + ); + }, + ); +} diff --git a/test/helper/timed_material_page_route.dart b/test/helper/timed_material_page_route.dart new file mode 100644 index 000000000..e015b0b1e --- /dev/null +++ b/test/helper/timed_material_page_route.dart @@ -0,0 +1,15 @@ +import 'package:flutter/material.dart'; + +/// [MaterialPageRoute] does not expose [transitionDuration] as a constructor +/// argument; override it so regression tests can drive a known slide duration. +class TimedMaterialPageRoute extends MaterialPageRoute { + TimedMaterialPageRoute({ + required super.builder, + required Duration transitionDuration, + }) : _transitionDuration = transitionDuration; + + final Duration _transitionDuration; + + @override + Duration get transitionDuration => _transitionDuration; +} diff --git a/test/integration/bitbox_reconnect_recovery_test.dart b/test/integration/bitbox_reconnect_recovery_test.dart index ed8e9edc2..e403d255a 100644 --- a/test/integration/bitbox_reconnect_recovery_test.dart +++ b/test/integration/bitbox_reconnect_recovery_test.dart @@ -20,8 +20,8 @@ // `SellBitboxEthReady` (or the faucet/error branch) accordingly. // // Tests run inside `fakeAsync` so the observer's periodic timer and the -// cubit's microtask-based `_checkEthBalance` are both bound to the same -// virtual clock — no wallclock-sleeps, deterministic tick counting. +// cubit's start()-scheduled `_checkEthBalance` microtask are both bound to +// the same virtual clock — no wallclock-sleeps, deterministic tick counting. import 'dart:typed_data'; @@ -277,9 +277,9 @@ void main() { blockchainService: blockchain, sellService: sellService, appStore: appStore, - ); + )..start(); - // The constructor schedules `_checkEthBalance` via scheduleMicrotask. + // start() schedules `_checkEthBalance` via scheduleMicrotask. async.flushMicrotasks(); expect( cubit.state, diff --git a/test/integration/sell_bitbox_flow_test.dart b/test/integration/sell_bitbox_flow_test.dart index c721f8832..8cfac045e 100644 --- a/test/integration/sell_bitbox_flow_test.dart +++ b/test/integration/sell_bitbox_flow_test.dart @@ -20,8 +20,10 @@ // They run headless (no device, no simulator), so they live under // `test/integration/` and run as part of `flutter test`. +import 'dart:async'; import 'dart:convert'; +import 'package:fake_async/fake_async.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; @@ -162,10 +164,10 @@ void main() { blockchainService: blockchain, sellService: sellService, appStore: appStore, - ); + )..start(); - // Waits for the constructor's first non-Checking emit and asserts it is - // EthReady — the precondition every flow test in this file shares. + // Waits for start()'s first non-Checking emit and asserts it is EthReady — + // the precondition every flow test in this file shares. Future settleToEthReady(SellBitboxCubit cubit) async { final state = await cubit.stream.firstWhere((s) => s is! SellBitboxCheckingEth); expect( @@ -413,5 +415,146 @@ void main() { await cubit.close(); }, ); + + // Pins the low-ETH path this file's other happy path skips: ethBalance + // below requiredGasEth → DFX API faucet → balance poll → full sell. + // The faucet HTTP response is held behind a Completer so the test can + // prove the cubit awaits requestFaucet() before installing the 5s poll. + test( + 'happy: faucet hop then full swap + deposit when ethBalance < requiredGasEth', + () { + fakeAsync((async) { + void drain() { + for (var i = 0; i < 40; i++) { + async.flushMicrotasks(); + async.elapse(Duration.zero); + } + } + + var faucetCalls = 0; + String? faucetPath; + var faucetCompleted = false; + final faucetResponse = Completer(); + var balancesCalls = 0; + Map? balancesBody; + var unsignedCalls = 0; + var broadcastCalls = 0; + var confirmCalls = 0; + final broadcastBodies = >[]; + + final client = MockClient((request) async { + final path = request.url.path; + if (request.method == 'POST' && path == '/v1/faucet') { + faucetCalls++; + faucetPath = path; + return faucetResponse.future; + } + if (request.method == 'POST' && path == '/v1/blockchain/balances') { + balancesCalls++; + if (!faucetCompleted) { + fail('balances before faucet completed'); + } + balancesBody = jsonDecode(request.body) as Map; + return http.Response( + jsonEncode({ + 'balances': [ + {'balance': 0.001}, + ], + }), + 200, + ); + } + if (path.endsWith('/unsigned-transactions')) { + if (!faucetCompleted) { + fail('unsigned-transactions before faucet completed'); + } + unsignedCalls++; + return http.Response( + jsonEncode({'swap': _rawSwap, 'deposit': _rawDeposit}), + 200, + ); + } + if (path.endsWith('/broadcast')) { + if (!faucetCompleted) { + fail('broadcast before faucet completed'); + } + broadcastCalls++; + broadcastBodies.add(jsonDecode(request.body) as Map); + return http.Response(jsonEncode({'txHash': '0xtx-$broadcastCalls'}), 200); + } + if (path.endsWith('/confirm')) { + if (!faucetCompleted) { + fail('confirm before faucet completed'); + } + confirmCalls++; + return http.Response('{}', 200); + } + fail('unexpected request: ${request.url}'); + }); + + when(() => appStore.httpClient).thenReturn(client); + final sellService = RealUnitSellPaymentInfoService(appStore, walletService); + final realFaucet = DfxFaucetService(appStore, walletService); + final realBlockchain = DfxBlockchainApiService(appStore, walletService); + + final cubit = SellBitboxCubit( + paymentInfo: _info(ethBalance: 0), + faucetService: realFaucet, + blockchainService: realBlockchain, + sellService: sellService, + appStore: appStore, + )..start(); + + // Faucet handler entered; cubit still awaiting; poll timer not installed. + drain(); + expect(faucetCalls, 1); + expect(faucetPath, '/v1/faucet'); + expect(cubit.state, isA()); + + // Elapsing 5s while faucet is still open must NOT hit balances — + // that would mean the poll timer was started before requestFaucet returned. + async.elapse(const Duration(seconds: 5)); + drain(); + expect(balancesCalls, 0); + + faucetCompleted = true; + faucetResponse.complete( + http.Response( + jsonEncode({'txId': '0xfaucet', 'amount': 0.01}), + 200, + ), + ); + drain(); + expect(cubit.state, isA()); + + async.elapse(const Duration(seconds: 5)); + drain(); + + expect(cubit.state, isA()); + expect(balancesCalls, greaterThanOrEqualTo(1)); + expect(balancesBody!['address'], appStore.primaryAddress); + expect(balancesBody!['blockchain'], 'Ethereum'); + expect(balancesBody!['assetIds'], contains(111)); + + cubit.proceedToSwap(); + drain(); + cubit.confirmSwap(); + drain(); + cubit.confirmDeposit(); + drain(); + + expect(cubit.state, isA()); + expect(creds.signCallCount, 2); + expect(unsignedCalls, 1); + expect(broadcastCalls, 2); + expect(confirmCalls, 1); + expect(broadcastBodies[0]['unsignedTx'], _rawSwap); + expect(broadcastBodies[1]['unsignedTx'], _rawDeposit); + + cubit.close(); + async.flushTimers(); + }); + }, + ); }); } diff --git a/test/packages/repository/settings_repository_test.dart b/test/packages/repository/settings_repository_test.dart index 216285525..7745d0fb0 100644 --- a/test/packages/repository/settings_repository_test.dart +++ b/test/packages/repository/settings_repository_test.dart @@ -66,11 +66,12 @@ void main() { }); group('currency', () { - test('defaults to CHF when no value is stored', () async { + test('defaults to EUR when nothing is stored', () async { SharedPreferences.setMockInitialValues({}); final repo = SettingsRepository(await SharedPreferences.getInstance()); - expect(repo.currency, 'CHF'); + expect(repo.currency, 'EUR'); + expect(repo.hasStoredCurrency, isFalse); }); test('returns the stored currency when set', () async { @@ -78,6 +79,7 @@ void main() { final repo = SettingsRepository(await SharedPreferences.getInstance()); expect(repo.currency, 'EUR'); + expect(repo.hasStoredCurrency, isTrue); }); test('currency setter persists', () async { diff --git a/test/packages/service/dfx/dfx_brokerbot_service_test.dart b/test/packages/service/dfx/dfx_brokerbot_service_test.dart index 0fff8002a..8ad9ac948 100644 --- a/test/packages/service/dfx/dfx_brokerbot_service_test.dart +++ b/test/packages/service/dfx/dfx_brokerbot_service_test.dart @@ -131,22 +131,40 @@ void main() { expect(uri!.queryParameters['currency'], 'EUR'); }); - test('normalises a comma decimal separator before parsing (300,75 → amount=300.75)', () async { - // The fiat converter field allows a comma; without normalization - // `double.tryParse("300,75")` is null and the converter fails silently. + test( + 'normalises a comma decimal separator before parsing (300,75 → amount=300.75)', + () async { + // The fiat converter field allows a comma; without normalization + // `double.tryParse("300,75")` is null and the converter fails silently. + Uri? uri; + final client = MockClient((request) async { + uri = request.url; + return http.Response( + jsonEncode({'shares': 3, 'pricePerShare': 100.25, 'availableShares': 100}), + 200, + ); + }); + + final shares = await build(client).getBuyShares('300,75', Currency.chf); + + expect(shares.shares, 3); + expect(uri!.queryParameters['amount'], '300.75'); + }, + ); + + test('reads 105.000 as one hundred five thousand, not 105 francs', () async { Uri? uri; final client = MockClient((request) async { uri = request.url; return http.Response( - jsonEncode({'shares': 3, 'pricePerShare': 100.25, 'availableShares': 100}), + jsonEncode({'shares': 76086, 'pricePerShare': 1.38, 'availableShares': 76766}), 200, ); }); - final shares = await build(client).getBuyShares('300,75', Currency.chf); + await build(client).getBuyShares('105.000', Currency.chf); - expect(shares.shares, 3); - expect(uri!.queryParameters['amount'], '300.75'); + expect(double.parse(uri!.queryParameters['amount']!), 105000); }); test('throws for non-numeric / zero / negative amount input', () { @@ -265,27 +283,30 @@ void main() { ); }); - test('normalises a comma decimal separator before parsing (300,75 → amount=300.75)', () async { - sessionCache.setAuthToken('jwt-2'); - Uri? uri; - final client = MockClient((request) async { - uri = request.url; - return http.Response( - jsonEncode({ - 'targetAmount': 300.75, - 'shares': 3, - 'pricePerShare': 100.25, - 'currency': 'CHF', - }), - 200, - ); - }); - - final shares = await build(client).getSellShares('300,75', Currency.chf); - - expect(shares.shares, 3); - expect(uri!.queryParameters['amount'], '300.75'); - }); + test( + 'normalises a comma decimal separator before parsing (300,75 → amount=300.75)', + () async { + sessionCache.setAuthToken('jwt-2'); + Uri? uri; + final client = MockClient((request) async { + uri = request.url; + return http.Response( + jsonEncode({ + 'targetAmount': 300.75, + 'shares': 3, + 'pricePerShare': 100.25, + 'currency': 'CHF', + }), + 200, + ); + }); + + final shares = await build(client).getSellShares('300,75', Currency.chf); + + expect(shares.shares, 3); + expect(uri!.queryParameters['amount'], '300.75'); + }, + ); test('throws when amount is invalid (before any HTTP call)', () async { var called = false; diff --git a/test/packages/service/dfx/exceptions/api_exception_test.dart b/test/packages/service/dfx/exceptions/api_exception_test.dart index 586d5a981..b1cd15991 100644 --- a/test/packages/service/dfx/exceptions/api_exception_test.dart +++ b/test/packages/service/dfx/exceptions/api_exception_test.dart @@ -110,6 +110,58 @@ void main() { }); }); + group('fromBody', () { + test('JSON object body matches fromJson including KYC subclass', () { + final exception = ApiException.fromBody( + '{"statusCode":403,"code":"KYC_LEVEL_REQUIRED","message":"KYC level too low","requiredLevel":30,"currentLevel":20}', + httpStatusCode: 403, + ); + + expect(exception, isA()); + expect(exception.statusCode, 403); + expect(exception.code, 'KYC_LEVEL_REQUIRED'); + expect(exception.message, 'KYC level too low'); + final kyc = exception as KycLevelRequiredException; + expect(kyc.requiredLevel, 30); + expect(kyc.currentLevel, 20); + }); + + test('plain-text 502 body yields empty-message ApiException', () { + final exception = ApiException.fromBody( + 'error code: 502', + httpStatusCode: 502, + ); + + expect(exception, isA()); + expect(exception, isNot(isA())); + expect(exception.statusCode, 502); + expect(exception.code, 'UNKNOWN'); + expect(exception.message, ''); + }); + + test('HTML 502 body yields empty-message ApiException', () { + final exception = ApiException.fromBody( + 'bad gateway', + httpStatusCode: 502, + ); + + expect(exception.statusCode, 502); + expect(exception.code, 'UNKNOWN'); + expect(exception.message, ''); + }); + + test('JSON array body yields empty-message ApiException', () { + final exception = ApiException.fromBody( + '[1]', + httpStatusCode: 500, + ); + + expect(exception.statusCode, 500); + expect(exception.code, 'UNKNOWN'); + expect(exception.message, ''); + }); + }); + group('userFacingMessage', () { test('returns ApiException.message 1:1', () { const error = ApiException( diff --git a/test/packages/service/dfx/exceptions/exception_surface_test.dart b/test/packages/service/dfx/exceptions/exception_surface_test.dart index 55387f3cb..eec5cd4de 100644 --- a/test/packages/service/dfx/exceptions/exception_surface_test.dart +++ b/test/packages/service/dfx/exceptions/exception_surface_test.dart @@ -42,6 +42,11 @@ void main() { const TransferGasFundingUnavailableException(), const TransferConfirmMismatchException(), const TransferAlreadyConfirmedException(code: 'TEST', message: 'test'), + const TransferReceiptTimeoutException( + code: 'TEST', + message: 'test', + txHash: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + ), ]; for (final ex in exceptions) { diff --git a/test/packages/service/dfx/models/payment/buy_sell_dtos_test.dart b/test/packages/service/dfx/models/payment/buy_sell_dtos_test.dart index 7d8889c71..8559822e5 100644 --- a/test/packages/service/dfx/models/payment/buy_sell_dtos_test.dart +++ b/test/packages/service/dfx/models/payment/buy_sell_dtos_test.dart @@ -108,6 +108,7 @@ void main() { expect(dto.id, 1); expect(dto.routeId, 2); expect(dto.timestamp, DateTime.utc(2026, 5, 15, 10)); + expect(dto.iban, 'CH...'); expect(dto.amount, 100.5); expect(dto.currency, Currency.chf); expect(dto.fees.total, 2.0); @@ -119,6 +120,17 @@ void main() { expect(dto.priceSteps, isEmpty); }); + test('parses the EUR settlement IBAN and currency from the wire', () { + final json = baseJson() + ..['iban'] = 'CH9708307000560946317' + ..['currency'] = 'EUR'; + + final dto = RealUnitBuyPaymentInfoDto.fromJson(json); + + expect(dto.iban, 'CH9708307000560946317'); + expect(dto.currency, Currency.eur); + }); + test('keeps optional fields null when the wire sends null', () { final json = baseJson() ..['minVolume'] = null @@ -182,9 +194,9 @@ void main() { }); group('$PaymentInfoError', () { - test('has the eight documented variants', () { + test('has the nine documented variants', () { // Pin the wire contract — any new variant has to be added intentionally. - expect(PaymentInfoError.values, hasLength(8)); + expect(PaymentInfoError.values, hasLength(9)); expect( PaymentInfoError.values.toSet(), { @@ -193,6 +205,7 @@ void main() { PaymentInfoError.primaryEmailRequired, PaymentInfoError.primaryEmailNotConfirmed, PaymentInfoError.minAmountNotMet, + PaymentInfoError.maxAmountExceeded, PaymentInfoError.bitboxDisconnected, PaymentInfoError.priceSourceUnavailable, PaymentInfoError.unknown, diff --git a/test/packages/service/dfx/models/user/dto/user_dto_test.dart b/test/packages/service/dfx/models/user/dto/user_dto_test.dart index 0aed23305..45f682a2e 100644 --- a/test/packages/service/dfx/models/user/dto/user_dto_test.dart +++ b/test/packages/service/dfx/models/user/dto/user_dto_test.dart @@ -1,6 +1,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:realunit_wallet/packages/service/dfx/models/kyc/kyc_level.dart'; import 'package:realunit_wallet/packages/service/dfx/models/user/dto/user_dto.dart'; +import 'package:realunit_wallet/styles/currency.dart'; void main() { group('$UserKycDto.fromJson', () { @@ -326,5 +327,23 @@ void main() { expect(dto.capabilities.canEditPhone, isFalse); expect(dto.capabilities.canEditAddress, isTrue); }); + + test('parses currency.name from the account payload', () { + final dto = UserDto.fromJson({ + 'kyc': kycJson(), + 'currency': {'id': 2, 'name': 'CHF'}, + }); + + expect(dto.currency, Currency.chf); + }); + + test('unknown or missing currency degrades to null', () { + expect( + UserDto.fromJson({'kyc': kycJson(), 'currency': {'name': 'USD'}}).currency, + isNull, + ); + expect(UserDto.fromJson({'kyc': kycJson()}).currency, isNull); + expect(UserDto.fromJson({'kyc': kycJson(), 'currency': null}).currency, isNull); + }); }); } 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..52ba82ccb 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 @@ -35,12 +35,14 @@ Map _buyPaymentInfoJson({ int id = 42, bool isValid = true, String? error, + String iban = 'CH9300762011623852957', + String currency = 'CHF', }) { return { 'id': id, 'routeId': 99, 'timestamp': '2026-05-23T10:00:00Z', - 'iban': 'CH9300762011623852957', + 'iban': iban, 'bic': 'POFICHBEXXX', 'name': 'DFX AG', 'street': 'Bahnhofstrasse', @@ -49,7 +51,7 @@ Map _buyPaymentInfoJson({ 'city': 'Zug', 'country': 'CH', 'amount': 500.0, - 'currency': 'CHF', + 'currency': currency, 'fees': { 'rate': 0.01, 'fixed': 0.5, @@ -104,7 +106,16 @@ void main() { capturedPath = request.url.path; capturedHeaders = request.headers; capturedBody = jsonDecode(request.body) as Map; - return http.Response(jsonEncode(_buyPaymentInfoJson()), 200); + // EUR quote must return the EUR settlement IBAN + currency — not a CHF leftover. + return http.Response( + jsonEncode( + _buyPaymentInfoJson( + iban: 'CH9708307000560946317', + currency: 'EUR', + ), + ), + 200, + ); }); final service = RealUnitBuyPaymentInfoService(appStore, walletService); @@ -121,7 +132,7 @@ void main() { // pin the fields the UI / cubits actually consume so a wire-shape // drift breaks the test instead of leaking into the buy flow. expect(info.id, 42); - expect(info.iban, 'CH9300762011623852957'); + expect(info.iban, 'CH9708307000560946317'); expect(info.bic, 'POFICHBEXXX'); expect(info.name, 'DFX AG'); expect(info.street, 'Bahnhofstrasse'); @@ -129,7 +140,7 @@ void main() { expect(info.zip, '6300'); expect(info.city, 'Zug'); expect(info.country, 'CH'); - expect(info.currency, Currency.chf); + expect(info.currency, Currency.eur); expect(info.paymentRequest, 'bcr:?query'); expect(info.remittanceInfo, 'REALU-42'); expect(info.isValid, isTrue); @@ -138,17 +149,27 @@ void main() { expect(info.error, isNull); }); - test('defaults the currency to CHF when the caller omits it', () async { + test('defaults the currency to CHF and maps the CHF settlement IBAN', () async { Map? capturedBody; final appStore = buildAppStore((request) async { capturedBody = jsonDecode(request.body) as Map; - return http.Response(jsonEncode(_buyPaymentInfoJson()), 200); + return http.Response( + jsonEncode( + _buyPaymentInfoJson( + iban: 'CH2208307000560946309', + currency: 'CHF', + ), + ), + 200, + ); }); final service = RealUnitBuyPaymentInfoService(appStore, walletService); - await service.getPaymentInfo(1000); + final info = await service.getPaymentInfo(1000); expect(capturedBody!['currency'], 'CHF'); + expect(info.iban, 'CH2208307000560946309'); + expect(info.currency, Currency.chf); }); test('propagates the `error` + invalid flag from the API response', () async { @@ -230,6 +251,45 @@ void main() { ), ); }); + + test('throws ApiException with empty message on plain-text 502', () async { + final appStore = buildAppStore( + (_) async => http.Response('error code: 502', 502), + ); + + final service = RealUnitBuyPaymentInfoService(appStore, walletService); + + await expectLater( + service.getPaymentInfo(500), + throwsA( + isA() + .having((e) => e.statusCode, 'statusCode', 502) + .having((e) => e.message, 'message', '') + .having((e) => e.code, 'code', 'UNKNOWN'), + ), + ); + }); + + test('throws ApiException from JSON 502 body', () async { + final appStore = buildAppStore( + (_) async => http.Response( + jsonEncode({'statusCode': 502, 'code': 'X', 'message': 'upstream'}), + 502, + ), + ); + + final service = RealUnitBuyPaymentInfoService(appStore, walletService); + + await expectLater( + service.getPaymentInfo(500), + throwsA( + isA() + .having((e) => e.statusCode, 'statusCode', 502) + .having((e) => e.code, 'code', 'X') + .having((e) => e.message, 'message', 'upstream'), + ), + ); + }); }); group('confirmPayment', () { @@ -298,6 +358,23 @@ void main() { ); }); + test('throws ApiException with empty message on plain-text 502', () async { + final appStore = buildAppStore( + (_) async => http.Response('error code: 502', 502), + ); + + final service = RealUnitBuyPaymentInfoService(appStore, walletService); + + expect( + () => service.confirmPayment(999), + throwsA( + isA() + .having((e) => e.statusCode, 'statusCode', 502) + .having((e) => e.message, 'message', ''), + ), + ); + }); + test('succeeds on 200 status code', () async { final referenceText = 'REALU-123'; final appStore = buildAppStore( diff --git a/test/packages/service/dfx/real_unit_transfer_service_test.dart b/test/packages/service/dfx/real_unit_transfer_service_test.dart index 13627707b..f9ea8559d 100644 --- a/test/packages/service/dfx/real_unit_transfer_service_test.dart +++ b/test/packages/service/dfx/real_unit_transfer_service_test.dart @@ -475,6 +475,90 @@ void main() { ); }); + test('400 with timeout-like message stays a plain ApiException', () async { + const hash = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + final client = MockClient( + (_) async => http.Response( + jsonEncode({ + 'statusCode': 400, + 'message': 'Timed out while waiting for transaction with hash "$hash"', + 'error': 'Bad Request', + }), + 400, + ), + ); + + await expectLater( + _confirm(build(client), _info()), + throwsA( + predicate((e) => e is ApiException && e is! TransferReceiptTimeoutException), + ), + ); + }); + + test('500 viem receipt-timeout with hash → TransferReceiptTimeoutException', () async { + const hash = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + final client = MockClient( + (_) async => http.Response( + jsonEncode({ + 'statusCode': 500, + 'message': 'Timed out while waiting for transaction with hash "$hash"\nVersion: viem@2.21.0', + 'error': 'Internal Server Error', + }), + 500, + ), + ); + + await expectLater( + _confirm(build(client), _info()), + throwsA( + isA().having((e) => e.txHash, 'txHash', hash), + ), + ); + }); + + test('HTTP 400 with body statusCode 500 and timeout phrase stays a plain ApiException', () async { + const hash = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + final client = MockClient( + (_) async => http.Response( + jsonEncode({ + 'statusCode': 500, + 'message': 'Timed out while waiting for transaction with hash "$hash"', + 'error': 'Internal Server Error', + }), + 400, + ), + ); + + await expectLater( + _confirm(build(client), _info()), + throwsA( + predicate((e) => e is ApiException && e is! TransferReceiptTimeoutException), + ), + ); + }); + + test('HTTP 500 with hash but no timeout phrase stays a plain ApiException', () async { + const hash = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + final client = MockClient( + (_) async => http.Response( + jsonEncode({ + 'statusCode': 500, + 'message': 'relay failed for $hash', + 'error': 'Internal Server Error', + }), + 500, + ), + ); + + await expectLater( + _confirm(build(client), _info()), + throwsA( + predicate((e) => e is ApiException && e is! TransferReceiptTimeoutException), + ), + ); + }); + test('any other 409 conflict stays a plain ApiException', () async { final client = MockClient( (_) async => http.Response( diff --git a/test/packages/utils/fiat_amount_test.dart b/test/packages/utils/fiat_amount_test.dart index dcc2215f8..dcb154234 100644 --- a/test/packages/utils/fiat_amount_test.dart +++ b/test/packages/utils/fiat_amount_test.dart @@ -3,14 +3,13 @@ import 'package:realunit_wallet/packages/utils/fiat_amount.dart'; void main() { group('chargedFiatAmount', () { - // The quote is always requested with this rounded integer, so the SEPA - // transfer / QR the API builds encodes it. - test('rounds a dot decimal to the charged integer (300.75 → 301)', () { - expect(chargedFiatAmount('300.75'), 301); + // Quote requests snap to Rappen, never to whole francs. + test('keeps a dot decimal at Rappen precision (300.75 → 300.75)', () { + expect(chargedFiatAmount('300.75'), 300.75); }); - test('normalises a comma decimal (300,75 → 301)', () { - expect(chargedFiatAmount('300,75'), 301); + test('normalises a comma decimal (300,75 → 300.75)', () { + expect(chargedFiatAmount('300,75'), 300.75); }); test('leaves a whole amount unchanged (300 → 300)', () { @@ -21,9 +20,13 @@ void main() { expect(chargedFiatAmount(''), 0); }); - test('rounds half away from zero and down below the half (0.5 → 1, 1.49 → 1)', () { - expect(chargedFiatAmount('0.5'), 1); - expect(chargedFiatAmount('1.49'), 1); + test('keeps half-franc and sub-franc amounts (0.5 → 0.5, 1.49 → 1.49)', () { + expect(chargedFiatAmount('0.5'), 0.5); + expect(chargedFiatAmount('1.49'), 1.49); + }); + + test('does not round leftover Rappen up to the next franc (9999.63 → 9999.63)', () { + expect(chargedFiatAmount('9999.63'), 9999.63); }); test('throws on structurally invalid input instead of guessing', () { @@ -31,12 +34,16 @@ void main() { expect(() => chargedFiatAmount('3,5,7'), throwsFormatException); }); - test('throws on grouping-ambiguous input instead of charging 1/1000th', () { - // `10,000` typed as ten thousand would otherwise parse as 10.0 and - // silently request a quote for ten francs. - expect(() => chargedFiatAmount('1,000'), throwsFormatException); - expect(() => chargedFiatAmount('10,000'), throwsFormatException); - expect(() => chargedFiatAmount('1.000'), throwsFormatException); + test('reads a thousands group as thousands, not as a 3-decimal fraction', () { + // CHF/EUR have at most two decimal places, so `10,000` / `105.000` + // cannot be a Rappen amount — they are ten thousand / one hundred five + // thousand. Parsing them as 10.0 would quote 1/1000th of the buy. + expect(chargedFiatAmount('1,000'), 1000); + expect(chargedFiatAmount('10,000'), 10000); + expect(chargedFiatAmount('1.000'), 1000); + expect(chargedFiatAmount('90.000'), 90000); + expect(chargedFiatAmount('105.000'), 105000); + expect(chargedFiatAmount("105'000"), 105000); }); }); @@ -45,18 +52,44 @@ void main() { expect(tryParseFiatAmount('300,75'), 300.75); }); - test('returns null on multi-separator input', () { + test('returns null on mixed decimal+grouping input', () { expect(tryParseFiatAmount('1.300,75'), isNull); }); - test('returns null on grouping-ambiguous input (separator + 3 digits)', () { - expect(tryParseFiatAmount('1,000'), isNull); - expect(tryParseFiatAmount('1.000'), isNull); + test('treats separator + 3 digits as a thousands group', () { + expect(tryParseFiatAmount('1,000'), 1000); + expect(tryParseFiatAmount('1.000'), 1000); + expect(tryParseFiatAmount('105.000'), 105000); + expect(tryParseFiatAmount('105,000'), 105000); + expect(tryParseFiatAmount("90'000"), 90000); }); test('still accepts unambiguous decimals', () { expect(tryParseFiatAmount('0,5'), 0.5); expect(tryParseFiatAmount('1,50'), 1.5); }); + + test('plain six-digit amounts stay themselves', () { + expect(tryParseFiatAmount('105000'), 105000); + }); + + test('optional leading minus is still parsed (sell cubit contract)', () { + expect(tryParseFiatAmount('-100'), -100); + expect(tryParseFiatAmount('-1.5'), -1.5); + expect(tryParseFiatAmount('-1,50'), -1.5); + expect(chargedFiatAmount('-100'), -100); + }); + + test('three fractional digits after a leading zero are not a thousands group', () { + expect(tryParseFiatAmount('0.105'), isNull); + expect(tryParseFiatAmount('0,001'), isNull); + expect(tryParseFiatAmount('0.010'), isNull); + }); + + test('malformed apostrophe or mixed grouping is rejected, not stripped to digits', () { + expect(tryParseFiatAmount("0'105"), isNull); + expect(tryParseFiatAmount("1'23"), isNull); + expect(tryParseFiatAmount('1.000,000'), isNull); + }); }); } diff --git a/test/screens/buy/buy_page_test.dart b/test/screens/buy/buy_page_test.dart index cbfe19d12..8880d80d0 100644 --- a/test/screens/buy/buy_page_test.dart +++ b/test/screens/buy/buy_page_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:bloc_test/bloc_test.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; @@ -12,6 +14,7 @@ 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/brokerbot/dfx_buy_shares_dto.dart'; import 'package:realunit_wallet/packages/service/dfx/models/payment/buy/buy_payment_info.dart'; import 'package:realunit_wallet/packages/service/dfx/models/payment/payment_info_error.dart'; import 'package:realunit_wallet/packages/service/dfx/real_unit_buy_payment_info_service.dart'; @@ -23,6 +26,7 @@ import 'package:realunit_wallet/screens/buy/widgets/buy_confirm_button.dart'; import 'package:realunit_wallet/screens/buy/widgets/payment_action_required.dart'; import 'package:realunit_wallet/screens/buy/widgets/payment_converter.dart'; import 'package:realunit_wallet/screens/buy/widgets/payment_information.dart'; +import 'package:realunit_wallet/screens/settings/bloc/settings_bloc.dart'; import 'package:realunit_wallet/styles/currency.dart'; import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart'; @@ -48,10 +52,18 @@ class MockSupportedFiatRepository extends Mock implements SupportedFiatRepositor void main() { late BuyConverterCubit converterCubit; late BuyPaymentInfoCubit buyPaymentInfoCubit; + late MockSettingsBloc settingsBloc; setUp(() { converterCubit = MockBuyConverterCubit(); buyPaymentInfoCubit = MockBuyPaymentInfoCubit(); + settingsBloc = MockSettingsBloc(); + when(() => settingsBloc.state).thenReturn(const SettingsState()); + whenListen( + settingsBloc, + const Stream.empty(), + initialState: const SettingsState(), + ); when(() => converterCubit.state).thenReturn(const BuyConverterState()); when(() => buyPaymentInfoCubit.state).thenReturn(const BuyPaymentInfoInitial()); @@ -61,6 +73,7 @@ void main() { currency: any(named: 'currency'), ), ).thenAnswer((_) => Future.value()); + when(() => buyPaymentInfoCubit.clear()).thenReturn(null); }); void setupDependencyInjection() { @@ -95,12 +108,133 @@ void main() { ); } + Widget wrapBuyPage(Widget page) { + return BlocProvider.value(value: settingsBloc, child: page); + } + group('$BuyPage', () { testWidgets('renders $BuyView', (tester) async { - await tester.pumpApp(const BuyPage()); + await tester.pumpApp(wrapBuyPage(const BuyPage())); expect(find.byType(BuyView), findsOne); }); + + testWidgets('opens the converter in the settings currency', (tester) async { + final brokerbot = GetIt.instance() as MockDfxBrokerbotService; + when(() => brokerbot.getBuyShares(any(), any())).thenAnswer( + (_) async => BrokerbotBuySharesDto( + shares: 217, + pricePerShare: 1.38, + availableShares: 50000, + ), + ); + const eur = SettingsState(currency: Currency.eur); + when(() => settingsBloc.state).thenReturn(eur); + whenListen(settingsBloc, const Stream.empty(), initialState: eur); + + await tester.pumpApp(wrapBuyPage(const BuyPage())); + await tester.pump(const Duration(milliseconds: 250)); + await tester.pump(); + + expect(find.text('EUR'), findsWidgets); + expect( + find.descendant( + of: find.byKey(const Key('buy-currency-picker')), + matching: find.text('EUR'), + ), + findsOneWidget, + ); + }); + + testWidgets('shows the 300 default and keeps the field freely editable — ' + 'the payable never replaces the typed amount', (tester) async { + final brokerbot = GetIt.instance() as MockDfxBrokerbotService; + when(() => brokerbot.getBuyShares(any(), any())).thenAnswer( + (_) async => BrokerbotBuySharesDto( + shares: 217, + pricePerShare: 1.38, + availableShares: 50000, + ), + ); + + await tester.pumpApp(wrapBuyPage(const BuyPage())); + // Past the 100ms conversion debounce of the initial default. + await tester.pump(const Duration(milliseconds: 250)); + await tester.pump(); + + final amountField = find.byType(TextField).first; + // The default stays 300 — the Rappen payable (217 × 1.38 = 299.46) + // belongs to the quote, not to the input field. + expect(tester.widget(amountField).controller!.text, '300'); + + await tester.enterText(amountField, '25'); + await tester.pump(const Duration(milliseconds: 250)); + await tester.pump(); + + expect(tester.widget(amountField).controller!.text, '25'); + }); + + testWidgets('shows Rappen-exact charge under the amount without writing it into the field', ( + tester, + ) async { + final brokerbot = GetIt.instance() as MockDfxBrokerbotService; + when(() => brokerbot.getBuyShares(any(), any())).thenAnswer( + (_) async => BrokerbotBuySharesDto( + shares: 217, + pricePerShare: 1.38, + availableShares: 50000, + ), + ); + + await tester.pumpApp(wrapBuyPage(const BuyPage())); + // Past the 100ms conversion debounce of the initial default. + await tester.pump(const Duration(milliseconds: 250)); + await tester.pump(); + + final amountField = find.byType(TextField).first; + expect(tester.widget(amountField).controller!.text, '300'); + expect(find.byKey(const Key('buy-charged-amount')), findsOneWidget); + expect( + tester.widget(find.byKey(const Key('buy-charged-amount'))).data, + contains('299.46'), + ); + }); + + testWidgets('applies a late SettingsBloc currency to the converter', (tester) async { + final brokerbot = GetIt.instance() as MockDfxBrokerbotService; + when(() => brokerbot.getBuyShares(any(), any())).thenAnswer( + (_) async => BrokerbotBuySharesDto( + shares: 217, + pricePerShare: 1.38, + availableShares: 50000, + ), + ); + + final settings = StreamController.broadcast(); + addTearDown(settings.close); + const initial = SettingsState(currency: Currency.eur); + when(() => settingsBloc.state).thenReturn(initial); + whenListen(settingsBloc, settings.stream, initialState: initial); + + await tester.pumpApp(wrapBuyPage(const BuyPage())); + await tester.pump(const Duration(milliseconds: 250)); + await tester.pump(); + + const next = SettingsState(currency: Currency.chf); + when(() => settingsBloc.state).thenReturn(next); + settings.add(next); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 250)); + await tester.pump(); + + expect( + find.descendant( + of: find.byKey(const Key('buy-currency-picker')), + matching: find.text('CHF'), + ), + findsOneWidget, + ); + }); }); group('$BuyView', () { @@ -111,6 +245,38 @@ void main() { expect(find.byType(PaymentInformation), findsOne); }); + testWidgets('hides charged amount when payable is empty', (tester) async { + when(() => converterCubit.state).thenReturn( + const BuyConverterState(fiatText: '300', payableText: ''), + ); + + await tester.pumpApp(buildSubject(const BuyView())); + + expect(find.byKey(const Key('buy-charged-amount')), findsNothing); + }); + + testWidgets('hides charged amount when payable matches the typed text', (tester) async { + when(() => converterCubit.state).thenReturn( + const BuyConverterState(fiatText: '125.50', payableText: '125.50'), + ); + + await tester.pumpApp(buildSubject(const BuyView())); + + expect(find.byKey(const Key('buy-charged-amount')), findsNothing); + }); + + testWidgets('hides charged amount when payable is the same value with extra fraction digits', ( + tester, + ) async { + when(() => converterCubit.state).thenReturn( + const BuyConverterState(fiatText: '300', payableText: '300.00'), + ); + + await tester.pumpApp(buildSubject(const BuyView())); + + expect(find.byKey(const Key('buy-charged-amount')), findsNothing); + }); + testWidgets('renders correctly when $BuyPaymentInfo is available', (tester) async { when(() => buyPaymentInfoCubit.state).thenReturn( const BuyPaymentInfoSuccess( @@ -149,6 +315,141 @@ void main() { expect(find.text(S.current.buyPaymentConfirm), findsOne); }); + testWidgets( + 'on an EUR quote the confirm CTA keeps the EUR settlement IBAN, not a CHF leftover', + ( + tester, + ) async { + const eurQuote = BuyPaymentInfo( + amount: 300, + id: 1, + iban: 'CH9708307000560946317', + bic: 'bic', + name: 'name', + street: 'street', + number: 'number', + zip: 'zip', + city: 'city', + country: 'country', + currency: Currency.eur, + ); + when(() => buyPaymentInfoCubit.state).thenReturn( + const BuyPaymentInfoSuccess(eurQuote), + ); + when(() => converterCubit.state).thenReturn( + const BuyConverterState(currency: Currency.eur, fiatText: '300'), + ); + + await tester.pumpApp(buildSubject(const BuyView())); + + expect(find.byType(BuyConfirmButton), findsOne); + expect(find.text(S.current.buyPaymentConfirm), findsOne); + final confirm = tester.widget(find.byType(BuyConfirmButton)); + expect(confirm.buyPaymentInfo.iban, 'CH9708307000560946317'); + expect(confirm.buyPaymentInfo.currency, Currency.eur); + expect(confirm.buyPaymentInfo.iban, isNot('CH2208307000560946309')); + }, + ); + + testWidgets('clears the quote when the converter currency changes', (tester) async { + const start = BuyConverterState(currency: Currency.eur); + const next = BuyConverterState(currency: Currency.chf, loading: true); + when(() => converterCubit.state).thenReturn(start); + whenListen( + converterCubit, + Stream.fromIterable([next]), + initialState: start, + ); + when(() => buyPaymentInfoCubit.state).thenReturn( + const BuyPaymentInfoMinAmountNotMetFailure( + PaymentInfoError.minAmountNotMet, + minAmount: 108.4, + ), + ); + + await tester.pumpApp(buildSubject(const BuyView())); + await tester.pump(); + + verify(() => buyPaymentInfoCubit.clear()).called(1); + verifyNever( + () => buyPaymentInfoCubit.getPaymentInfo( + amount: any(named: 'amount'), + currency: any(named: 'currency'), + ), + ); + }); + + testWidgets('clears the quote when conversion finishes before fetching the new quote', ( + tester, + ) async { + const start = BuyConverterState(currency: Currency.chf, fiatText: '300', loading: true); + const next = BuyConverterState( + currency: Currency.chf, + fiatText: '400', + payableText: '400.00', + ); + when(() => converterCubit.state).thenReturn(start); + whenListen( + converterCubit, + Stream.fromIterable([next]), + initialState: start, + ); + 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, + ), + ), + ); + + await tester.pumpApp(buildSubject(const BuyView())); + await tester.pump(); + + verifyInOrder([ + () => buyPaymentInfoCubit.clear(), + () => buyPaymentInfoCubit.getPaymentInfo(amount: '400.00', currency: Currency.chf), + ]); + }); + + testWidgets('hides confirm when the quote currency does not match the converter', ( + tester, + ) async { + const eurQuote = BuyPaymentInfo( + amount: 300, + id: 1, + iban: 'CH9708307000560946317', + bic: 'bic', + name: 'name', + street: 'street', + number: 'number', + zip: 'zip', + city: 'city', + country: 'country', + currency: Currency.eur, + ); + when(() => buyPaymentInfoCubit.state).thenReturn( + const BuyPaymentInfoSuccess(eurQuote), + ); + when(() => converterCubit.state).thenReturn( + const BuyConverterState(currency: Currency.chf, fiatText: '300'), + ); + + await tester.pumpApp(buildSubject(const BuyView())); + + expect(find.byType(BuyConfirmButton), findsNothing); + expect(find.text(S.current.buyPaymentConfirm), findsNothing); + }); + testWidgets('renders correctly when $BuyPaymentInfo is loading', (tester) async { when(() => buyPaymentInfoCubit.state).thenReturn(const BuyPaymentInfoLoading()); @@ -228,6 +529,40 @@ void main() { ); }); + testWidgets('renders correctly when max amount is exceeded', (tester) async { + // Non-integer max: the API returns the remaining cap in the input + // currency. Display rounds DOWN to the largest whole amount that still + // satisfies the server-side maximum (symmetric to min's ceil). + // 90000.6 distinguishes floor (90000) from round/ceil (90001). + final maxAmount = 90000.6; + final currency = Currency.eur; + + when(() => buyPaymentInfoCubit.state).thenReturn( + BuyPaymentInfoMaxAmountExceededFailure( + PaymentInfoError.maxAmountExceeded, + maxAmount: maxAmount, + ), + ); + when(() => converterCubit.state).thenReturn( + BuyConverterState( + currency: currency, + ), + ); + + await tester.pumpApp(buildSubject(const BuyView())); + + expect(find.byType(PaymentActionRequired), findsNothing); + expect(find.byType(PaymentInformation), findsOne); + expect(find.text(S.current.buyMaxAmount('${maxAmount.floor()}', currency.code)), findsOne); + expect(find.text(S.current.retry), findsNothing); + expect( + find.byWidgetPredicate( + (Widget widget) => widget is AppFilledButton && widget.onPressed == null, + ), + findsOne, + ); + }); + testWidgets('retries payment info when unknown error is shown', (tester) async { when(() => buyPaymentInfoCubit.state).thenReturn( const BuyPaymentInfoFailure( @@ -236,7 +571,11 @@ void main() { ), ); when(() => converterCubit.state).thenReturn( - const BuyConverterState(currency: Currency.eur), + const BuyConverterState( + currency: Currency.eur, + fiatText: '300', + payableText: '299.46', + ), ); await tester.pumpApp(buildSubject(const BuyView())); @@ -252,7 +591,7 @@ void main() { verify( () => buyPaymentInfoCubit.getPaymentInfo( - amount: '', + amount: '299.46', currency: Currency.eur, ), ).called(1); @@ -280,5 +619,73 @@ void main() { expect(amount.controller!.text, equals('5.00')); expect(result.controller!.text, equals('0.50')); }); + + testWidgets('requests the quote with the live payable, not the typed field text', ( + tester, + ) async { + whenListen( + converterCubit, + Stream.fromIterable([ + const BuyConverterState( + fiatText: '300', + payableText: '', + sharesText: '217', + loading: true, + ), + const BuyConverterState( + fiatText: '300', + payableText: '299.46', + sharesText: '217', + loading: false, + ), + ]), + initialState: const BuyConverterState(fiatText: '300'), + ); + + await tester.pumpApp(buildSubject(const BuyView())); + await tester.pumpAndSettle(); + + verify( + () => buyPaymentInfoCubit.getPaymentInfo( + amount: '299.46', + currency: Currency.chf, + ), + ).called(1); + }); + + testWidgets('requests the EUR quote with the live payable when converter settles in EUR', ( + tester, + ) async { + whenListen( + converterCubit, + Stream.fromIterable([ + const BuyConverterState( + fiatText: '300', + payableText: '', + sharesText: '217', + loading: true, + currency: Currency.eur, + ), + const BuyConverterState( + fiatText: '300', + payableText: '299.46', + sharesText: '217', + loading: false, + currency: Currency.eur, + ), + ]), + initialState: const BuyConverterState(fiatText: '300', currency: Currency.eur), + ); + + await tester.pumpApp(buildSubject(const BuyView())); + await tester.pumpAndSettle(); + + verify( + () => buyPaymentInfoCubit.getPaymentInfo( + amount: '299.46', + currency: Currency.eur, + ), + ).called(1); + }); }); } diff --git a/test/screens/buy/buy_responsive_matrix_test.dart b/test/screens/buy/buy_responsive_matrix_test.dart index e89f7aaaf..7c9bd5df4 100644 --- a/test/screens/buy/buy_responsive_matrix_test.dart +++ b/test/screens/buy/buy_responsive_matrix_test.dart @@ -104,6 +104,24 @@ void main() { ); } + /// Quote-over-inventory: extra max-amount hint + disabled Next in the + /// sticky actions. Overflow-only — the button is not tappable by design. + void stubMaxAmountExceededState() { + when(() => buyPaymentInfoCubit.state).thenReturn( + const BuyPaymentInfoMaxAmountExceededFailure( + PaymentInfoError.maxAmountExceeded, + maxAmount: 90000.6, + ), + ); + when(() => converterCubit.state).thenReturn( + const BuyConverterState( + currency: Currency.chf, + fiatText: '999999999', + sharesText: '1234567.89', + ), + ); + } + Widget buildSubject() { return MultiBlocProvider( providers: [ @@ -207,4 +225,28 @@ void main() { }); }, ); + + group('BuyView maxAmountExceeded matrix (overflow only)', () { + for (final cell in kFullResponsiveMatrix) { + testWidgets('maxAmountExceeded · ${cell.id}', (tester) async { + await withTargetPlatform(cell.device.platform, () async { + stubMaxAmountExceededState(); + + await expectNoLayoutOverflow( + tester, + () async { + await pumpScreen(tester, cell, buildSubject()); + }, + reason: 'overflow on maxAmountExceeded / ${cell.label}', + ); + + expect(find.byType(AppFilledButton), findsOneWidget); + expect( + tester.widget(find.byType(AppFilledButton)).onPressed, + isNull, + ); + }); + }); + } + }); } diff --git a/test/screens/buy/cubits/buy_converter_cubit_test.dart b/test/screens/buy/cubits/buy_converter_cubit_test.dart index 53ecc7186..46376207e 100644 --- a/test/screens/buy/cubits/buy_converter_cubit_test.dart +++ b/test/screens/buy/cubits/buy_converter_cubit_test.dart @@ -22,15 +22,21 @@ void main() { }); group('$BuyConverterCubit', () { - test('initial state is empty with CHF', () { + test('initial state is empty with EUR', () { final cubit = BuyConverterCubit(service); expect(cubit.state.fiatText, ''); expect(cubit.state.sharesText, ''); - expect(cubit.state.currency, Currency.chf); + expect(cubit.state.currency, Currency.eur); expect(cubit.state.loading, isFalse); }); + test('honours an explicit initial currency', () { + final cubit = BuyConverterCubit(service, currency: Currency.chf); + + expect(cubit.state.currency, Currency.chf); + }); + test('onFiatChanged debounces, then writes the converted shares', () async { when(() => service.getBuyShares(any(), any())).thenAnswer( (_) async => BrokerbotBuySharesDto( @@ -45,10 +51,67 @@ void main() { // Past the 100ms debounce. await Future.delayed(const Duration(milliseconds: 250)); + // The typed text is never overwritten; the Rappen-exact charge lives + // in payableText and is what quotes are requested with. expect(cubit.state.fiatText, '100'); + expect(cubit.state.payableText, '87.50'); + expect(cubit.state.quoteAmountText, '87.50'); expect(cubit.state.sharesText, '7'); expect(cubit.state.loading, isFalse); - verify(() => service.getBuyShares('100', Currency.chf)).called(1); + verify(() => service.getBuyShares('100', Currency.eur)).called(1); + }); + + test('onFiatChanged exposes shares × list in Rappen as payable (10000 → 7299 × 1.37 = 9999.63) ' + 'without touching the typed text', () async { + when(() => service.getBuyShares(any(), any())).thenAnswer( + (_) async => BrokerbotBuySharesDto( + shares: 7299, + pricePerShare: 1.37, + availableShares: 50000, + ), + ); + + final cubit = BuyConverterCubit(service); + await cubit.onFiatChanged('10000'); + await Future.delayed(const Duration(milliseconds: 250)); + + expect(cubit.state.sharesText, '7299'); + expect(cubit.state.fiatText, '10000'); + expect(cubit.state.payableText, '9999.63'); + }); + + test('deleting digits keeps the user text editable — the field is never snapped back ' + '(regression: 299.46 prefill could not be deleted)', () async { + when(() => service.getBuyShares(any(), any())).thenAnswer( + (_) async => BrokerbotBuySharesDto( + shares: 217, + pricePerShare: 1.38, + availableShares: 50000, + ), + ); + + final cubit = BuyConverterCubit(service); + await cubit.onFiatChanged('300'); + await Future.delayed(const Duration(milliseconds: 250)); + expect(cubit.state.payableText, '299.46'); + + // Backspace: the user shortens the amount. The stale payable is + // dropped immediately and the typed text stays exactly as typed. + when(() => service.getBuyShares(any(), any())).thenAnswer( + (_) async => BrokerbotBuySharesDto( + shares: 21, + pricePerShare: 1.38, + availableShares: 50000, + ), + ); + await cubit.onFiatChanged('30'); + expect(cubit.state.fiatText, '30'); + expect(cubit.state.payableText, ''); + expect(cubit.state.quoteAmountText, '30'); + + await Future.delayed(const Duration(milliseconds: 250)); + expect(cubit.state.fiatText, '30'); + expect(cubit.state.payableText, '28.98'); }); test('onFiatChanged debounces — only the latest value reaches the service', () async { @@ -104,6 +167,7 @@ void main() { expect(cubit.state.sharesText, '5.000'); expect(cubit.state.fiatText, '125.500'); + expect(cubit.state.payableText, '125.50'); }, ); @@ -140,7 +204,7 @@ void main() { }); test( - 'onCurrencyChanged refetches shares with the new currency and emits both fields', + 'onCurrencyChanged refetches shares with the new currency and preserves fiatText', () async { when(() => service.getBuyShares(any(), any())).thenAnswer( (_) async => BrokerbotBuySharesDto( @@ -150,13 +214,20 @@ void main() { ), ); - final cubit = BuyConverterCubit(service); + final cubit = BuyConverterCubit(service, currency: Currency.chf); + await cubit.onFiatChanged('300'); + await Future.delayed(const Duration(milliseconds: 250)); await cubit.onCurrencyChanged(Currency.eur); - expect(cubit.state.currency, Currency.eur); + // Typed text survives the currency switch; conversion lands in + // sharesText / payableText only. + expect(cubit.state.fiatText, '300'); expect(cubit.state.sharesText, '3'); + expect(cubit.state.payableText, '30.00'); + expect(cubit.state.quoteAmountText, '30.00'); + expect(cubit.state.currency, Currency.eur); expect(cubit.state.loading, isFalse); - verify(() => service.getBuyShares('', Currency.eur)).called(1); + verify(() => service.getBuyShares('300', Currency.eur)).called(1); }, ); @@ -165,7 +236,7 @@ void main() { () => service.getBuyShares(any(), any()), ).thenAnswer((_) async => throw Exception('throttle')); - final cubit = BuyConverterCubit(service); + final cubit = BuyConverterCubit(service, currency: Currency.chf); await cubit.onCurrencyChanged(Currency.eur); expect(cubit.state.currency, Currency.eur); @@ -189,26 +260,34 @@ void main() { final cubit = BuyConverterCubit(service); await cubit.onFiatChanged('460'); - await Future.delayed(const Duration(milliseconds: 150)); // timer fires → API_460 in flight + await Future.delayed( + const Duration(milliseconds: 150), + ); // timer fires → API_460 in flight await cubit.onFiatChanged('4600'); - await Future.delayed(const Duration(milliseconds: 150)); // timer fires → API_4600 in flight + await Future.delayed( + const Duration(milliseconds: 150), + ); // timer fires → API_4600 in flight // Pathological response order: the NEWER request resolves first… - c4600.complete(BrokerbotBuySharesDto( - shares: 3216, - pricePerShare: 1.43, - availableShares: 100, - )); + c4600.complete( + BrokerbotBuySharesDto( + shares: 3216, + pricePerShare: 1.43, + availableShares: 100, + ), + ); await Future.delayed(const Duration(milliseconds: 50)); expect(cubit.state.sharesText, '3216'); // …and the OLDER request resolves later. Without the seq guard, // this would overwrite sharesText with '321'. - c460.complete(BrokerbotBuySharesDto( - shares: 321, - pricePerShare: 1.43, - availableShares: 100, - )); + c460.complete( + BrokerbotBuySharesDto( + shares: 321, + pricePerShare: 1.43, + availableShares: 100, + ), + ); await Future.delayed(const Duration(milliseconds: 50)); expect( cubit.state.sharesText, @@ -234,19 +313,23 @@ void main() { await cubit.onSharesChanged('50'); await Future.delayed(const Duration(milliseconds: 150)); - c50.complete(BrokerbotBuyPriceDto( - totalCost: 71.50, - pricePerShare: 1.43, - availableShares: 100, - )); + c50.complete( + BrokerbotBuyPriceDto( + totalCost: 71.50, + pricePerShare: 1.43, + availableShares: 100, + ), + ); await Future.delayed(const Duration(milliseconds: 50)); expect(cubit.state.fiatText, '71.50'); - c5.complete(BrokerbotBuyPriceDto( - totalCost: 7.15, - pricePerShare: 1.43, - availableShares: 100, - )); + c5.complete( + BrokerbotBuyPriceDto( + totalCost: 7.15, + pricePerShare: 1.43, + availableShares: 100, + ), + ); await Future.delayed(const Duration(milliseconds: 50)); expect( cubit.state.fiatText, @@ -282,16 +365,21 @@ void main() { await cubit.onFiatChanged('1000'); await Future.delayed(const Duration(milliseconds: 250)); - cEur.complete(BrokerbotBuySharesDto( - shares: 0, - pricePerShare: 1.43, - availableShares: 100, - )); + cEur.complete( + BrokerbotBuySharesDto( + shares: 0, + pricePerShare: 1.43, + availableShares: 100, + ), + ); await Future.delayed(const Duration(milliseconds: 50)); expect(cubit.state.currency, Currency.eur); - expect(cubit.state.sharesText, '700', - reason: 'the superseded onCurrencyChanged response (shares=0) must be dropped'); + expect( + cubit.state.sharesText, + '700', + reason: 'the superseded onCurrencyChanged response (shares=0) must be dropped', + ); }, ); diff --git a/test/screens/buy/cubits/buy_payment_info/buy_payment_info_state_test.dart b/test/screens/buy/cubits/buy_payment_info/buy_payment_info_state_test.dart index 168459aa9..02ab4d7e6 100644 --- a/test/screens/buy/cubits/buy_payment_info/buy_payment_info_state_test.dart +++ b/test/screens/buy/cubits/buy_payment_info/buy_payment_info_state_test.dart @@ -133,6 +133,33 @@ void main() { }); }); + group('BuyPaymentInfoMaxAmountExceededFailure', () { + test('same error+maxAmount is equal and props match', () { + final a = BuyPaymentInfoMaxAmountExceededFailure( + PaymentInfoError.maxAmountExceeded, + maxAmount: 90000, + ); + final b = BuyPaymentInfoMaxAmountExceededFailure( + PaymentInfoError.maxAmountExceeded, + maxAmount: 90000, + ); + expect(a, equals(b)); + expect(a.props, [PaymentInfoError.maxAmountExceeded, 90000]); + }); + + test('different maxAmount is unequal', () { + final a = BuyPaymentInfoMaxAmountExceededFailure( + PaymentInfoError.maxAmountExceeded, + maxAmount: 90000, + ); + final b = BuyPaymentInfoMaxAmountExceededFailure( + PaymentInfoError.maxAmountExceeded, + maxAmount: 80000, + ); + expect(a, isNot(equals(b))); + }); + }); + group('BuyPaymentInfoState (cross-subclass identity)', () { test('Initial vs Loading are unequal', () { expect(BuyPaymentInfoInitial(), isNot(equals(BuyPaymentInfoLoading()))); @@ -148,5 +175,14 @@ void main() { // check separates them. expect(f, isNot(equals(m))); }); + + test('Failure vs MaxAmountExceededFailure with matching error are unequal', () { + final f = BuyPaymentInfoFailure(PaymentInfoError.maxAmountExceeded); + final m = BuyPaymentInfoMaxAmountExceededFailure( + PaymentInfoError.maxAmountExceeded, + maxAmount: 90000, + ); + expect(f, isNot(equals(m))); + }); }); } diff --git a/test/screens/buy/cubits/buy_payment_info_cubit_test.dart b/test/screens/buy/cubits/buy_payment_info_cubit_test.dart index 486ba9242..59854d1db 100644 --- a/test/screens/buy/cubits/buy_payment_info_cubit_test.dart +++ b/test/screens/buy/cubits/buy_payment_info_cubit_test.dart @@ -11,18 +11,19 @@ import 'package:realunit_wallet/packages/service/dfx/real_unit_buy_payment_info_ import 'package:realunit_wallet/screens/buy/cubits/buy_payment_info/buy_payment_info_cubit.dart'; import 'package:realunit_wallet/styles/currency.dart'; -class _MockBuyPaymentInfoService extends Mock - implements RealUnitBuyPaymentInfoService {} +class _MockBuyPaymentInfoService extends Mock implements RealUnitBuyPaymentInfoService {} BuyPaymentInfo _info({ bool isValid = true, double? minVolume, + double? maxVolume, String? error, Currency currency = Currency.chf, + String iban = 'CH56 0483 5012 3456 78', }) => BuyPaymentInfo( amount: 300, id: 1, - iban: 'CH56 0483 5012 3456 78', + iban: iban, bic: 'CRESCHZZ80A', name: 'DFX AG', street: 'Bahnhofstrasse', @@ -33,6 +34,7 @@ BuyPaymentInfo _info({ currency: currency, isValid: isValid, minVolume: minVolume, + maxVolume: maxVolume, error: error, ); @@ -54,9 +56,41 @@ void main() { expect(build().state, isA()); }); + test('clear drops an in-flight quote back to Initial', () async { + when( + () => service.getPaymentInfo(any(), currency: any(named: 'currency')), + ).thenAnswer((_) async => _info()); + + final cubit = build(); + await cubit.getPaymentInfo(amount: '300'); + expect(cubit.state, isA()); + + cubit.clear(); + expect(cubit.state, isA()); + }); + + test('a quote that completes after clear does not replace Initial', () async { + final held = Completer(); + when( + () => service.getPaymentInfo(any(), currency: any(named: 'currency')), + ).thenAnswer((_) => held.future); + + final cubit = build(); + final pending = cubit.getPaymentInfo(amount: '300'); + cubit.clear(); + expect(cubit.state, isA()); + + held.complete(_info()); + // clear() cancels the in-flight operation; awaiting `.value` hung. + // valueOrCancellation() must complete so this timeout is not hit. + await pending.timeout(const Duration(seconds: 1)); + expect(cubit.state, isA()); + }); + test('happy path emits Success with the payment info from the API', () async { - when(() => service.getPaymentInfo(any(), currency: any(named: 'currency'))) - .thenAnswer((_) async => _info()); + when( + () => service.getPaymentInfo(any(), currency: any(named: 'currency')), + ).thenAnswer((_) async => _info()); final cubit = build(); await cubit.getPaymentInfo(amount: '300'); @@ -65,31 +99,54 @@ void main() { verify(() => service.getPaymentInfo(300, currency: Currency.chf)).called(1); }); - test('API isValid=false with error=AmountTooLow → MinAmountNotMetFailure with API limit', () async { - when(() => service.getPaymentInfo(any(), currency: any(named: 'currency'))) - .thenAnswer((_) async => _info(isValid: false, error: 'AmountTooLow', minVolume: 100)); + test('EUR quote emits Success with the EUR IBAN and currency from the API', () async { + when(() => service.getPaymentInfo(any(), currency: any(named: 'currency'))).thenAnswer( + (_) async => _info( + currency: Currency.eur, + iban: 'CH9708307000560946317', + ), + ); final cubit = build(); - await cubit.getPaymentInfo(amount: '50'); + await cubit.getPaymentInfo(amount: '300', currency: Currency.eur); - expect(cubit.state, isA()); - final f = cubit.state as BuyPaymentInfoMinAmountNotMetFailure; - expect(f.error, PaymentInfoError.minAmountNotMet); - expect(f.minAmount, 100); - verify(() => service.getPaymentInfo(50, currency: Currency.chf)).called(1); + expect(cubit.state, isA()); + final success = cubit.state as BuyPaymentInfoSuccess; + expect(success.buyPaymentInfo.iban, 'CH9708307000560946317'); + expect(success.buyPaymentInfo.currency, Currency.eur); + verify(() => service.getPaymentInfo(300, currency: Currency.eur)).called(1); }); + test( + 'API isValid=false with error=AmountTooLow → MinAmountNotMetFailure with API limit', + () async { + when( + () => service.getPaymentInfo(any(), currency: any(named: 'currency')), + ).thenAnswer((_) async => _info(isValid: false, error: 'AmountTooLow', minVolume: 100)); + + final cubit = build(); + await cubit.getPaymentInfo(amount: '50'); + + expect(cubit.state, isA()); + final f = cubit.state as BuyPaymentInfoMinAmountNotMetFailure; + expect(f.error, PaymentInfoError.minAmountNotMet); + expect(f.minAmount, 100); + verify(() => service.getPaymentInfo(50, currency: Currency.chf)).called(1); + }, + ); + test('EUR min is reported by the API as-is, not scaled in the app', () async { // For EUR the API returns its own currency-specific minVolume; the // app no longer multiplies a hardcoded CHF baseline by an exchange // rate locally — the rate lives server-side. - when(() => service.getPaymentInfo(any(), currency: any(named: 'currency'))) - .thenAnswer((_) async => _info( - isValid: false, - error: 'AmountTooLow', - minVolume: 92, - currency: Currency.eur, - )); + when(() => service.getPaymentInfo(any(), currency: any(named: 'currency'))).thenAnswer( + (_) async => _info( + isValid: false, + error: 'AmountTooLow', + minVolume: 92, + currency: Currency.eur, + ), + ); final cubit = build(); await cubit.getPaymentInfo(amount: '50', currency: Currency.eur); @@ -99,28 +156,31 @@ void main() { verify(() => service.getPaymentInfo(50, currency: Currency.eur)).called(1); }); - test('API isValid=false with error=PrimaryEmailRequired → Failure(primaryEmailRequired)', () async { - // The API pre-tells on the quote that the account has no primary - // email; the app gates the confirm before the tap instead of - // reacting to a post-submit 400. - when(() => service.getPaymentInfo(any(), currency: any(named: 'currency'))) - .thenAnswer((_) async => _info(isValid: false, error: 'PrimaryEmailRequired')); + test( + 'API isValid=false with error=PrimaryEmailRequired → Failure(primaryEmailRequired)', + () async { + // The API pre-tells on the quote that the account has no primary + // email; the app gates the confirm before the tap instead of + // reacting to a post-submit 400. + when( + () => service.getPaymentInfo(any(), currency: any(named: 'currency')), + ).thenAnswer((_) async => _info(isValid: false, error: 'PrimaryEmailRequired')); - final cubit = build(); - await cubit.getPaymentInfo(amount: '300'); + final cubit = build(); + await cubit.getPaymentInfo(amount: '300'); - expect(cubit.state, isA()); - expect((cubit.state as BuyPaymentInfoFailure).error, PaymentInfoError.primaryEmailRequired); - }); + expect(cubit.state, isA()); + expect((cubit.state as BuyPaymentInfoFailure).error, PaymentInfoError.primaryEmailRequired); + }, + ); test('API isValid=false with error=PrimaryEmailNotConfirmed → ' 'Failure(primaryEmailNotConfirmed)', () async { // A registered-but-unconfirmed email routes to the confirmation flow // (via KYC) instead of a post-submit failure or the email-capture path. - when(() => service.getPaymentInfo(any(), currency: any(named: 'currency'))) - .thenAnswer( - (_) async => _info(isValid: false, error: 'PrimaryEmailNotConfirmed'), - ); + when(() => service.getPaymentInfo(any(), currency: any(named: 'currency'))).thenAnswer( + (_) async => _info(isValid: false, error: 'PrimaryEmailNotConfirmed'), + ); final cubit = build(); await cubit.getPaymentInfo(amount: '300'); @@ -134,10 +194,9 @@ void main() { test('API isValid=false with error=PrimaryEmailNotConfirmed → ' 'Failure carries context', () async { - when(() => service.getPaymentInfo(any(), currency: any(named: 'currency'))) - .thenAnswer( - (_) async => _info(isValid: false, error: 'PrimaryEmailNotConfirmed'), - ); + when(() => service.getPaymentInfo(any(), currency: any(named: 'currency'))).thenAnswer( + (_) async => _info(isValid: false, error: 'PrimaryEmailNotConfirmed'), + ); final cubit = build(); await cubit.getPaymentInfo(amount: '300'); @@ -146,9 +205,68 @@ void main() { expect((cubit.state as BuyPaymentInfoFailure).context, 'RealunitBuy'); }); + test('API isValid=false with error=AmountTooHigh and maxVolume → ' + 'MaxAmountExceededFailure with API limit', () async { + when(() => service.getPaymentInfo(any(), currency: any(named: 'currency'))).thenAnswer( + (_) async => _info(isValid: false, error: 'AmountTooHigh', maxVolume: 90000), + ); + + final cubit = build(); + await cubit.getPaymentInfo(amount: '90001'); + + expect(cubit.state, isA()); + final f = cubit.state as BuyPaymentInfoMaxAmountExceededFailure; + expect(f.error, PaymentInfoError.maxAmountExceeded); + expect(f.maxAmount, 90000); + verify(() => service.getPaymentInfo(90001, currency: Currency.chf)).called(1); + }); + + test('API isValid=false with error=LimitExceeded and maxVolume → ' + 'MaxAmountExceededFailure with API limit', () async { + when(() => service.getPaymentInfo(any(), currency: any(named: 'currency'))).thenAnswer( + (_) async => _info(isValid: false, error: 'LimitExceeded', maxVolume: 90000.4), + ); + + final cubit = build(); + await cubit.getPaymentInfo(amount: '100000'); + + expect(cubit.state, isA()); + final f = cubit.state as BuyPaymentInfoMaxAmountExceededFailure; + expect(f.error, PaymentInfoError.maxAmountExceeded); + expect(f.maxAmount, 90000.4); + verify(() => service.getPaymentInfo(100000, currency: Currency.chf)).called(1); + }); + + test('AmountTooHigh without maxVolume → generic Failure(unknown)', () async { + when( + () => service.getPaymentInfo(any(), currency: any(named: 'currency')), + ).thenAnswer((_) async => _info(isValid: false, error: 'AmountTooHigh')); + + final cubit = build(); + await cubit.getPaymentInfo(amount: '90001'); + + expect(cubit.state, isA()); + expect(cubit.state, isNot(isA())); + expect((cubit.state as BuyPaymentInfoFailure).error, PaymentInfoError.unknown); + }); + + test('LimitExceeded without maxVolume → generic Failure(unknown)', () async { + when( + () => service.getPaymentInfo(any(), currency: any(named: 'currency')), + ).thenAnswer((_) async => _info(isValid: false, error: 'LimitExceeded')); + + final cubit = build(); + await cubit.getPaymentInfo(amount: '100000'); + + expect(cubit.state, isA()); + expect(cubit.state, isNot(isA())); + expect((cubit.state as BuyPaymentInfoFailure).error, PaymentInfoError.unknown); + }); + test('API isValid=false with unknown error → generic Failure', () async { - when(() => service.getPaymentInfo(any(), currency: any(named: 'currency'))) - .thenAnswer((_) async => _info(isValid: false, error: 'AmountTooHigh', minVolume: 100)); + when( + () => service.getPaymentInfo(any(), currency: any(named: 'currency')), + ).thenAnswer((_) async => _info(isValid: false, error: 'CountryNotAllowed')); final cubit = build(); await cubit.getPaymentInfo(amount: '999999999'); @@ -158,8 +276,9 @@ void main() { }); test('empty amount string is treated as 0 → still calls the API', () async { - when(() => service.getPaymentInfo(any(), currency: any(named: 'currency'))) - .thenAnswer((_) async => _info(isValid: false, error: 'AmountTooLow', minVolume: 100)); + when( + () => service.getPaymentInfo(any(), currency: any(named: 'currency')), + ).thenAnswer((_) async => _info(isValid: false, error: 'AmountTooLow', minVolume: 100)); final cubit = build(); await cubit.getPaymentInfo(amount: ''); @@ -169,19 +288,29 @@ void main() { }); test('comma decimal separator is normalised to dot', () async { - when(() => service.getPaymentInfo(any(), currency: any(named: 'currency'))) - .thenAnswer((_) async => _info()); + when( + () => service.getPaymentInfo(any(), currency: any(named: 'currency')), + ).thenAnswer((_) async => _info()); final cubit = build(); await cubit.getPaymentInfo(amount: '300,75'); - // 300.75 rounded to 301. - verify(() => service.getPaymentInfo(301, currency: Currency.chf)).called(1); + verify(() => service.getPaymentInfo(300.75, currency: Currency.chf)).called(1); + }); + + test('thousands grouping 105.000 is quoted as 105000, not 105', () async { + when( + () => service.getPaymentInfo(any(), currency: any(named: 'currency')), + ).thenAnswer((_) async => _info()); + + final cubit = build(); + await cubit.getPaymentInfo(amount: '105.000'); + + verify(() => service.getPaymentInfo(105000, currency: Currency.chf)).called(1); }); test('KycLevelRequiredException → Failure(kycRequired, requiredLevel)', () async { - when(() => service.getPaymentInfo(any(), currency: any(named: 'currency'))) - .thenAnswer( + when(() => service.getPaymentInfo(any(), currency: any(named: 'currency'))).thenAnswer( (_) async => throw const KycLevelRequiredException( statusCode: 403, code: 'KYC_REQUIRED', @@ -200,8 +329,7 @@ void main() { }); test('KycLevelRequiredException with context → Failure carries context', () async { - when(() => service.getPaymentInfo(any(), currency: any(named: 'currency'))) - .thenAnswer( + when(() => service.getPaymentInfo(any(), currency: any(named: 'currency'))).thenAnswer( (_) async => throw const KycLevelRequiredException( statusCode: 403, code: 'KYC_REQUIRED', @@ -222,8 +350,7 @@ void main() { }); test('RegistrationRequiredException → Failure(registrationRequired)', () async { - when(() => service.getPaymentInfo(any(), currency: any(named: 'currency'))) - .thenAnswer( + when(() => service.getPaymentInfo(any(), currency: any(named: 'currency'))).thenAnswer( (_) async => throw const RegistrationRequiredException( statusCode: 403, code: 'REGISTRATION_REQUIRED', @@ -239,8 +366,7 @@ void main() { }); test('RegistrationRequiredException with context → Failure carries context', () async { - when(() => service.getPaymentInfo(any(), currency: any(named: 'currency'))) - .thenAnswer( + when(() => service.getPaymentInfo(any(), currency: any(named: 'currency'))).thenAnswer( (_) async => throw const RegistrationRequiredException( statusCode: 403, code: 'REGISTRATION_REQUIRED', @@ -258,19 +384,40 @@ void main() { }); test('generic exception → Failure(unknown)', () async { - when(() => service.getPaymentInfo(any(), currency: any(named: 'currency'))) - .thenAnswer((_) async => throw Exception('network')); + when( + () => service.getPaymentInfo(any(), currency: any(named: 'currency')), + ).thenAnswer((_) async => throw Exception('network')); final cubit = build(); await cubit.getPaymentInfo(amount: '300'); final f = cubit.state as BuyPaymentInfoFailure; expect(f.error, PaymentInfoError.unknown); + expect(f.message, ''); + }); + + test('FormatException from service → Failure(unknown) with empty message', () async { + when(() => service.getPaymentInfo(any(), currency: any(named: 'currency'))).thenAnswer( + (_) async => throw const FormatException( + 'Unexpected character (at character 1)', + 'error code: 502', + ), + ); + + final cubit = build(); + await cubit.getPaymentInfo(amount: '300'); + + final f = cubit.state as BuyPaymentInfoFailure; + expect(f.error, PaymentInfoError.unknown); + expect(f.message, ''); + expect(f.message, isNot(contains('FormatException'))); + expect(f.message, isNot(contains('error code: 502'))); }); test('BitboxNotConnectedException → Failure(bitboxDisconnected)', () async { - when(() => service.getPaymentInfo(any(), currency: any(named: 'currency'))) - .thenAnswer((_) async => throw const BitboxNotConnectedException()); + when( + () => service.getPaymentInfo(any(), currency: any(named: 'currency')), + ).thenAnswer((_) async => throw const BitboxNotConnectedException()); final cubit = build(); await cubit.getPaymentInfo(amount: '300'); @@ -280,8 +427,7 @@ void main() { }); test('ApiException 503 → Failure(priceSourceUnavailable)', () async { - when(() => service.getPaymentInfo(any(), currency: any(named: 'currency'))) - .thenAnswer( + when(() => service.getPaymentInfo(any(), currency: any(named: 'currency'))).thenAnswer( (_) async => throw const ApiException( statusCode: 503, code: 'PRICE_SOURCE_UNAVAILABLE', @@ -299,26 +445,64 @@ void main() { ); }); - test('ApiException with code PRICE_SOURCE_UNAVAILABLE (non-503) → priceSourceUnavailable', () async { - when(() => service.getPaymentInfo(any(), currency: any(named: 'currency'))) - .thenAnswer( + test('ApiException 502 with empty message → Failure(priceSourceUnavailable)', () async { + when(() => service.getPaymentInfo(any(), currency: any(named: 'currency'))).thenAnswer( (_) async => throw const ApiException( - statusCode: 500, - code: 'PRICE_SOURCE_UNAVAILABLE', - message: 'unavailable', + statusCode: 502, + code: 'UNKNOWN', + message: '', ), ); final cubit = build(); await cubit.getPaymentInfo(amount: '300'); - expect((cubit.state as BuyPaymentInfoFailure).error, PaymentInfoError.priceSourceUnavailable); - expect((cubit.state as BuyPaymentInfoFailure).message, 'unavailable'); + final f = cubit.state as BuyPaymentInfoFailure; + expect(f.error, PaymentInfoError.priceSourceUnavailable); + expect(f.message, ''); + }); + + test('ApiException 502 with message → Failure(priceSourceUnavailable)', () async { + when(() => service.getPaymentInfo(any(), currency: any(named: 'currency'))).thenAnswer( + (_) async => throw const ApiException( + statusCode: 502, + code: 'UNKNOWN', + message: 'upstream down', + ), + ); + + final cubit = build(); + await cubit.getPaymentInfo(amount: '300'); + + final f = cubit.state as BuyPaymentInfoFailure; + expect(f.error, PaymentInfoError.priceSourceUnavailable); + expect(f.message, 'upstream down'); }); + test( + 'ApiException with code PRICE_SOURCE_UNAVAILABLE (non-503) → priceSourceUnavailable', + () async { + when(() => service.getPaymentInfo(any(), currency: any(named: 'currency'))).thenAnswer( + (_) async => throw const ApiException( + statusCode: 500, + code: 'PRICE_SOURCE_UNAVAILABLE', + message: 'unavailable', + ), + ); + + final cubit = build(); + await cubit.getPaymentInfo(amount: '300'); + + expect( + (cubit.state as BuyPaymentInfoFailure).error, + PaymentInfoError.priceSourceUnavailable, + ); + expect((cubit.state as BuyPaymentInfoFailure).message, 'unavailable'); + }, + ); + test('other ApiException (e.g. 400) → Failure(unknown)', () async { - when(() => service.getPaymentInfo(any(), currency: any(named: 'currency'))) - .thenAnswer( + when(() => service.getPaymentInfo(any(), currency: any(named: 'currency'))).thenAnswer( (_) async => throw const ApiException(statusCode: 400, code: 'BAD_REQUEST', message: 'bad'), ); @@ -331,8 +515,9 @@ void main() { test('does not emit after close', () async { final completer = Completer(); - when(() => service.getPaymentInfo(any(), currency: any(named: 'currency'))) - .thenAnswer((_) => completer.future); + when( + () => service.getPaymentInfo(any(), currency: any(named: 'currency')), + ).thenAnswer((_) => completer.future); final cubit = build(); unawaited(cubit.getPaymentInfo(amount: '300')); diff --git a/test/screens/buy/widgets/buy_confirm_button_test.dart b/test/screens/buy/widgets/buy_confirm_button_test.dart index 87cbdabe3..155168cb0 100644 --- a/test/screens/buy/widgets/buy_confirm_button_test.dart +++ b/test/screens/buy/widgets/buy_confirm_button_test.dart @@ -33,7 +33,7 @@ const _info = BuyPaymentInfo( currency: Currency.chf, ); -// The quote echoes the charged amount; a fractional echo must render rounded. +// The quote echoes the charged amount; rappen must render, not round to francs. const _quotedFractional = BuyPaymentInfo( amount: 300.75, id: 42, @@ -48,6 +48,20 @@ const _quotedFractional = BuyPaymentInfo( currency: Currency.chf, ); +const _eurInfo = BuyPaymentInfo( + amount: 300, + id: 42, + iban: 'CH9708307000560946317', + bic: 'BICCBIC', + name: 'RealUnit AG', + street: 'Bahnhofstrasse', + number: '1', + zip: '8001', + city: 'Zurich', + country: 'Switzerland', + currency: Currency.eur, +); + void main() { late _MockBuyConfirmCubit cubit; @@ -57,10 +71,10 @@ void main() { when(() => cubit.confirmPayment(any())).thenAnswer((_) async {}); }); - Widget host({GoRouter? router}) { + Widget host({GoRouter? router, BuyPaymentInfo info = _info}) { final view = BlocProvider.value( value: cubit, - child: const BuyConfirmButtonView(buyPaymentInfo: _info), + child: BuyConfirmButtonView(buyPaymentInfo: info), ); if (router != null) { return MaterialApp.router( @@ -104,6 +118,15 @@ void main() { verify(() => cubit.confirmPayment(42)).called(1); }); + testWidgets('tapping confirms an EUR quote; the CTA is not disabled', (tester) async { + await tester.pumpWidget(host(info: _eurInfo)); + + await tester.tap(find.text(S.current.buyPaymentConfirm)); + await tester.pump(); + + verify(() => cubit.confirmPayment(42)).called(1); + }); + testWidgets('shows a loading indicator while confirming', (tester) async { when(() => cubit.state).thenReturn(const BuyConfirmLoading()); @@ -200,7 +223,7 @@ void main() { }); testWidgets('shows the charged amount echoed by the quote on the details ' - 'page, rounded (300.75 → 301) — never derived from keystrokes', (tester) async { + 'page with rappen (300.75, not 301) — never derived from keystrokes', (tester) async { whenListen( cubit, Stream.fromIterable([ @@ -218,8 +241,30 @@ void main() { // The details amount is the quote's own echoed charge, so it can never // disagree with the SEPA transfer / QR the backend built for the quote. - expect(find.text('301'), findsOneWidget); - expect(find.text('300.75'), findsNothing); + expect(find.text('300.75'), findsOneWidget); + expect(find.text('301'), findsNothing); + }); + + testWidgets('after confirm success shows the EUR settlement IBAN and amount-in EUR, ' + 'not the CHF IBAN', (tester) async { + whenListen( + cubit, + Stream.fromIterable([ + const BuyConfirmSuccess( + reference: 'RU-REF-EUR', + remittanceInfo: null, + paymentRequest: null, + ), + ]), + initialState: const BuyConfirmInitial(), + ); + + await tester.pumpWidget(host(router: detailsRouter(info: _eurInfo))); + await tester.pumpAndSettle(); + + expect(find.text('CH97 0830 7000 5609 4631 7'), findsOneWidget); + expect(find.text('${S.current.amountIn} ${Currency.eur.code}'), findsOneWidget); + expect(find.text('CH22 0830 7000 5609 4630 9'), findsNothing); }); testWidgets('forward path: remittanceInfo + paymentRequest drive the ' diff --git a/test/screens/buy/widgets/payment_action_button_test.dart b/test/screens/buy/widgets/payment_action_button_test.dart index a05cdc96a..ef2af8c3f 100644 --- a/test/screens/buy/widgets/payment_action_button_test.dart +++ b/test/screens/buy/widgets/payment_action_button_test.dart @@ -15,8 +15,7 @@ import 'package:realunit_wallet/setup/routing/routes/app_routes.dart'; import 'package:realunit_wallet/setup/routing/routes/support_routes.dart'; import 'package:realunit_wallet/styles/currency.dart'; -class _MockBuyConverterCubit extends MockCubit - implements BuyConverterCubit {} +class _MockBuyConverterCubit extends MockCubit implements BuyConverterCubit {} class _MockBuyPaymentInfoCubit extends MockCubit implements BuyPaymentInfoCubit {} @@ -24,7 +23,6 @@ class _MockBuyPaymentInfoCubit extends MockCubit void main() { late BuyConverterCubit converterCubit; late BuyPaymentInfoCubit paymentInfoCubit; - late TextEditingController amountController; late List pushedRoutes; // Result the modelled email-capture page pops with; the buy gate // re-fetches the quote after the capture flow returns regardless of the @@ -40,13 +38,15 @@ void main() { setUp(() { converterCubit = _MockBuyConverterCubit(); paymentInfoCubit = _MockBuyPaymentInfoCubit(); - amountController = TextEditingController(text: '250'); pushedRoutes = []; emailCaptureResult = true; kycExtra = null; - when(() => converterCubit.state) - .thenReturn(const BuyConverterState(currency: Currency.eur)); + // fiatText models the typed amount; with no live payable the re-fetch + // falls back to it (quoteAmountText), so '250' is what the gates send. + when( + () => converterCubit.state, + ).thenReturn(const BuyConverterState(currency: Currency.eur, fiatText: '250')); when( () => paymentInfoCubit.getPaymentInfo( amount: any(named: 'amount'), @@ -55,8 +55,6 @@ void main() { ).thenAnswer((_) async {}); }); - tearDown(() => amountController.dispose()); - GoRouter buildRouter() { return GoRouter( initialLocation: '/', @@ -68,8 +66,8 @@ void main() { BlocProvider.value(value: paymentInfoCubit), BlocProvider.value(value: converterCubit), ], - child: Scaffold( - body: PaymentActionButton(amountController: amountController), + child: const Scaffold( + body: PaymentActionButton(), ), ), ), @@ -145,6 +143,13 @@ void main() { when(() => paymentInfoCubit.state).thenReturn( const BuyPaymentInfoFailure(PaymentInfoError.primaryEmailRequired), ); + when(() => converterCubit.state).thenReturn( + const BuyConverterState( + currency: Currency.eur, + fiatText: '250', + payableText: '249.50', + ), + ); await pumpButton(tester); @@ -154,10 +159,10 @@ void main() { // Routed to email capture, not to the binding-buy / details flow. expect(pushedRoutes, [SupportRoutes.emailCapture]); // After the capture flow returns, the quote is re-fetched with the - // current amount + currency so a now-valid quote surfaces the CTA. + // live payable (not the typed fiatText) so a now-valid quote surfaces. verify( () => paymentInfoCubit.getPaymentInfo( - amount: '250', + amount: '249.50', currency: Currency.eur, ), ).called(1); @@ -191,6 +196,13 @@ void main() { context: 'RealunitBuy', ), ); + when(() => converterCubit.state).thenReturn( + const BuyConverterState( + currency: Currency.eur, + fiatText: '250', + payableText: '249.50', + ), + ); await pumpButton(tester); @@ -201,16 +213,41 @@ void main() { expect(pushedRoutes, [AppRoutes.kyc]); expect(kycExtra, 'RealunitBuy'); // After the KYC flow returns, the quote is re-fetched with the - // current amount + currency so a now-confirmed email surfaces the CTA. + // live payable (not the typed fiatText) so a confirmed email surfaces. verify( () => paymentInfoCubit.getPaymentInfo( - amount: '250', + amount: '249.50', currency: Currency.eur, ), ).called(1); }, ); }); + + group('$PaymentActionButton maxAmountExceeded gate', () { + testWidgets( + 'renders disabled Next, the max-amount label, not confirm or retry', + (tester) async { + when(() => paymentInfoCubit.state).thenReturn( + const BuyPaymentInfoMaxAmountExceededFailure( + PaymentInfoError.maxAmountExceeded, + maxAmount: 90000.6, + ), + ); + when(() => converterCubit.state).thenReturn( + const BuyConverterState(currency: Currency.chf, fiatText: '90001'), + ); + + await pumpButton(tester); + + expect(find.text(S.current.next), findsOne); + expect(find.text(S.current.buyMaxAmount('90000', 'CHF')), findsOne); + expect(find.byType(BuyConfirmButton), findsNothing); + expect(find.text(S.current.buyPaymentConfirm), findsNothing); + expect(find.text(S.current.retry), findsNothing); + }, + ); + }); } /// Minimal capture-page stub that pops with a caller-controlled value on diff --git a/test/screens/buy/widgets/payment_details_card_test.dart b/test/screens/buy/widgets/payment_details_card_test.dart index 94021f45a..924af2846 100644 --- a/test/screens/buy/widgets/payment_details_card_test.dart +++ b/test/screens/buy/widgets/payment_details_card_test.dart @@ -38,6 +38,20 @@ const _rawIbanInfo = BuyPaymentInfo( currency: Currency.chf, ); +const _eurInfo = BuyPaymentInfo( + amount: 300, + id: 1, + iban: 'CH9708307000560946317', + bic: 'BICCBIC', + name: 'RealUnit AG', + street: 'Bahnhofstrasse', + number: '1', + zip: '8001', + city: 'Zurich', + country: 'Switzerland', + currency: Currency.eur, +); + // The card uses IconButton (needs a Material ancestor) and renders a tall list of // rows — host it in a scrollable Scaffold so it lays out without overflow. Widget _host({ @@ -67,6 +81,17 @@ void main() { expect(find.text('${S.current.amountIn} ${Currency.chf.code}'), findsOneWidget); }); + testWidgets('renders the EUR settlement IBAN and amount-in EUR, not the CHF IBAN', ( + tester, + ) async { + await tester.pumpApp(_host(buyPaymentInfo: _eurInfo)); + + expect(find.text('CH97 0830 7000 5609 4631 7'), findsOneWidget); + expect(find.text('${S.current.amountIn} ${Currency.eur.code}'), findsOneWidget); + expect(find.text('CH22 0830 7000 5609 4630 9'), findsNothing); + expect(find.text('${S.current.amountIn} ${Currency.chf.code}'), findsNothing); + }); + testWidgets('renders the purpose of payment when purposeOfPayment is set', (tester) async { await tester.pumpApp(_host(purposeOfPayment: 'REF-XYZ')); diff --git a/test/screens/buy/widgets/payment_information_test.dart b/test/screens/buy/widgets/payment_information_test.dart index f08995ce3..fe8634f6c 100644 --- a/test/screens/buy/widgets/payment_information_test.dart +++ b/test/screens/buy/widgets/payment_information_test.dart @@ -124,6 +124,37 @@ void main() { }, ); + testWidgets( + 'MaxAmountExceededFailure: message lives on the CTA, not PaymentActionRequired', + (tester) async { + when(() => cubit.state).thenReturn( + const BuyPaymentInfoMaxAmountExceededFailure( + PaymentInfoError.maxAmountExceeded, + maxAmount: 90000, + ), + ); + + await tester.pumpApp(_host(cubit)); + + expect(find.byType(PaymentActionRequired), findsNothing); + expect(find.byType(CupertinoActivityIndicator), findsNothing); + }, + ); + + testWidgets( + 'Failure(maxAmountExceeded) (without specialized class) falls through to SizedBox.shrink', + (tester) async { + when(() => cubit.state).thenReturn( + const BuyPaymentInfoFailure(PaymentInfoError.maxAmountExceeded), + ); + + await tester.pumpApp(_host(cubit)); + + expect(find.byType(PaymentActionRequired), findsNothing); + expect(find.byType(CupertinoActivityIndicator), findsNothing); + }, + ); + testWidgets('Initial (or unhandled): SizedBox.shrink', (tester) async { when(() => cubit.state).thenReturn(const BuyPaymentInfoInitial()); diff --git a/test/screens/buy_sell_states_test.dart b/test/screens/buy_sell_states_test.dart index e7763982e..fd1653ca7 100644 --- a/test/screens/buy_sell_states_test.dart +++ b/test/screens/buy_sell_states_test.dart @@ -106,6 +106,23 @@ void main() { expect(a, b); expect(a, isNot(c)); }); + + test('MaxAmountExceededFailure adds the maxAmount to props', () { + const a = BuyPaymentInfoMaxAmountExceededFailure( + PaymentInfoError.maxAmountExceeded, + maxAmount: 90000, + ); + const b = BuyPaymentInfoMaxAmountExceededFailure( + PaymentInfoError.maxAmountExceeded, + maxAmount: 90000, + ); + const c = BuyPaymentInfoMaxAmountExceededFailure( + PaymentInfoError.maxAmountExceeded, + maxAmount: 80000, + ); + expect(a, b); + expect(a, isNot(c)); + }); }); group('$SellPaymentInfoState equality', () { diff --git a/test/screens/dashboard/dashboard_bloc_test.dart b/test/screens/dashboard/dashboard_bloc_test.dart index 44be4c87c..9db1f655f 100644 --- a/test/screens/dashboard/dashboard_bloc_test.dart +++ b/test/screens/dashboard/dashboard_bloc_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/models/portfolio_value_point.dart'; @@ -33,16 +35,21 @@ void main() { }); DashboardBloc build({Currency currency = Currency.chf}) { - when(() => priceService.getPriceOfAsset(any(), any())) - .thenAnswer((_) async => BigInt.from(12345)); - when(() => priceService.getPriceChart(any(), any())).thenAnswer((_) async => [ - _pp(10000, DateTime.utc(2026, 1, 1)), - _pp(11000, DateTime.utc(2026, 2, 1)), - ]); - when(() => accountService.getPortfolioHistory(any())).thenAnswer((_) async => [ - _pt(50000, DateTime.utc(2026, 1, 1)), - _pt(52000, DateTime.utc(2026, 2, 1)), - ]); + when( + () => priceService.getPriceOfAsset(any(), any()), + ).thenAnswer((_) async => BigInt.from(12345)); + when(() => priceService.getPriceChart(any(), any())).thenAnswer( + (_) async => [ + _pp(10000, DateTime.utc(2026, 1, 1)), + _pp(11000, DateTime.utc(2026, 2, 1)), + ], + ); + when(() => accountService.getPortfolioHistory(any())).thenAnswer( + (_) async => [ + _pt(50000, DateTime.utc(2026, 1, 1)), + _pt(52000, DateTime.utc(2026, 2, 1)), + ], + ); return DashboardBloc( priceService, accountService, @@ -80,8 +87,7 @@ void main() { }); test('refresh survives priceService failure without crashing', () async { - when(() => priceService.getPriceOfAsset(any(), any())) - .thenThrow(Exception('503')); + when(() => priceService.getPriceOfAsset(any(), any())).thenThrow(Exception('503')); final bloc = build(); // If the handler crashes without try-catch, an unhandled error propagates. @@ -93,8 +99,7 @@ void main() { }); test('refresh survives priceChart failure without crashing', () async { - when(() => priceService.getPriceChart(any(), any())) - .thenThrow(Exception('timeout')); + when(() => priceService.getPriceChart(any(), any())).thenThrow(Exception('timeout')); final bloc = build(); await Future.delayed(const Duration(milliseconds: 100)); @@ -103,8 +108,7 @@ void main() { }); test('refresh survives portfolioHistory failure without crashing', () async { - when(() => accountService.getPortfolioHistory(any())) - .thenThrow(Exception('network')); + when(() => accountService.getPortfolioHistory(any())).thenThrow(Exception('network')); final bloc = build(); await Future.delayed(const Duration(milliseconds: 100)); @@ -118,8 +122,9 @@ void main() { await bloc.stream.firstWhere((s) => s.portfolioHistory.isNotEmpty); clearInteractions(priceService); clearInteractions(accountService); - when(() => priceService.getPriceOfAsset(any(), any())) - .thenAnswer((_) async => BigInt.from(99)); + when( + () => priceService.getPriceOfAsset(any(), any()), + ).thenAnswer((_) async => BigInt.from(99)); when(() => priceService.getPriceChart(any(), any())).thenAnswer((_) async => []); when(() => accountService.getPortfolioHistory(any())).thenAnswer((_) async => []); @@ -136,5 +141,103 @@ void main() { verify(() => priceService.getPriceChart(realUnitAsset, Currency.eur)).called(1); verify(() => accountService.getPortfolioHistory(Currency.eur)).called(1); }); + + test('CurrencyChangedEvent clears previous quotes before refetch', () async { + final bloc = build(); + await bloc.stream.firstWhere((s) => s.portfolioHistory.isNotEmpty); + expect(bloc.state.price, BigInt.from(12345)); + expect(bloc.state.priceChart, isNotEmpty); + expect(bloc.state.portfolioHistory, isNotEmpty); + + final heldPrice = Completer(); + when(() => priceService.getPriceOfAsset(any(), any())).thenAnswer((_) => heldPrice.future); + when( + () => priceService.getPriceChart(any(), any()), + ).thenAnswer((_) => Completer>().future); + when( + () => accountService.getPortfolioHistory(any()), + ).thenAnswer((_) => Completer>().future); + + bloc.add(const CurrencyChangedEvent(Currency.eur)); + await bloc.stream.firstWhere((s) => s.currency == Currency.eur); + + expect(bloc.state.price, BigInt.zero); + expect(bloc.state.priceChart, isEmpty); + expect(bloc.state.portfolioHistory, isEmpty); + await bloc.close(); + }); + + test('stale price is dropped after A→B→A even when the currency matches again', () async { + var chfCalls = 0; + final firstChf = Completer(); + final secondChf = Completer(); + when(() => priceService.getPriceOfAsset(any(), any())).thenAnswer((invocation) { + final currency = invocation.positionalArguments[1] as Currency; + if (currency == Currency.chf) { + chfCalls++; + return chfCalls == 1 ? firstChf.future : secondChf.future; + } + return Future.value(BigInt.from(50)); + }); + when(() => priceService.getPriceChart(any(), any())).thenAnswer((_) async => []); + when(() => accountService.getPortfolioHistory(any())).thenAnswer((_) async => []); + + final bloc = DashboardBloc( + priceService, + accountService, + asset: realUnitAsset, + initialCurrency: Currency.chf, + ); + bloc.add(const CurrencyChangedEvent(Currency.eur)); + await bloc.stream.firstWhere((s) => s.currency == Currency.eur); + bloc.add(const CurrencyChangedEvent(Currency.chf)); + await bloc.stream.firstWhere((s) => s.currency == Currency.chf && s.price == BigInt.zero); + + firstChf.complete(BigInt.from(111)); + await Future.delayed(const Duration(milliseconds: 50)); + expect(bloc.state.price, BigInt.zero); + + secondChf.complete(BigInt.from(999)); + await bloc.stream.firstWhere((s) => s.price == BigInt.from(999)); + expect(bloc.state.currency, Currency.chf); + await bloc.close(); + }); + + test('late price for the previous currency is dropped after CurrencyChangedEvent', () async { + final stalePrice = Completer(); + when(() => priceService.getPriceOfAsset(any(), any())).thenAnswer((invocation) { + final currency = invocation.positionalArguments[1] as Currency; + if (currency == Currency.chf) return stalePrice.future; + return Future.value(BigInt.from(99)); + }); + when(() => priceService.getPriceChart(any(), any())).thenAnswer( + (_) async => [ + _pp(10000, DateTime.utc(2026, 1, 1)), + ], + ); + when(() => accountService.getPortfolioHistory(any())).thenAnswer( + (_) async => [ + _pt(50000, DateTime.utc(2026, 1, 1)), + ], + ); + + final bloc = DashboardBloc( + priceService, + accountService, + asset: realUnitAsset, + initialCurrency: Currency.chf, + ); + bloc.add(const CurrencyChangedEvent(Currency.eur)); + await bloc.stream.firstWhere( + (s) => s.currency == Currency.eur && s.price == BigInt.from(99), + ); + + stalePrice.complete(BigInt.from(12345)); + await Future.delayed(const Duration(milliseconds: 50)); + + expect(bloc.state.currency, Currency.eur); + expect(bloc.state.price, BigInt.from(99)); + await bloc.close(); + }); }); } diff --git a/test/screens/dashboard/dashboard_responsive_matrix_test.dart b/test/screens/dashboard/dashboard_responsive_matrix_test.dart index f7bd104d6..64b65e745 100644 --- a/test/screens/dashboard/dashboard_responsive_matrix_test.dart +++ b/test/screens/dashboard/dashboard_responsive_matrix_test.dart @@ -8,7 +8,7 @@ // painting the CTA outside the parent's hit-testable region. // // A second matrix group below covers the same tappability guarantee for the -// four DashboardActions buttons (Buy/Sell/Pay/Send) in a standalone host — +// three DashboardActions buttons (Buy/Sell/Pay) in a standalone host — // pre-existing overflow debt in the surrounding dashboard sections (e.g. // cash_holding_box.dart) is tracked separately as issue #887 and out of // scope for that check. @@ -109,9 +109,9 @@ void main() { ), ); - // A minimal five-route stack: '/' hosts either the full dashboard or the + // A minimal four-route stack: '/' hosts either the full dashboard or the // standalone DashboardActions host (see [homeBuilder]); '/buy', '/sell', - // '/pay' and '/send' are marker pages so the four action buttons' real, + // and '/pay' are marker pages so the three action buttons' real, // unmocked `context.pushNamed(...)` calls resolve instead of throwing. GoRouter buildRouter({ Widget Function(BuildContext, GoRouterState)? homeBuilder, @@ -140,13 +140,6 @@ void main() { body: Center(child: Text('pay-page-marker')), ), ), - GoRoute( - name: AppRoutes.send, - path: '/send', - builder: (_, _) => const Scaffold( - body: Center(child: Text('send-page-marker')), - ), - ), ], ); @@ -242,7 +235,7 @@ void main() { }); group( - 'DashboardActions responsive matrix - insider unlocked, all four actions ' + 'DashboardActions responsive matrix - insider unlocked, all three actions ' 'tappable (full device x textScale)', () { for (final cell in kFullResponsiveMatrix) { @@ -282,13 +275,6 @@ void main() { reason: '${cell.label}: pay button not tappable', ); - await pumpActions(tester, cell); - await expectFullyTappable( - tester, - find.text(S.current.send), - within: find.byType(DashboardActions), - reason: '${cell.label}: send button not tappable', - ); }); }); } diff --git a/test/screens/dashboard/portfolio_chart_cubit_test.dart b/test/screens/dashboard/portfolio_chart_cubit_test.dart index 0fd01d7cc..db96e1e78 100644 --- a/test/screens/dashboard/portfolio_chart_cubit_test.dart +++ b/test/screens/dashboard/portfolio_chart_cubit_test.dart @@ -99,10 +99,9 @@ void main() { expect(cubit.state.maxY, 104.0); }); - test('zero-average flat series collapses horizontal lines to a single value', () { - // Guards the `deviation <= 0` branch in _calculateHorizontalLines: - // when value == 0 the average is 0 and the 5% floor is 0, so the - // padded deviation is 0 → all 6 lines fall on `average`. + test('zero-average flat series keeps a visible Y-range from 0', () { + // average 0 → relative 5% floor is 0 → absolute floor 5 → interval 2 + // → lines start at 0 (holdings never go negative). final points = [ _pt(DateTime.utc(2026, 1, 1), 0), _pt(DateTime.utc(2026, 2, 1), 0), @@ -111,10 +110,33 @@ void main() { final cubit = PortfolioChartCubit(points); cubit.selectPeriod(TimePeriod.all); - expect(cubit.state.horizontalLineValues, hasLength(6)); - expect(cubit.state.horizontalLineValues.every((v) => v == 0.0), isTrue); + expect(cubit.state.visibleSpots, hasLength(2)); + expect(cubit.state.visibleSpots.every((s) => s.y == 0.0), isTrue); + expect( + cubit.state.horizontalLineValues, + [0.0, 2.0, 4.0, 6.0, 8.0, 10.0], + ); expect(cubit.state.minY, 0.0); - expect(cubit.state.maxY, 0.0); + expect(cubit.state.maxY, 10.0); + }); + + test('oneWeek all-zero window keeps a visible Y-range around 0', () { + final now = DateTime.now(); + final points = [ + _pt(now.subtract(const Duration(days: 60)), 0), + _pt(now.subtract(const Duration(days: 3)), 0), + _pt(now.subtract(const Duration(days: 1)), 0), + ]; + + final cubit = PortfolioChartCubit(points); + cubit.selectPeriod(TimePeriod.oneWeek); + + expect(cubit.state.visibleSpots, hasLength(3)); + expect(cubit.state.visibleSpots.every((s) => s.y == 0.0), isTrue); + expect(cubit.state.visibleSpots.first.x, cubit.state.minX); + expect(cubit.state.minY, 0.0); + expect(cubit.state.maxY, 10.0); + expect(cubit.state.minY < cubit.state.maxY, isTrue); }); test('selectPeriod to the same period is a no-op (no emit)', () async { @@ -138,8 +160,10 @@ void main() { cubit.selectPeriod(TimePeriod.oneWeek); expect(cubit.state.selectedPeriod, TimePeriod.oneWeek); - expect(cubit.state.visibleSpots, hasLength(1)); - expect(cubit.state.visibleSpots.single.y, 60.0); + expect(cubit.state.visibleSpots, hasLength(2)); + expect(cubit.state.visibleSpots.first.x, cubit.state.minX); + expect(cubit.state.visibleSpots.first.y, 50.0); + expect(cubit.state.visibleSpots.last.y, 60.0); }); test('selectPeriod to oneMonth narrows visibleSpots to the 1-month window', () { @@ -151,8 +175,10 @@ void main() { cubit.selectPeriod(TimePeriod.oneMonth); expect(cubit.state.selectedPeriod, TimePeriod.oneMonth); - expect(cubit.state.visibleSpots, hasLength(1)); - expect(cubit.state.visibleSpots.single.y, 70.0); + expect(cubit.state.visibleSpots, hasLength(2)); + expect(cubit.state.visibleSpots.first.x, cubit.state.minX); + expect(cubit.state.visibleSpots.first.y, 50.0); + expect(cubit.state.visibleSpots.last.y, 70.0); }); test('selectPeriod to threeMonths narrows visibleSpots to the 3-month window', () { @@ -164,8 +190,10 @@ void main() { cubit.selectPeriod(TimePeriod.threeMonths); expect(cubit.state.selectedPeriod, TimePeriod.threeMonths); - expect(cubit.state.visibleSpots, hasLength(1)); - expect(cubit.state.visibleSpots.single.y, 80.0); + expect(cubit.state.visibleSpots, hasLength(2)); + expect(cubit.state.visibleSpots.first.x, cubit.state.minX); + expect(cubit.state.visibleSpots.first.y, 50.0); + expect(cubit.state.visibleSpots.last.y, 80.0); }); test('selectPeriod to oneYear narrows visibleSpots to the 1-year window', () { @@ -177,8 +205,42 @@ void main() { cubit.selectPeriod(TimePeriod.oneYear); expect(cubit.state.selectedPeriod, TimePeriod.oneYear); - expect(cubit.state.visibleSpots, hasLength(1)); - expect(cubit.state.visibleSpots.single.y, 90.0); + expect(cubit.state.visibleSpots, hasLength(2)); + expect(cubit.state.visibleSpots.first.x, cubit.state.minX); + expect(cubit.state.visibleSpots.first.y, 50.0); + expect(cubit.state.visibleSpots.last.y, 90.0); + }); + + test('oneYear holds a pre-window zero across a gap so the line spans minX', () { + // Customer-shaped 1J: last sample before the window is 0, next interior + // sample is months later. Without the hold, the stroke started at 50%. + final now = DateTime.now(); + final cubit = PortfolioChartCubit([ + _pt(now.subtract(const Duration(days: 370)), 0), + _pt(now.subtract(const Duration(days: 180)), 0), + _pt(now, 0), + ]); + cubit.selectPeriod(TimePeriod.oneYear); + + expect(cubit.state.visibleSpots.length, greaterThanOrEqualTo(3)); + expect(cubit.state.visibleSpots.first.x, cubit.state.minX); + expect(cubit.state.visibleSpots.every((s) => s.y == 0.0), isTrue); + }); + + test('oneYear does not duplicate a sample that already sits on minX', () { + final now = DateTime.now(); + final start = DateTime(now.year - 1, now.month, now.day); + final cubit = PortfolioChartCubit([ + _pt(start.subtract(const Duration(days: 10)), 5000), + _pt(start, 6000), + _pt(now, 7000), + ]); + cubit.selectPeriod(TimePeriod.oneYear); + + expect(cubit.state.visibleSpots, hasLength(2)); + expect(cubit.state.visibleSpots.first.x, start.millisecondsSinceEpoch.toDouble()); + expect(cubit.state.visibleSpots.first.y, 60.0); + expect(cubit.state.visibleSpots.last.y, 70.0); }); test('minX is clamped to first price time even when the period predates it', () { diff --git a/test/screens/dashboard/widgets/sections/dashboard_actions_test.dart b/test/screens/dashboard/widgets/sections/dashboard_actions_test.dart index 2c0aa6f86..71f8245c8 100644 --- a/test/screens/dashboard/widgets/sections/dashboard_actions_test.dart +++ b/test/screens/dashboard/widgets/sections/dashboard_actions_test.dart @@ -49,7 +49,6 @@ void main() { target(AppRoutes.buy, '/buy'), target(AppRoutes.sell, '/sell'), target(AppRoutes.pay, '/pay'), - target(AppRoutes.send, '/send'), ], ); } @@ -107,14 +106,14 @@ void main() { .thenReturn(const SettingsState(insiderFeaturesUnlocked: true)); }); - testWidgets('renders the buy, sell, pay and send action buttons', (tester) async { + testWidgets('renders the buy, sell and pay action buttons', (tester) async { await pumpActions(tester); expect(actionButtonByLabel(S.current.buy), findsOneWidget); expect(actionButtonByLabel(S.current.sell), findsOneWidget); expect(actionButtonByLabel(S.current.pay), findsOneWidget); - expect(actionButtonByLabel(S.current.send), findsOneWidget); - expect(find.byType(Expanded), findsNWidgets(4)); + expect(actionButtonByLabel(S.current.send), findsNothing); + expect(find.byType(Expanded), findsNWidgets(3)); }); testWidgets('renders the expected icons for each action', (tester) async { @@ -123,7 +122,6 @@ void main() { expect(find.byIcon(Icons.add_circle_rounded), findsOneWidget); expect(find.byIcon(Icons.do_not_disturb_on_rounded), findsOneWidget); expect(find.byIcon(Icons.qr_code_scanner_rounded), findsOneWidget); - expect(find.byIcon(Icons.send_rounded), findsOneWidget); }); testWidgets('buy button pushes the buy route', (tester) async { @@ -147,12 +145,6 @@ void main() { expect(pushedRoutes, [AppRoutes.pay]); }); - testWidgets('send button pushes the send route', (tester) async { - await pumpActions(tester); - await tester.tap(actionButtonByLabel(S.current.send)); - await tester.pumpAndSettle(); - expect(pushedRoutes, [AppRoutes.send]); - }); }); group('transitions', () { @@ -181,7 +173,7 @@ void main() { await tester.pump(); expect(actionButtonByLabel(S.current.pay), findsOneWidget); - expect(actionButtonByLabel(S.current.send), findsOneWidget); + expect(actionButtonByLabel(S.current.send), findsNothing); }, ); }); diff --git a/test/screens/kyc/cubits/kyc/kyc_cubit_test.dart b/test/screens/kyc/cubits/kyc/kyc_cubit_test.dart index 804e99e6f..2c7424ba9 100644 --- a/test/screens/kyc/cubits/kyc/kyc_cubit_test.dart +++ b/test/screens/kyc/cubits/kyc/kyc_cubit_test.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:bloc_test/bloc_test.dart'; import 'package:fake_async/fake_async.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; import 'package:mocktail/mocktail.dart'; import 'package:realunit_wallet/packages/service/app_store.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_kyc_service.dart'; @@ -24,6 +25,9 @@ import 'package:realunit_wallet/packages/service/dfx/real_unit_legal_service.dar import 'package:realunit_wallet/packages/service/dfx/real_unit_registration_service.dart'; import 'package:realunit_wallet/packages/wallet/wallet.dart'; import 'package:realunit_wallet/screens/kyc/cubits/kyc/kyc_cubit.dart'; +import 'package:realunit_wallet/screens/settings/bloc/settings_bloc.dart'; +import 'package:realunit_wallet/setup/account_currency_sync.dart'; +import 'package:realunit_wallet/styles/currency.dart'; class _MockDfxKycService extends Mock implements DfxKycService {} @@ -35,15 +39,19 @@ class _MockAppStore extends Mock implements AppStore {} class _MockAWallet extends Mock implements AWallet {} +class _MockSettingsBloc extends MockBloc implements SettingsBloc {} + UserKycDto _kycHeader({KycLevel level = KycLevel.level0}) => UserKycDto(hash: 'h', level: level, dataComplete: false); UserDto _user({ String? mail = 'test@example.com', KycLevel headerLevel = KycLevel.level0, + Currency? currency, }) => UserDto( mail: mail, kyc: _kycHeader(level: headerLevel), + currency: currency, ); KycStepDto _step( @@ -153,6 +161,7 @@ void main() { setUpAll(() { registerFallbackValue([]); + registerFallbackValue(const ApplyAccountCurrencyEvent(Currency.chf)); }); setUp(() { @@ -195,6 +204,143 @@ void main() { ], ); + blocTest( + 'applies account currency from GET /v2/user when a wallet is open', + setUp: () { + final settings = _MockSettingsBloc(); + GetIt.instance.registerSingleton(settings); + GetIt.instance.registerSingleton( + AccountCurrencySync( + settings: settings, + kyc: kycService, + currentWallet: () => wallet, + ), + ); + when(() => kycService.getKycStatus()).thenAnswer( + (_) async => _kycStatus(level: KycLevel.level0), + ); + when(() => kycService.getUser()).thenAnswer( + (_) async => _user(mail: null, currency: Currency.chf), + ); + }, + build: buildCubit, + act: (cubit) => cubit.checkKyc(), + expect: () => [ + const KycLoading(), + const KycSuccess(currentStep: KycStep.email), + ], + verify: (_) { + verify( + () => GetIt.instance().add(const ApplyAccountCurrencyEvent(Currency.chf)), + ).called(1); + }, + tearDown: () async => GetIt.instance.reset(), + ); + + blocTest( + 'does not dispatch ApplyAccountCurrencyEvent when GET /v2/user has no currency', + setUp: () { + GetIt.instance.registerSingleton(_MockSettingsBloc()); + when(() => kycService.getKycStatus()).thenAnswer( + (_) async => _kycStatus(level: KycLevel.level0), + ); + when(() => kycService.getUser()).thenAnswer((_) async => _user(mail: null)); + }, + build: buildCubit, + act: (cubit) => cubit.checkKyc(), + expect: () => [ + const KycLoading(), + const KycSuccess(currentStep: KycStep.email), + ], + verify: (_) { + verifyNever(() => GetIt.instance().add(any())); + }, + tearDown: () async => GetIt.instance.reset(), + ); + + blocTest( + 'does not dispatch ApplyAccountCurrencyEvent when the wallet is closed', + setUp: () { + final settings = _MockSettingsBloc(); + GetIt.instance.registerSingleton(settings); + GetIt.instance.registerSingleton( + AccountCurrencySync( + settings: settings, + kyc: kycService, + currentWallet: () => null, + ), + ); + when(() => kycService.getKycStatus()).thenAnswer( + (_) async => _kycStatus(level: KycLevel.level0), + ); + when(() => kycService.getUser()).thenAnswer( + (_) async => _user(mail: null, currency: Currency.chf), + ); + }, + build: buildCubit, + act: (cubit) => cubit.checkKyc(), + expect: () => [ + const KycLoading(), + const KycSuccess(currentStep: KycStep.email), + ], + verify: (_) { + verifyNever(() => GetIt.instance().add(any())); + }, + tearDown: () async => GetIt.instance.reset(), + ); + + test('does not dispatch ApplyAccountCurrencyEvent after a different wallet opens', () async { + final settings = _MockSettingsBloc(); + GetIt.instance.registerSingleton(settings); + AWallet? current = wallet; + GetIt.instance.registerSingleton( + AccountCurrencySync( + settings: settings, + kyc: kycService, + currentWallet: () => current, + ), + ); + when(() => kycService.getKycStatus()).thenAnswer( + (_) async => _kycStatus(level: KycLevel.level0), + ); + final held = Completer(); + when(() => kycService.getUser()).thenAnswer((_) => held.future); + + final cubit = buildCubit(); + final pending = cubit.checkKyc(); + + current = _MockAWallet(); + held.complete(_user(mail: null, currency: Currency.chf)); + await pending; + + verifyNever(() => settings.add(any())); + await cubit.close(); + await GetIt.instance.reset(); + }); + + blocTest( + 'does not dispatch ApplyAccountCurrencyEvent when AccountCurrencySync is unregistered', + setUp: () { + GetIt.instance.registerSingleton(_MockSettingsBloc()); + when(() => kycService.getKycStatus()).thenAnswer( + (_) async => _kycStatus(level: KycLevel.level0), + ); + when(() => kycService.getUser()).thenAnswer( + (_) async => _user(mail: null, currency: Currency.chf), + ); + }, + build: buildCubit, + act: (cubit) => cubit.checkKyc(), + expect: () => [ + const KycLoading(), + const KycSuccess(currentStep: KycStep.email), + ], + verify: (_) { + verifyNever(() => GetIt.instance().add(any())); + }, + tearDown: () async => GetIt.instance.reset(), + ); + blocTest( 'auto-registers email when mail exists but level < 10, then recurses', setUp: () { diff --git a/test/screens/pay/pay_info_page_test.dart b/test/screens/pay/pay_info_page_test.dart new file mode 100644 index 000000000..edbea6c40 --- /dev/null +++ b/test/screens/pay/pay_info_page_test.dart @@ -0,0 +1,32 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/screens/pay/pay_info_page.dart'; +import 'package:realunit_wallet/screens/pay/pay_scan_page.dart'; + +import '../../helper/helper.dart'; + +void main() { + setUpAll(stubMobileScannerChannel); + + group('$PayInfoPage', () { + testWidgets('shows the OpenCryptoPay exchange disclosure', (tester) async { + await tester.pumpApp(const PayInfoPage()); + + expect(find.text(S.current.payInfoTitle), findsOneWidget); + expect(find.text(S.current.payInfoBody), findsOneWidget); + expect(find.text(S.current.next), findsOneWidget); + }); + + testWidgets('continues to the scanner with the initial payload', (tester) async { + const initialPayload = 'payload-from-deeplink'; + await tester.pumpApp(const PayInfoPage(initialPayload: initialPayload)); + + await tester.tap(find.text(S.current.next)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + + final page = tester.widget(find.byType(PayScanPage)); + expect(page.initialPayload, initialPayload); + }); + }); +} diff --git a/test/screens/pay/pay_info_responsive_matrix_test.dart b/test/screens/pay/pay_info_responsive_matrix_test.dart new file mode 100644 index 000000000..92e8e7c33 --- /dev/null +++ b/test/screens/pay/pay_info_responsive_matrix_test.dart @@ -0,0 +1,61 @@ +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/pay/pay_info_page.dart'; +import 'package:realunit_wallet/styles/themes.dart'; + +import '../../helper/helper.dart'; + +Future _pumpScreen( + WidgetTester tester, + MatrixCell cell, + Widget child, +) async { + await tester.binding.setSurfaceSize(cell.mediaQuery.size); + addTearDown(() async => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget( + MediaQuery( + data: cell.mediaQuery, + child: MaterialApp( + theme: realUnitTheme, + locale: const Locale('de'), + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + supportedLocales: S.delegate.supportedLocales, + home: child, + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); +} + +void main() { + setUpAll(stubMobileScannerChannel); + + group('PayInfoPage responsive matrix (full device × textScale)', () { + for (final cell in kFullResponsiveMatrix) { + testWidgets(cell.id, (tester) async { + await withTargetPlatform(cell.device.platform, () async { + await expectNoLayoutOverflow( + tester, + () => _pumpScreen(tester, cell, const PayInfoPage()), + reason: 'PayInfoPage overflow / ${cell.label}', + ); + + await expectFullyTappable( + tester, + find.widgetWithText(FilledButton, S.current.next), + within: find.byType(PayInfoPage), + reason: 'PayInfoPage / ${cell.label}: Continue CTA not tappable', + ); + }); + }); + } + }); +} diff --git a/test/screens/pay/pay_process_page_test.dart b/test/screens/pay/pay_process_page_test.dart index 42d2eb967..2882fcfe0 100644 --- a/test/screens/pay/pay_process_page_test.dart +++ b/test/screens/pay/pay_process_page_test.dart @@ -40,9 +40,11 @@ void main() { setUpAll(() { final getIt = GetIt.instance; - // PayProcessPage resolves a full service graph from getIt and calls - // start(). A debug wallet makes start() settle immediately - // (signatureUnsupported) without touching the chain. + // PayProcessPage resolves a full service graph from getIt and creates + // the cubit without start(); a route gate starts it after the route + // animation completes (immediately when pumped as home). A debug wallet + // makes start() settle immediately (signatureUnsupported) without + // touching the chain. final payService = _MockPayService(); getIt.registerSingleton(payService); getIt.registerSingleton(_MockFaucetService()); @@ -74,12 +76,65 @@ void main() { group('$PayProcessPage', () { testWidgets('builds its own cubit and renders $PayProcessView', (tester) async { await tester.pumpApp(const PayProcessPage(paymentLinkId: 'pl_abc', zchfNeeded: 42.7)); - // start() runs and emits a failure on the debug wallet; pump a frame to - // let the cubit settle (the sheet animation is not awaited here). + // The route gate starts the cubit after the home route is settled; pump + // a frame so start() can emit (the sheet animation is not awaited here). await tester.pump(); expect(find.byType(PayProcessView), findsOne); }); + + testWidgets('keeps preparing UI during route slide; starts only after animation completes', ( + tester, + ) async { + await tester.pumpApp( + Builder( + builder: (context) => Scaffold( + body: Center( + child: TextButton( + onPressed: () { + Navigator.of(context).push( + TimedMaterialPageRoute( + transitionDuration: const Duration(milliseconds: 300), + builder: (_) => + const PayProcessPage(paymentLinkId: 'pl_abc', zchfNeeded: 42.7), + ), + ); + }, + child: const Text('go'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('go')); + await tester.pump(); + await tester.pump(); // flush the post-frame callback + await tester.pump(const Duration(milliseconds: 50)); + + expect(find.byType(PayProcessView), findsOne); + expect( + find.text(S.current.payPreparingSwap, skipOffstage: false), + findsOne, + ); + expect( + find.text(S.current.payFailureTitle, skipOffstage: false), + findsNothing, + ); + expect( + find.text(S.current.close, skipOffstage: false), + findsNothing, + ); + + await tester.pump(const Duration(milliseconds: 300)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + + // Body label and the result sheet both use payFailureTitle. + expect(find.text(S.current.payFailureTitle), findsAtLeast(1)); + expect(find.text(S.current.close), findsOne); + expect(find.byIcon(Icons.error_rounded), findsOne); + }); }); group('$PayProcessView progress labels', () { diff --git a/test/screens/pay/pay_quote_page_test.dart b/test/screens/pay/pay_quote_page_test.dart index a003fd44e..f4bf037e7 100644 --- a/test/screens/pay/pay_quote_page_test.dart +++ b/test/screens/pay/pay_quote_page_test.dart @@ -52,9 +52,11 @@ void main() { setUpAll(() { final getIt = GetIt.instance; - // PayQuotePage resolves the pay service from getIt and calls load(); the - // load throws a typed error here so the pushed route builds deterministically - // (rendering PayQuoteError) without a live backend. + // PayQuotePage resolves the pay service from getIt and creates the cubit + // without load(); a route gate calls load() after the route animation + // completes (immediately when pumped as home). The load throws a typed + // error here so the page builds deterministically (PayQuoteError) without + // a live backend. final payService = _MockPayService(); when(() => payService.getPaymentDetails(any())).thenThrow( const ApiException(code: 'TEST', message: 'no backend in widget test'), @@ -62,8 +64,9 @@ void main() { getIt.registerSingleton(payService); // The confirm button pushes PayProcessPage, which resolves a full service - // graph from getIt and calls start(). A debug wallet makes start() settle - // immediately (signatureUnsupported) without touching the chain. + // graph from getIt; its route gate starts the cubit after animation. + // A debug wallet makes start() settle immediately (signatureUnsupported) + // without touching the chain. getIt.registerSingleton(_MockFaucetService()); getIt.registerSingleton(_MockBlockchainService()); getIt.registerSingleton(_MockWalletService()); diff --git a/test/screens/pay/pay_scan_page_test.dart b/test/screens/pay/pay_scan_page_test.dart index 404af7bff..ce77b4630 100644 --- a/test/screens/pay/pay_scan_page_test.dart +++ b/test/screens/pay/pay_scan_page_test.dart @@ -100,25 +100,33 @@ void main() { verify(() => scanCubit.reset()).called(1); }); - testWidgets('a decoded link navigates to the quote step and resets the cubit', (tester) async { - final link = DecodedPaymentLink( - id: 'pl_abc123', - lnurlpUrl: Uri.parse('https://api.dfx.swiss/v1/lnurlp/pl_abc123'), - ); - whenListen( - scanCubit, - Stream.fromIterable([PayScanDecoded(link)]), - initialState: const PayScanScanning(), - ); + testWidgets( + 'a decoded link navigates to the quote step; reset waits until pop', + (tester) async { + final link = DecodedPaymentLink( + id: 'pl_abc123', + lnurlpUrl: Uri.parse('https://api.dfx.swiss/v1/lnurlp/pl_abc123'), + ); + whenListen( + scanCubit, + Stream.fromIterable([PayScanDecoded(link)]), + initialState: const PayScanScanning(), + ); - await tester.pumpApp(buildSubject()); - await tester.pumpAndSettle(); + await tester.pumpApp(buildSubject()); + await tester.pumpAndSettle(); - // The quote step is pushed and rendered; the cubit is reset so returning - // to the scanner re-arms detection. - expect(find.byType(PayQuoteView), findsOne); - verify(() => scanCubit.reset()).called(1); - }); + // The quote step is pushed and rendered; reset is deferred until pop + // so the Decoded guard still blocks frame 2 from double-pushing. + expect(find.byType(PayQuoteView), findsOne); + verifyNever(() => scanCubit.reset()); + + await tester.pageBack(); + await tester.pumpAndSettle(); + + verify(() => scanCubit.reset()).called(1); + }, + ); testWidgets( 'errorBuilder shows the camera-unavailable message for non-permission-denied errors', @@ -193,9 +201,14 @@ void main() { ); await tester.pumpAndSettle(); - // Spinner dismissed, quote step pushed and rendered; the cubit is reset. + // Spinner dismissed, quote step pushed; reset waits until pop. expect(find.byType(CupertinoActivityIndicator), findsNothing); expect(find.byType(PayQuoteView), findsOne); + verifyNever(() => scanCubit.reset()); + + await tester.pageBack(); + await tester.pumpAndSettle(); + verify(() => scanCubit.reset()).called(1); }, ); diff --git a/test/screens/pay/pay_scan_scanner_navigation_test.dart b/test/screens/pay/pay_scan_scanner_navigation_test.dart new file mode 100644 index 000000000..eafcd2253 --- /dev/null +++ b/test/screens/pay/pay_scan_scanner_navigation_test.dart @@ -0,0 +1,87 @@ +// Gate for the QR scanner double-stack bug: a live camera delivers the same +// LNURL on every frame. Pushing the quote step and resetting the cubit in the +// same turn drops the Decoded guard, so frame 2 pushes a second PayQuotePage. +// This test fires two BarcodeCaptures against the real cubit and expects +// PayQuoteView exactly once; after pop, a third capture is accepted again. +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_pay_service.dart'; +import 'package:realunit_wallet/screens/pay/pay_quote_page.dart'; +import 'package:realunit_wallet/screens/pay/pay_scan_page.dart'; + +import '../../helper/helper.dart'; + +class _MockPayService extends Mock implements RealUnitPayService {} + +void main() { + // LUD-01 LNURL from pay_scan_cubit_test.dart. + const lnurl = + 'LNURL1DP68GURN8GHJ7CTSDYHXGENC9EEHW6TNWVHHVVF0D3H82UNVWQHHQMZLV93XXVFJXV5T0E5A'; + + setUpAll(() { + stubMobileScannerChannel(); + + // PayQuotePage resolves RealUnitPayService from getIt and triggers load(); + // throw so the pushed route builds deterministically without a backend. + final payService = _MockPayService(); + when(() => payService.getPaymentDetails(any())).thenThrow( + const ApiException(code: 'TEST', message: 'no backend in widget test'), + ); + GetIt.instance.registerSingleton(payService); + }); + + tearDownAll(() async => GetIt.instance.reset()); + + testWidgets( + 'two identical captures push PayQuoteView once; re-arms after pop', + (tester) async { + await tester.pumpApp(const PayScanPage()); + + final scanner = tester.widget(find.byType(MobileScanner)); + const capture = BarcodeCapture(barcodes: [Barcode(rawValue: lnurl)]); + scanner.onDetect!(capture); + scanner.onDetect!(capture); // second frame — must not push again + await tester.pumpAndSettle(); + + expect(find.byType(PayQuoteView), findsOne); + + // Quote may be in an error state from the mocked service; pop via the + // navigator on that view rather than pageBack if a sheet blocks. + Navigator.of(tester.element(find.byType(PayQuoteView))).pop(); + await tester.pumpAndSettle(); + expect(find.byType(PayQuoteView), findsNothing); + + final scannerAfterPop = tester.widget(find.byType(MobileScanner)); + scannerAfterPop.onDetect!(capture); + await tester.pumpAndSettle(); + expect(find.byType(PayQuoteView), findsOne); + }, + ); + + testWidgets( + 'a capture during the pop animation does not push a second quote page', + (tester) async { + await tester.pumpApp(const PayScanPage()); + + final scanner = tester.widget(find.byType(MobileScanner)); + final onDetect = scanner.onDetect!; + const capture = BarcodeCapture(barcodes: [Barcode(rawValue: lnurl)]); + onDetect(capture); + await tester.pumpAndSettle(); + expect(find.byType(PayQuoteView), findsOne); + + Navigator.of(tester.element(find.byType(PayQuoteView))).pop(); + await tester.pump(); // pop animation in flight — not settled + onDetect(capture); + await tester.pump(); + expect(find.byType(PayQuoteView), findsOne); + + await tester.pumpAndSettle(); + expect(find.byType(PayQuoteView), findsNothing); + }, + ); +} diff --git a/test/screens/receive/receive_page_test.dart b/test/screens/receive/receive_page_test.dart new file mode 100644 index 000000000..49bc3c3eb --- /dev/null +++ b/test/screens/receive/receive_page_test.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.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/app_store.dart'; +import 'package:realunit_wallet/screens/receive/receive_page.dart'; +import 'package:realunit_wallet/screens/receive/widgets/qr_address_widget.dart'; +import 'package:realunit_wallet/setup/routing/routes/app_routes.dart'; +import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart'; + +import '../../helper/helper.dart'; + +void main() { + final AppStore appStore = MockAppStore(); + + setUpAll(() { + GetIt.instance.registerSingleton(appStore); + }); + + setUp(() { + when(() => appStore.primaryAddress) + .thenReturn('0x938115b533a0b746428361760a6972dfd06d984a'); + }); + + tearDownAll(() async { + await GetIt.instance.reset(); + }); + + Finder sendButton() => find.widgetWithText(AppFilledButton, S.current.send); + + group('$ReceivePage', () { + testWidgets('bottom-sheet variant renders QR and Send', (tester) async { + await tester.pumpApp(const ReceivePage()); + + expect(find.byType(AppBar), findsNothing); + expect(find.byType(QRAddressWidget), findsOneWidget); + expect(sendButton(), findsOneWidget); + }); + + testWidgets('full-page variant renders AppBar back, QR and Send', + (tester) async { + await tester.pumpApp(const ReceivePage(isBottomSheet: false)); + + expect(find.byType(AppBar), findsOneWidget); + expect(find.byIcon(Icons.arrow_back_rounded), findsOneWidget); + expect(find.byType(QRAddressWidget), findsOneWidget); + expect(sendButton(), findsOneWidget); + }); + + testWidgets('tapping Send pushes the send route', (tester) async { + final pushedRoutes = []; + final router = GoRouter( + initialLocation: '/', + routes: [ + GoRoute( + path: '/', + builder: (_, _) => const ReceivePage(), + ), + GoRoute( + name: AppRoutes.send, + path: '/send', + builder: (_, _) { + pushedRoutes.add(AppRoutes.send); + return const Scaffold(body: Text('ROUTE:send')); + }, + ), + ], + ); + addTearDown(router.dispose); + + await tester.pumpWidget( + MaterialApp.router( + routerConfig: router, + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + ], + supportedLocales: S.delegate.supportedLocales, + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(sendButton()); + await tester.pumpAndSettle(); + + expect(pushedRoutes, [AppRoutes.send]); + }); + }); +} diff --git a/test/screens/receive/receive_responsive_matrix_test.dart b/test/screens/receive/receive_responsive_matrix_test.dart new file mode 100644 index 000000000..38eedf6ba --- /dev/null +++ b/test/screens/receive/receive_responsive_matrix_test.dart @@ -0,0 +1,126 @@ +import 'package:flutter/material.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/app_store.dart'; +import 'package:realunit_wallet/screens/receive/receive_page.dart'; +import 'package:realunit_wallet/setup/routing/routes/app_routes.dart'; +import 'package:realunit_wallet/styles/themes.dart'; +import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart'; + +import '../../helper/helper.dart'; + +Future _pumpScreen( + WidgetTester tester, + MatrixCell cell, + Widget child, +) async { + await tester.binding.setSurfaceSize(cell.mediaQuery.size); + addTearDown(() async => tester.binding.setSurfaceSize(null)); + final router = GoRouter( + initialLocation: '/', + routes: [ + GoRoute( + path: '/', + builder: (_, _) => child, + ), + GoRoute( + name: AppRoutes.send, + path: '/send', + builder: (_, _) => const Scaffold(body: Text('ROUTE:send')), + ), + ], + ); + addTearDown(router.dispose); + await tester.pumpWidget( + MediaQuery( + data: cell.mediaQuery, + child: MaterialApp.router( + theme: realUnitTheme, + locale: const Locale('de'), + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + supportedLocales: S.delegate.supportedLocales, + routerConfig: router, + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); +} + +void main() { + final AppStore appStore = MockAppStore(); + + setUpAll(() { + GetIt.instance.registerSingleton(appStore); + }); + + setUp(() { + when(() => appStore.primaryAddress) + .thenReturn('0x938115b533a0b746428361760a6972dfd06d984a'); + }); + + tearDownAll(() async { + await GetIt.instance.reset(); + }); + + group('ReceivePage responsive matrix (bottom sheet)', () { + for (final cell in kFullResponsiveMatrix) { + testWidgets(cell.id, (tester) async { + await withTargetPlatform(cell.device.platform, () async { + await expectNoLayoutOverflow( + tester, + () => _pumpScreen( + tester, + cell, + const ReceivePage(), + ), + reason: 'ReceivePage (bottom sheet) overflow / ${cell.label}', + ); + + await expectFullyTappable( + tester, + find.widgetWithText(AppFilledButton, S.current.send), + within: find.byType(ReceivePage), + reason: + 'ReceivePage (bottom sheet) / ${cell.label}: Send CTA not tappable', + ); + }); + }); + } + }); + + group('ReceivePage responsive matrix (full page)', () { + for (final cell in kFullResponsiveMatrix) { + testWidgets(cell.id, (tester) async { + await withTargetPlatform(cell.device.platform, () async { + await expectNoLayoutOverflow( + tester, + () => _pumpScreen( + tester, + cell, + const ReceivePage(isBottomSheet: false), + ), + reason: 'ReceivePage (full page) overflow / ${cell.label}', + ); + + await expectFullyTappable( + tester, + find.widgetWithText(AppFilledButton, S.current.send), + within: find.byType(ReceivePage), + reason: + 'ReceivePage (full page) / ${cell.label}: Send CTA not tappable', + ); + }); + }); + } + }); +} diff --git a/test/screens/receive/widgets/qr_address_widget_test.dart b/test/screens/receive/widgets/qr_address_widget_test.dart index 1d4652ade..e5fae8f1a 100644 --- a/test/screens/receive/widgets/qr_address_widget_test.dart +++ b/test/screens/receive/widgets/qr_address_widget_test.dart @@ -74,5 +74,24 @@ void main() { )); expect(tester.takeException(), isNull); }); + + testWidgets('address row shrink-wraps so the parent Column can center it', + (tester) async { + await tester.pumpWidget(_host( + const QRAddressWidget(uri: 'ethereum:$_address', subtitle: _address), + )); + + final row = tester.widget( + find.descendant( + of: find.byType(InkWell), + matching: find.byType(Row), + ), + ); + expect(row.mainAxisSize, MainAxisSize.min); + + final inkSize = tester.getSize(find.byType(InkWell)); + final screenWidth = tester.getSize(find.byType(Scaffold)).width; + expect(inkSize.width, lessThan(screenWidth)); + }); }); } diff --git a/test/screens/sell_bitbox/sell_bitbox_cubit_happy_paths_test.dart b/test/screens/sell_bitbox/sell_bitbox_cubit_happy_paths_test.dart index 5b1a24bc9..14ed61933 100644 --- a/test/screens/sell_bitbox/sell_bitbox_cubit_happy_paths_test.dart +++ b/test/screens/sell_bitbox/sell_bitbox_cubit_happy_paths_test.dart @@ -113,7 +113,7 @@ void main() { blockchainService: blockchain, sellService: sellService, appStore: appStore, - ); + )..start(); Future settleToEthReady(SellBitboxCubit cubit) => cubit.stream.firstWhere((s) => s is SellBitboxEthReady); diff --git a/test/screens/sell_bitbox/sell_bitbox_cubit_test.dart b/test/screens/sell_bitbox/sell_bitbox_cubit_test.dart index a3ee00b09..558af3cc6 100644 --- a/test/screens/sell_bitbox/sell_bitbox_cubit_test.dart +++ b/test/screens/sell_bitbox/sell_bitbox_cubit_test.dart @@ -123,17 +123,17 @@ void main() { blockchainService: blockchain, sellService: sellService, appStore: appStore, - ); + )..start(); - // Helper: wait until the cubit settles into a non-Checking state. The - // constructor schedules _checkEthBalance via scheduleMicrotask, so the - // first emit lands after a microtask. + // Helper: wait until the cubit settles into a non-Checking state. start() + // schedules _checkEthBalance via scheduleMicrotask, so the first emit lands + // after a microtask. Future settle(SellBitboxCubit cubit) async { if (cubit.state is! SellBitboxCheckingEth) return cubit.state; return cubit.stream.firstWhere((s) => s is! SellBitboxCheckingEth); } - group('constructor / _checkEthBalance', () { + group('start / _checkEthBalance', () { test('disconnected BitBox → SellBitboxBitboxRequired', () async { when(() => account.primaryAddress).thenReturn( FakeBitboxCredentials(behavior: FakeBitboxBehavior.disconnect)..bitboxManager = null, @@ -350,7 +350,7 @@ void main() { }); final cubit = build(info: _info(ethBalance: 0, requiredGasEth: 0.001)); - // Drive the constructor microtask + the awaited faucet request to + // Drive the start() microtask + the awaited faucet request to // completion so the periodic Timer is installed. async.flushMicrotasks(); async.elapse(Duration.zero); diff --git a/test/screens/send/cubits/send_process_cubit_test.dart b/test/screens/send/cubits/send_process_cubit_test.dart index aee48a490..94f5d7a6e 100644 --- a/test/screens/send/cubits/send_process_cubit_test.dart +++ b/test/screens/send/cubits/send_process_cubit_test.dart @@ -816,6 +816,29 @@ void main() { }, ); + test( + 'confirm-phase TransferReceiptTimeoutException → SendProcessSuccess', + () async { + const hash = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + when(() => service.prepareTransfer(any())).thenAnswer((_) async => _info()); + stubConfirm( + const TransferReceiptTimeoutException( + statusCode: 500, + code: 'X', + message: 'Timed out while waiting for transaction', + txHash: hash, + ), + ); + + final cubit = build(); + await cubit.start(); + + final state = cubit.state as SendProcessSuccess; + expect(state.txHash, hash); + await cubit.close(); + }, + ); + test( 'confirm-phase API 400 → invalidRequest (non-retryable definitive failure)', () async { diff --git a/test/screens/send/send_confirm_page_test.dart b/test/screens/send/send_confirm_page_test.dart index 4f37bb555..410859235 100644 --- a/test/screens/send/send_confirm_page_test.dart +++ b/test/screens/send/send_confirm_page_test.dart @@ -22,9 +22,10 @@ class _MockWallet extends Mock implements SoftwareWallet {} void main() { setUpAll(() { - // The confirm button pushes SendProcessPage, which builds a cubit off getIt - // and calls start(). A debug wallet makes start() settle immediately without - // any network call. + // The confirm button pushes SendProcessPage, which creates a cubit off getIt + // without start(); a route gate starts it after the push animation + // completes. A debug wallet makes start() settle immediately without any + // network call. final getIt = GetIt.instance; getIt.registerSingleton(_MockTransferService()); final appStore = _MockAppStore(); diff --git a/test/screens/send/send_info_page_test.dart b/test/screens/send/send_info_page_test.dart new file mode 100644 index 000000000..ff987ddc7 --- /dev/null +++ b/test/screens/send/send_info_page_test.dart @@ -0,0 +1,30 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/screens/send/send_info_page.dart'; +import 'package:realunit_wallet/screens/send/send_recipient_page.dart'; + +import '../../helper/helper.dart'; + +void main() { + setUpAll(stubMobileScannerChannel); + + group('$SendInfoPage', () { + testWidgets('shows the shareholder transfer disclosure', (tester) async { + await tester.pumpApp(const SendInfoPage()); + + expect(find.text(S.current.sendInfoTitle), findsOneWidget); + expect(find.text(S.current.sendInfoBody), findsOneWidget); + expect(find.text(S.current.next), findsOneWidget); + }); + + testWidgets('continues to the recipient scanner', (tester) async { + await tester.pumpApp(const SendInfoPage()); + + await tester.tap(find.text(S.current.next)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + + expect(find.byType(SendRecipientPage), findsOneWidget); + }); + }); +} diff --git a/test/screens/send/send_info_responsive_matrix_test.dart b/test/screens/send/send_info_responsive_matrix_test.dart new file mode 100644 index 000000000..5b62e63c0 --- /dev/null +++ b/test/screens/send/send_info_responsive_matrix_test.dart @@ -0,0 +1,61 @@ +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/send/send_info_page.dart'; +import 'package:realunit_wallet/styles/themes.dart'; + +import '../../helper/helper.dart'; + +Future _pumpScreen( + WidgetTester tester, + MatrixCell cell, + Widget child, +) async { + await tester.binding.setSurfaceSize(cell.mediaQuery.size); + addTearDown(() async => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget( + MediaQuery( + data: cell.mediaQuery, + child: MaterialApp( + theme: realUnitTheme, + locale: const Locale('de'), + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + supportedLocales: S.delegate.supportedLocales, + home: child, + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); +} + +void main() { + setUpAll(stubMobileScannerChannel); + + group('SendInfoPage responsive matrix (full device × textScale)', () { + for (final cell in kFullResponsiveMatrix) { + testWidgets(cell.id, (tester) async { + await withTargetPlatform(cell.device.platform, () async { + await expectNoLayoutOverflow( + tester, + () => _pumpScreen(tester, cell, const SendInfoPage()), + reason: 'SendInfoPage overflow / ${cell.label}', + ); + + await expectFullyTappable( + tester, + find.widgetWithText(FilledButton, S.current.next), + within: find.byType(SendInfoPage), + reason: 'SendInfoPage / ${cell.label}: Continue CTA not tappable', + ); + }); + }); + } + }); +} diff --git a/test/screens/send/send_process_page_test.dart b/test/screens/send/send_process_page_test.dart index f057b4976..8b92f7655 100644 --- a/test/screens/send/send_process_page_test.dart +++ b/test/screens/send/send_process_page_test.dart @@ -31,9 +31,11 @@ void main() { setUpAll(() { final getIt = GetIt.instance; - // SendProcessPage resolves the service + AppStore from getIt and calls - // start(). A debug wallet makes start() settle immediately - // (signatureUnsupported) without touching the network. + // SendProcessPage resolves the service + AppStore from getIt and creates + // the cubit without start(); a route gate starts it after the route + // animation completes (immediately when pumped as home). A debug wallet + // makes start() settle immediately (signatureUnsupported) without + // touching the network. getIt.registerSingleton(_MockTransferService()); final appStore = _MockAppStore(); final apiConfig = _MockApiConfig(); @@ -64,6 +66,58 @@ void main() { expect(find.byType(SendProcessView), findsOne); }); + + testWidgets('keeps preparing UI during route slide; starts only after animation completes', ( + tester, + ) async { + await tester.pumpApp( + Builder( + builder: (context) => Scaffold( + body: Center( + child: TextButton( + onPressed: () { + Navigator.of(context).push( + TimedMaterialPageRoute( + transitionDuration: const Duration(milliseconds: 300), + builder: (_) => const SendProcessPage(recipient: '0xRecipient', amount: 5), + ), + ); + }, + child: const Text('go'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('go')); + await tester.pump(); + await tester.pump(); // flush the post-frame callback + await tester.pump(const Duration(milliseconds: 50)); + + expect(find.byType(SendProcessView), findsOne); + expect( + find.text(S.current.sendPreparing, skipOffstage: false), + findsOne, + ); + expect( + find.text(S.current.sendFailureTitle, skipOffstage: false), + findsNothing, + ); + expect( + find.text(S.current.close, skipOffstage: false), + findsNothing, + ); + + await tester.pump(const Duration(milliseconds: 300)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + + // Body label and the result sheet both use sendFailureTitle. + expect(find.text(S.current.sendFailureTitle), findsAtLeast(1)); + expect(find.text(S.current.close), findsOne); + expect(find.byIcon(Icons.error_rounded), findsOne); + }); }); group('$SendProcessView progress labels', () { @@ -262,6 +316,33 @@ void main() { expect(find.text(S.current.retry), findsNothing); }); + testWidgets( + 'generic failure with raw viem message still shows sendFailureGeneric; Retry when canRetry', + (tester) async { + const viemMessage = + 'Timed out while waiting for transaction with hash ' + '"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"\n' + 'Version: viem@2.21.0'; + when(() => processCubit.retryConfirm()).thenAnswer((_) async {}); + + await pumpWithState( + tester, + const SendProcessFailure( + SendProcessFailureReason.generic, + message: viemMessage, + canRetry: true, + ), + ); + + expect(find.text(S.current.sendFailureGeneric), findsOne); + expect(find.text(viemMessage), findsNothing); + expect(find.textContaining('viem@'), findsNothing); + expect(find.textContaining('Timed out while waiting for transaction'), findsNothing); + expect(find.text(S.current.retry), findsOne); + expect(find.text(S.current.close), findsOne); + }, + ); + testWidgets('confirm-mismatch failure message (non-retryable)', (tester) async { await pumpWithState( tester, @@ -287,6 +368,8 @@ void main() { ), ); + expect(find.text(S.current.sendFailureGeneric), findsOne); + expect(find.text('socket hung up'), findsNothing); expect(find.text(S.current.retry), findsOne); expect(find.text(S.current.close), findsOne); expect(find.byType(SendProcessView), findsOne); diff --git a/test/screens/send/send_recipient_page_test.dart b/test/screens/send/send_recipient_page_test.dart index 4d793d44e..b2ca58755 100644 --- a/test/screens/send/send_recipient_page_test.dart +++ b/test/screens/send/send_recipient_page_test.dart @@ -162,20 +162,28 @@ void main() { expect(find.text(S.current.sendRecipientInvalid), findsOne); }); - testWidgets('a valid recipient navigates to the amount step and resets', (tester) async { - whenListen( - recipientCubit, - Stream.fromIterable([ - const SendRecipientValid('0x9F5713dEAcb8e9CaB6c2D3FaE1aFc2715F8D2D71'), - ]), - initialState: const SendRecipientEmpty(), - ); - - await tester.pumpApp(buildSubject()); - await tester.pumpAndSettle(); - - expect(find.byType(SendAmountView), findsOne); - verify(() => recipientCubit.reset()).called(1); - }); + testWidgets( + 'a valid recipient navigates to the amount step; reset waits until pop', + (tester) async { + whenListen( + recipientCubit, + Stream.fromIterable([ + const SendRecipientValid('0x9F5713dEAcb8e9CaB6c2D3FaE1aFc2715F8D2D71'), + ]), + initialState: const SendRecipientEmpty(), + ); + + await tester.pumpApp(buildSubject()); + await tester.pumpAndSettle(); + + expect(find.byType(SendAmountView), findsOne); + verifyNever(() => recipientCubit.reset()); + + await tester.pageBack(); + await tester.pumpAndSettle(); + + verify(() => recipientCubit.reset()).called(1); + }, + ); }); } diff --git a/test/screens/send/send_recipient_scanner_navigation_test.dart b/test/screens/send/send_recipient_scanner_navigation_test.dart new file mode 100644 index 000000000..5f473d707 --- /dev/null +++ b/test/screens/send/send_recipient_scanner_navigation_test.dart @@ -0,0 +1,113 @@ +// Gate for the QR scanner double-stack bug: a live camera delivers the same +// code on every frame. Pushing the amount step and resetting the cubit in the +// same turn drops the Valid guard, so frame 2 pushes a second SendAmountPage. +// This test fires two BarcodeCaptures against the real cubit and expects +// SendAmountView exactly once; after pop, a third capture is accepted again. +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/models/balance.dart'; +import 'package:realunit_wallet/packages/config/api_config.dart'; +import 'package:realunit_wallet/packages/repository/balance_repository.dart'; +import 'package:realunit_wallet/packages/service/app_store.dart'; +import 'package:realunit_wallet/packages/utils/default_assets.dart'; +import 'package:realunit_wallet/screens/send/send_amount_page.dart'; +import 'package:realunit_wallet/screens/send/send_recipient_page.dart'; + +import '../../helper/helper.dart'; + +class _MockBalanceRepository extends Mock implements BalanceRepository {} + +class _MockAppStore extends Mock implements AppStore {} + +class _MockApiConfig extends Mock implements ApiConfig {} + +void main() { + // EIP-55 address from send_recipient_cubit_test.dart. + const checksummed = '0x9F5713DEacB8e9CAB6c2d3FaE1AFc2715F8D2D71'; + + Balance balanceOf(BigInt value) => Balance( + chainId: realUnitAsset.chainId, + contractAddress: realUnitAsset.address, + walletAddress: '0xwallet', + balance: value, + asset: realUnitAsset, + ); + + setUpAll(() { + registerFallbackValue( + Balance( + chainId: 1, + contractAddress: '0x', + walletAddress: '0x', + balance: BigInt.zero, + asset: realUnitAsset, + ), + ); + stubMobileScannerChannel(); + + final getIt = GetIt.instance; + final balanceRepo = _MockBalanceRepository(); + when(() => balanceRepo.watchBalance(any())).thenAnswer( + (_) => Stream.value(balanceOf(BigInt.from(100))), + ); + getIt.registerFactory(() => balanceRepo); + final appStore = _MockAppStore(); + final apiConfig = _MockApiConfig(); + when(() => apiConfig.asset).thenReturn(realUnitAsset); + when(() => appStore.apiConfig).thenReturn(apiConfig); + when(() => appStore.primaryAddress).thenReturn('0xwallet'); + getIt.registerSingleton(appStore); + }); + + tearDownAll(() async => GetIt.instance.reset()); + + testWidgets( + 'two identical captures push SendAmountView once; re-arms after pop', + (tester) async { + await tester.pumpApp(const SendRecipientPage()); + + final scanner = tester.widget(find.byType(MobileScanner)); + const capture = BarcodeCapture(barcodes: [Barcode(rawValue: checksummed)]); + scanner.onDetect!(capture); + scanner.onDetect!(capture); // second frame — must not push again + await tester.pumpAndSettle(); + + expect(find.byType(SendAmountView), findsOne); + + await tester.pageBack(); + await tester.pumpAndSettle(); + expect(find.byType(SendAmountView), findsNothing); + + // Re-armed after pop: a new capture is accepted again. + final scannerAfterPop = tester.widget(find.byType(MobileScanner)); + scannerAfterPop.onDetect!(capture); + await tester.pumpAndSettle(); + expect(find.byType(SendAmountView), findsOne); + }, + ); + + testWidgets( + 'a capture during the pop animation does not push a second amount page', + (tester) async { + await tester.pumpApp(const SendRecipientPage()); + + final scanner = tester.widget(find.byType(MobileScanner)); + final onDetect = scanner.onDetect!; + const capture = BarcodeCapture(barcodes: [Barcode(rawValue: checksummed)]); + onDetect(capture); + await tester.pumpAndSettle(); + expect(find.byType(SendAmountView), findsOne); + + await tester.pageBack(); + await tester.pump(); // pop animation in flight — not settled + onDetect(capture); + await tester.pump(); + expect(find.byType(SendAmountView), findsOne); + + await tester.pumpAndSettle(); + expect(find.byType(SendAmountView), findsNothing); + }, + ); +} diff --git a/test/screens/settings/settings_bloc_test.dart b/test/screens/settings/settings_bloc_test.dart index 525cff27e..cb6624617 100644 --- a/test/screens/settings/settings_bloc_test.dart +++ b/test/screens/settings/settings_bloc_test.dart @@ -18,17 +18,18 @@ void main() { authRefreshCount = 0; // Defaults — sane values for the initial state. when(() => repo.language).thenReturn('en'); - when(() => repo.currency).thenReturn('CHF'); + when(() => repo.currency).thenReturn('EUR'); + when(() => repo.hasStoredCurrency).thenReturn(false); when(() => repo.networkMode).thenReturn(NetworkMode.mainnet); when(() => repo.insiderFeaturesUnlocked).thenReturn(false); }); SettingsBloc build() => SettingsBloc( - repo, - () async { - authRefreshCount++; - }, - ); + repo, + () async { + authRefreshCount++; + }, + ); group('$SettingsBloc', () { test('initial state reads from the repository', () { @@ -66,6 +67,48 @@ void main() { }, ); + blocTest( + 'ApplyAccountCurrencyEvent emits the account currency when nothing is stored', + build: build, + act: (bloc) => bloc.add(const ApplyAccountCurrencyEvent(Currency.chf)), + expect: () => [ + isA().having((s) => s.currency, 'currency', Currency.chf), + ], + verify: (bloc) { + verifyNever(() => repo.currency = any()); + }, + ); + + blocTest( + 'ApplyAccountCurrencyEvent is ignored when the user already stored a currency', + build: build, + setUp: () => when(() => repo.hasStoredCurrency).thenReturn(true), + act: (bloc) => bloc.add(const ApplyAccountCurrencyEvent(Currency.chf)), + expect: () => [], + ); + + blocTest( + 'ClearAccountCurrencyEvent restores the unset default when nothing is stored', + build: build, + seed: () => const SettingsState(currency: Currency.chf), + act: (bloc) => bloc.add(const ClearAccountCurrencyEvent()), + expect: () => [ + isA().having((s) => s.currency, 'currency', Currency.eur), + ], + verify: (bloc) { + verifyNever(() => repo.currency = any()); + }, + ); + + blocTest( + 'ClearAccountCurrencyEvent is ignored when the user already stored a currency', + build: build, + setUp: () => when(() => repo.hasStoredCurrency).thenReturn(true), + seed: () => const SettingsState(currency: Currency.chf), + act: (bloc) => bloc.add(const ClearAccountCurrencyEvent()), + expect: () => [], + ); + test('SetNetworkModeEvent writes the new mode, refreshes auth, and emits', () async { final bloc = build(); diff --git a/test/screens/settings_wallet_address/settings_wallet_address_page_test.dart b/test/screens/settings_wallet_address/settings_wallet_address_page_test.dart index ffcbb210b..0fdfad8ea 100644 --- a/test/screens/settings_wallet_address/settings_wallet_address_page_test.dart +++ b/test/screens/settings_wallet_address/settings_wallet_address_page_test.dart @@ -1,57 +1,92 @@ -import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_svg/svg.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_svg/flutter_svg.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/app_store.dart'; import 'package:realunit_wallet/screens/receive/widgets/qr_address_widget.dart'; import 'package:realunit_wallet/screens/settings_wallet_address/settings_wallet_address_page.dart'; +import 'package:realunit_wallet/setup/routing/routes/app_routes.dart'; +import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart'; import '../../helper/helper.dart'; -class MockAppStore extends Mock implements AppStore {} - void main() { final AppStore appStore = MockAppStore(); - setUp(() { - when(() => appStore.primaryAddress).thenReturn('0x938115b533a0b746428361760a6972dfd06d984a'); + setUpAll(() { + GetIt.instance.registerSingleton(appStore); }); - void setupDependencyInjection() { - final getIt = GetIt.instance; - getIt.registerSingleton(appStore); - } + setUp(() { + when(() => appStore.primaryAddress) + .thenReturn('0x938115b533a0b746428361760a6972dfd06d984a'); + }); - setUpAll(() { - setupDependencyInjection(); + tearDownAll(() async { + await GetIt.instance.reset(); }); + Finder sendButton() => find.widgetWithText(AppFilledButton, S.current.send); + group('$SettingsWalletAddressPage', () { - testWidgets('renders initially correctly', (tester) async { + testWidgets('renders logo, QR, disclaimer and Send', (tester) async { await tester.pumpApp(const SettingsWalletAddressPage()); - expect(find.byType(SvgPicture), findsOne); - expect(find.byType(QRAddressWidget), findsOne); - expect( - find.byWidgetPredicate( - (widget) => widget is Text && widget.data == S.current.walletAddressDisclaimer, - ), - findsOne, - ); + expect(find.byType(SvgPicture), findsOneWidget); + expect(find.byType(QRAddressWidget), findsOneWidget); + expect(find.text(S.current.walletAddressDisclaimer), findsOneWidget); + expect(sendButton(), findsOneWidget); }); - testWidgets('displays the address in EIP-55 checksummed form', (tester) async { + testWidgets('QR uses EIP-55 checksummed address', (tester) async { await tester.pumpApp(const SettingsWalletAddressPage()); - // The mocked primaryAddress is all-lowercase; the screen must render and - // encode the checksummed (EIP-55) form in both the text and the QR uri. - const checksummed = '0x938115B533a0b746428361760A6972dfd06D984a'; final qr = tester.widget(find.byType(QRAddressWidget)); - expect(qr.subtitle, checksummed); - expect(qr.uri, contains(checksummed)); + expect(qr.subtitle, '0x938115B533a0b746428361760A6972dfd06D984a'); + expect(qr.uri, 'ethereum:0x938115B533a0b746428361760A6972dfd06D984a'); + }); + + testWidgets('tapping Send pushes the send route', (tester) async { + final pushedRoutes = []; + final router = GoRouter( + initialLocation: '/', + routes: [ + GoRoute( + path: '/', + builder: (_, _) => const SettingsWalletAddressPage(), + ), + GoRoute( + name: AppRoutes.send, + path: '/send', + builder: (_, _) { + pushedRoutes.add(AppRoutes.send); + return const Scaffold(body: Text('ROUTE:send')); + }, + ), + ], + ); + addTearDown(router.dispose); + + await tester.pumpWidget( + MaterialApp.router( + routerConfig: router, + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + ], + supportedLocales: S.delegate.supportedLocales, + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(sendButton()); + await tester.pumpAndSettle(); + + expect(pushedRoutes, [AppRoutes.send]); }); }); } diff --git a/test/screens/settings_wallet_address/settings_wallet_address_responsive_matrix_test.dart b/test/screens/settings_wallet_address/settings_wallet_address_responsive_matrix_test.dart new file mode 100644 index 000000000..4bcd95b68 --- /dev/null +++ b/test/screens/settings_wallet_address/settings_wallet_address_responsive_matrix_test.dart @@ -0,0 +1,100 @@ +import 'package:flutter/material.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/app_store.dart'; +import 'package:realunit_wallet/screens/settings_wallet_address/settings_wallet_address_page.dart'; +import 'package:realunit_wallet/setup/routing/routes/app_routes.dart'; +import 'package:realunit_wallet/styles/themes.dart'; +import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart'; + +import '../../helper/helper.dart'; + +Future _pumpScreen( + WidgetTester tester, + MatrixCell cell, + Widget child, +) async { + await tester.binding.setSurfaceSize(cell.mediaQuery.size); + addTearDown(() async => tester.binding.setSurfaceSize(null)); + final router = GoRouter( + initialLocation: '/', + routes: [ + GoRoute( + path: '/', + builder: (_, _) => child, + ), + GoRoute( + name: AppRoutes.send, + path: '/send', + builder: (_, _) => const Scaffold(body: Text('ROUTE:send')), + ), + ], + ); + addTearDown(router.dispose); + await tester.pumpWidget( + MediaQuery( + data: cell.mediaQuery, + child: MaterialApp.router( + theme: realUnitTheme, + locale: const Locale('de'), + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + supportedLocales: S.delegate.supportedLocales, + routerConfig: router, + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); +} + +void main() { + final AppStore appStore = MockAppStore(); + + setUpAll(() { + GetIt.instance.registerSingleton(appStore); + }); + + setUp(() { + when(() => appStore.primaryAddress) + .thenReturn('0x938115b533a0b746428361760a6972dfd06d984a'); + }); + + tearDownAll(() async { + await GetIt.instance.reset(); + }); + + group('SettingsWalletAddressPage responsive matrix', () { + for (final cell in kFullResponsiveMatrix) { + testWidgets(cell.id, (tester) async { + await withTargetPlatform(cell.device.platform, () async { + await expectNoLayoutOverflow( + tester, + () => _pumpScreen( + tester, + cell, + const SettingsWalletAddressPage(), + ), + reason: 'SettingsWalletAddressPage overflow / ${cell.label}', + ); + + await expectFullyTappable( + tester, + find.widgetWithText(AppFilledButton, S.current.send), + within: find.byType(SettingsWalletAddressPage), + reason: + 'SettingsWalletAddressPage / ${cell.label}: Send CTA not tappable', + ); + }); + }); + } + }); +} diff --git a/test/setup/account_currency_sync_test.dart b/test/setup/account_currency_sync_test.dart new file mode 100644 index 000000000..1b2c149ce --- /dev/null +++ b/test/setup/account_currency_sync_test.dart @@ -0,0 +1,159 @@ +import 'dart:async'; + +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_kyc_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/kyc/kyc_level.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/user/dto/user_dto.dart'; +import 'package:realunit_wallet/packages/wallet/wallet.dart'; +import 'package:realunit_wallet/screens/settings/bloc/settings_bloc.dart'; +import 'package:realunit_wallet/setup/account_currency_sync.dart'; +import 'package:realunit_wallet/styles/currency.dart'; + +class _MockSettingsBloc extends MockBloc implements SettingsBloc {} + +class _MockKycService extends Mock implements DfxKycService {} + +class _MockWallet extends Mock implements AWallet {} + +UserDto _user({Currency? currency}) => UserDto( + kyc: const UserKycDto(hash: 'h', level: KycLevel.level0, dataComplete: false), + currency: currency, +); + +void main() { + late _MockSettingsBloc settings; + late _MockKycService kyc; + late _MockWallet wallet; + late AWallet? current; + + setUpAll(() { + registerFallbackValue(const ApplyAccountCurrencyEvent(Currency.chf)); + registerFallbackValue(const ClearAccountCurrencyEvent()); + }); + + setUp(() { + settings = _MockSettingsBloc(); + kyc = _MockKycService(); + wallet = _MockWallet(); + current = wallet; + }); + + AccountCurrencySync build() => AccountCurrencySync( + settings: settings, + kyc: kyc, + currentWallet: () => current, + ); + + test('onOpened applies GET /v2/user.currency when the wallet is still open', () async { + when(() => kyc.getUser()).thenAnswer((_) async => _user(currency: Currency.chf)); + + final sync = build(); + sync.onOpened(wallet); + await untilCalled(() => settings.add(any())); + + verify(() => settings.add(const ApplyAccountCurrencyEvent(Currency.chf))).called(1); + verifyNever(() => settings.add(const ClearAccountCurrencyEvent())); + }); + + test('onOpened does not dispatch when GET /v2/user has no currency', () async { + when(() => kyc.getUser()).thenAnswer((_) async => _user()); + + final sync = build(); + sync.onOpened(wallet); + await Future.delayed(Duration.zero); + + verifyNever(() => settings.add(any())); + }); + + test('onOpened swallows GET /v2/user failures', () async { + when(() => kyc.getUser()).thenAnswer((_) async => throw Exception('offline')); + + final sync = build(); + sync.onOpened(wallet); + await Future.delayed(Duration.zero); + + verifyNever(() => settings.add(any())); + }); + + test('onClosed clears the unset account default', () { + build().onClosed(); + + verify(() => settings.add(const ClearAccountCurrencyEvent())).called(1); + }); + + test('a late apply after onClosed is dropped', () async { + final held = Completer(); + when(() => kyc.getUser()).thenAnswer((_) => held.future); + + final sync = build(); + sync.onOpened(wallet); + sync.onClosed(); + current = null; + held.complete(_user(currency: Currency.chf)); + await Future.delayed(Duration.zero); + + verify(() => settings.add(const ClearAccountCurrencyEvent())).called(1); + verifyNever(() => settings.add(any(that: isA()))); + }); + + test('onSwitched clears then applies the new wallet currency', () async { + final other = _MockWallet(); + when(() => kyc.getUser()).thenAnswer((_) async => _user(currency: Currency.eur)); + + final sync = build(); + current = other; + sync.onSwitched(other); + await untilCalled(() => settings.add(any(that: isA()))); + + verifyInOrder([ + () => settings.add(const ClearAccountCurrencyEvent()), + () => settings.add(const ApplyAccountCurrencyEvent(Currency.eur)), + ]); + }); + + test('a late apply from wallet A is dropped after switch to B', () async { + final held = Completer(); + when(() => kyc.getUser()).thenAnswer((_) => held.future); + final other = _MockWallet(); + + final sync = build(); + sync.onOpened(wallet); + when(() => kyc.getUser()).thenAnswer((_) async => _user(currency: Currency.eur)); + current = other; + sync.onSwitched(other); + held.complete(_user(currency: Currency.chf)); + await untilCalled(() => settings.add(const ApplyAccountCurrencyEvent(Currency.eur))); + + verify(() => settings.add(const ClearAccountCurrencyEvent())).called(1); + verifyNever(() => settings.add(const ApplyAccountCurrencyEvent(Currency.chf))); + verify(() => settings.add(const ApplyAccountCurrencyEvent(Currency.eur))).called(1); + }); + + test('applyFromUser dispatches when captured is still the open wallet', () { + build().applyFromUser(_user(currency: Currency.chf), captured: wallet); + + verify(() => settings.add(const ApplyAccountCurrencyEvent(Currency.chf))).called(1); + }); + + test('applyFromUser is ignored when captured is a different wallet', () { + build().applyFromUser(_user(currency: Currency.chf), captured: _MockWallet()); + + verifyNever(() => settings.add(any())); + }); + + test('applyFromUser drops an in-flight onOpened apply', () async { + final held = Completer(); + when(() => kyc.getUser()).thenAnswer((_) => held.future); + + final sync = build(); + sync.onOpened(wallet); + sync.applyFromUser(_user(currency: Currency.chf), captured: wallet); + held.complete(_user(currency: Currency.eur)); + await Future.delayed(Duration.zero); + + verify(() => settings.add(const ApplyAccountCurrencyEvent(Currency.chf))).called(1); + verifyNever(() => settings.add(const ApplyAccountCurrencyEvent(Currency.eur))); + }); +} diff --git a/test/setup/routing/pay_route_test.dart b/test/setup/routing/pay_route_test.dart index c0a1dda27..58e14b10d 100644 --- a/test/setup/routing/pay_route_test.dart +++ b/test/setup/routing/pay_route_test.dart @@ -6,7 +6,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:realunit_wallet/generated/i18n.dart'; import 'package:realunit_wallet/screens/home/bloc/home_bloc.dart'; -import 'package:realunit_wallet/screens/pay/pay_scan_page.dart'; +import 'package:realunit_wallet/screens/pay/pay_info_page.dart'; import 'package:realunit_wallet/setup/routing/router_config.dart'; import 'package:realunit_wallet/setup/routing/routes/app_routes.dart'; @@ -18,8 +18,8 @@ void main() { late _MockHomeBloc homeBloc; setUpAll(() { - // PayScanPage embeds a MobileScanner; the stub keeps the headless camera - // preview deterministic and free of MissingPluginException. + // /pay lands on PayInfoPage (no camera). The stub stays so a later + // Continue onto PayScanPage in other tests does not throw MissingPluginException. stubMobileScannerChannel(); }); @@ -49,14 +49,13 @@ void main() { await tester.pumpAndSettle(); } - testWidgets('the pay route builds PayScanPage', (tester) async { + testWidgets('the pay route builds PayInfoPage', (tester) async { await pumpRouter(tester); routerConfig.goNamed(AppRoutes.pay); await tester.pumpAndSettle(); - expect(find.byType(PayScanPage), findsOneWidget); - expect(find.byType(PayScanView), findsOneWidget); + expect(find.byType(PayInfoPage), findsOneWidget); // Restore the router to its initial location so the global singleton // does not leak the /pay location into any later test. @@ -81,7 +80,7 @@ void main() { expect(tester.takeException(), isNull); expect( - tester.widget(find.byType(PayScanPage)).initialPayload, + tester.widget(find.byType(PayInfoPage)).initialPayload, 'lightning:LNURL1DP68GURN8GHJ7VF3XGENJVE5UMD', ); @@ -101,7 +100,7 @@ void main() { expect(tester.takeException(), isNull); expect( - tester.widget(find.byType(PayScanPage)).initialPayload, + tester.widget(find.byType(PayInfoPage)).initialPayload, isNull, ); diff --git a/test/setup/routing/pay_send_routes_test.dart b/test/setup/routing/pay_send_routes_test.dart index 27667f561..a5cccec7c 100644 --- a/test/setup/routing/pay_send_routes_test.dart +++ b/test/setup/routing/pay_send_routes_test.dart @@ -6,8 +6,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:realunit_wallet/generated/i18n.dart'; import 'package:realunit_wallet/screens/home/bloc/home_bloc.dart'; -import 'package:realunit_wallet/screens/pay/pay_scan_page.dart'; -import 'package:realunit_wallet/screens/send/send_recipient_page.dart'; +import 'package:realunit_wallet/screens/pay/pay_info_page.dart'; +import 'package:realunit_wallet/screens/send/send_info_page.dart'; import 'package:realunit_wallet/setup/routing/router_config.dart'; import 'package:realunit_wallet/setup/routing/routes/app_routes.dart'; @@ -19,9 +19,8 @@ void main() { late _MockHomeBloc homeBloc; setUpAll(() { - // Both routed pages embed a QrScannerView (MobileScanner); the stub keeps - // the headless camera preview deterministic and free of - // MissingPluginException. + // /pay and /send land on info pages (no camera). The stub stays so a later + // Continue onto a scanner page does not throw MissingPluginException. stubMobileScannerChannel(); }); @@ -55,21 +54,21 @@ void main() { // test so the /pay or /send location does not leak into any later test. tearDown(() => routerConfig.goNamed(AppRoutes.home)); - testWidgets('the pay route builds PayScanPage', (tester) async { + testWidgets('the pay route builds PayInfoPage', (tester) async { await pumpRouter(tester); routerConfig.goNamed(AppRoutes.pay); await tester.pumpAndSettle(); - expect(find.byType(PayScanPage), findsOneWidget); + expect(find.byType(PayInfoPage), findsOneWidget); }); - testWidgets('the send route builds SendRecipientPage', (tester) async { + testWidgets('the send route builds SendInfoPage', (tester) async { await pumpRouter(tester); routerConfig.goNamed(AppRoutes.send); await tester.pumpAndSettle(); - expect(find.byType(SendRecipientPage), findsOneWidget); + expect(find.byType(SendInfoPage), findsOneWidget); }); } diff --git a/test/styles/currency_test.dart b/test/styles/currency_test.dart index 9d1f5045f..eb8ba6ed8 100644 --- a/test/styles/currency_test.dart +++ b/test/styles/currency_test.dart @@ -31,5 +31,15 @@ void main() { expect(() => Currency.fromCode(''), throwsA(isA())); }); }); + + group('tryFromCode', () { + test('resolves known codes and returns null otherwise', () { + expect(Currency.tryFromCode('CHF'), Currency.chf); + expect(Currency.tryFromCode('eur'), Currency.eur); + expect(Currency.tryFromCode('USD'), isNull); + expect(Currency.tryFromCode(''), isNull); + expect(Currency.tryFromCode(null), isNull); + }); + }); }); } diff --git a/test/widgets/route_animation_gate_test.dart b/test/widgets/route_animation_gate_test.dart new file mode 100644 index 000000000..d0b26d4fb --- /dev/null +++ b/test/widgets/route_animation_gate_test.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:realunit_wallet/widgets/route_animation_gate.dart'; + +import '../helper/helper.dart'; + +void main() { + testWidgets('onSettled fires only after the incoming route animation completes', ( + tester, + ) async { + final settledCount = ValueNotifier(0); + addTearDown(settledCount.dispose); + + await tester.pumpApp( + Builder( + builder: (context) => Scaffold( + body: Center( + child: TextButton( + onPressed: () { + Navigator.of(context).push( + TimedMaterialPageRoute( + transitionDuration: const Duration(milliseconds: 300), + builder: (_) => RouteAnimationGate( + onSettled: (_) => settledCount.value++, + child: const Scaffold(body: Text('gated')), + ), + ), + ); + }, + child: const Text('go'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('go')); + await tester.pump(); + await tester.pump(); // flush the post-frame callback + await tester.pump(const Duration(milliseconds: 50)); + + expect(settledCount.value, 0); + expect(find.text('gated'), findsOne); + + await tester.pump(const Duration(milliseconds: 300)); + await tester.pump(); + + expect(settledCount.value, 1); + }); +}