diff --git a/.github/workflows/handbook-build-check.yaml b/.github/workflows/handbook-build-check.yaml index e797fa309..797ed36fd 100644 --- a/.github/workflows/handbook-build-check.yaml +++ b/.github/workflows/handbook-build-check.yaml @@ -92,8 +92,8 @@ jobs: set -euo pipefail bash scripts/assemble-handbook-screenshots.sh /tmp/handbook-shots count=$(ls -1 /tmp/handbook-shots/*.png | wc -l | tr -d ' ') - if [ "$count" != "279" ]; then - echo "expected 279 screenshots, got $count" >&2 + if [ "$count" != "278" ]; then + echo "expected 278 screenshots, got $count" >&2 exit 1 fi @@ -173,7 +173,7 @@ jobs: exit 1 fi - # Screenshots dir must contain all 279 PNGs assembled from Goldens. + # Screenshots dir must contain all 278 PNGs assembled from Goldens. # Hit one of them through the auth gate to verify wiring end-to-end. # Mix of the original 01-61 range and the 62-269 batch (every Golden # baseline) so a regression in either half surfaces here. diff --git a/.github/workflows/handbook-deploy.yaml b/.github/workflows/handbook-deploy.yaml index 5e4175580..5aab6c245 100644 --- a/.github/workflows/handbook-deploy.yaml +++ b/.github/workflows/handbook-deploy.yaml @@ -55,3 +55,4 @@ jobs: DEPLOY_SSH_KNOWN_HOSTS: ${{ secrets.DEPLOY_PRD_SSH_KNOWN_HOSTS }} DEPLOY_USER: ${{ secrets.DEPLOY_PRD_USER }} DEPLOY_HOST: ${{ secrets.DEPLOY_PRD_HOST }} + HANDBOOK_API_CHECKOUT_SSH_KEY: ${{ secrets.HANDBOOK_API_CHECKOUT_SSH_KEY }} diff --git a/.github/workflows/handbook.yaml b/.github/workflows/handbook.yaml index b966850d9..f45eb014e 100644 --- a/.github/workflows/handbook.yaml +++ b/.github/workflows/handbook.yaml @@ -47,6 +47,8 @@ on: required: true DEPLOY_HOST: required: true + HANDBOOK_API_CHECKOUT_SSH_KEY: + required: true # Reusable workflows do NOT inherit `permissions` from the caller — GITHUB_TOKEN # scopes are resolved per-workflow. Mirror the `contents: read` floor that both @@ -84,19 +86,26 @@ jobs: # changes in the api repo only flow in after such a push/dispatch in # this repo. There is NO auto-dispatch from the api repo. The api ref # stays `develop` regardless of the handbook's own deploy branch. + - name: Require mail-preview source checkout key + env: + HANDBOOK_API_CHECKOUT_SSH_KEY: ${{ secrets.HANDBOOK_API_CHECKOUT_SSH_KEY }} + run: | + set -euo pipefail + if [ -z "${HANDBOOK_API_CHECKOUT_SSH_KEY}" ]; then + echo "::error::HANDBOOK_API_CHECKOUT_SSH_KEY is empty — set the repo secret (read-only deploy key for the mail-preview source)." + exit 1 + fi + - name: Check out DFXswiss/api at develop into _api-checkout/ uses: actions/checkout@v4 with: - # DFXswiss/api is a PUBLIC repo, so the runner's default GITHUB_TOKEN - # is sufficient to clone it — no cross-repo PAT setup needed. If the - # api repo is ever flipped to private, this step will start failing - # with HTTP 404 on checkout; the fix is then to add a PAT secret on - # the realunit-app repo and pass it through as `token:` here (and - # through the secrets: block of this reusable workflow + the - # handbook-deploy.yaml caller). + # Cross-org clone: the default GITHUB_TOKEN cannot read DFXswiss/api. + # HANDBOOK_API_CHECKOUT_SSH_KEY is a read-only deploy key on that repo. repository: DFXswiss/api ref: develop path: _api-checkout + ssh-key: ${{ secrets.HANDBOOK_API_CHECKOUT_SSH_KEY }} + persist-credentials: false - name: Set up Node.js for mail-preview generator uses: actions/setup-node@v4 @@ -319,13 +328,13 @@ jobs: - name: Stage web e2e baselines from web repo env: WEB_DIR: _web-checkout - # RealUnitCH/web commits 22 Playwright visual baselines under + # RealUnitCH/web commits 36 Playwright visual baselines under # tests/__screenshots__/{desktop-chromium,tablet-chromium,mobile-safari}/. # `-ne` below fails the build on ANY drift (add OR remove) so a change # to the committed set upstream requires an explicit bump here AND a # matching card edit in docs/handbook/de/index.html (#spec-web) in the # SAME change — same guard rationale as the api steps above. - EXPECTED_WEB_BASELINE_COUNT: 22 + EXPECTED_WEB_BASELINE_COUNT: 36 run: | set -euo pipefail diff --git a/assets/languages/strings_de.arb b/assets/languages/strings_de.arb index 6587b7800..b69627dd7 100644 --- a/assets/languages/strings_de.arb +++ b/assets/languages/strings_de.arb @@ -28,9 +28,6 @@ "buyBackToMain": "Zurück zum Hauptbereich", "buyMinAmount": "Mindestbetrag: ${amount} ${currency}", "buyPaymentConfirm": "Jetzt verbindlich kaufen", - "buyPaymentConfirmFailed": "Es gibt ein technisches Problem. Bitte versuchen Sie es später erneut. Falls der Fehler weiterhin besteht, kontaktieren Sie unseren Support.", - "buyPaymentConfirmFailedAktionariat": "Es gibt ein technisches Problem. Bitte überprüfen Sie Ihr E-Mail-Postfach, möglicherweise fehlt noch eine Bestätigung Ihrer Blockchain-Adresse. Andernfalls versuchen Sie es später erneut. Falls der Fehler weiterhin besteht, kontaktieren Sie unseren Support.", - "buyPaymentConfirmFailedAmountTooLow": "Der Betrag liegt unter dem Mindestbetrag für den Kauf. Bitte erhöhen Sie den Betrag und versuchen Sie es erneut.", "buyPaymentDetailsTitle": "Zahlungsdetails", "buyPaymentInstructionEmail": "Bitte überweisen Sie den Kaufbetrag mittels Banktransaktion. Die folgende Zahlungsanweisung haben wir Ihnen soeben auch per E-Mail zugestellt.", "buyPaymentPurposeHint": "Hinweis: Der Verwendungszweck ist wichtig! Vergessen Sie nicht, ihn als Zahlungsmitteilung zu erfassen.", @@ -286,6 +283,8 @@ "registerEmailVerificationTitle": "Willkommen zurück!", "registerPhoneNumberInvalid": "Telefonnummer ist erforderlich", "registerPhoneNumberOnlyDigits": "Nur Zahlen sind erlaubt", + "registerPhoneNumberPrefixFormat": "Vorwahl muss aus 1 bis 3 Ziffern bestehen", + "registerPhoneNumberPrefixInvalid": "Vorwahl ist erforderlich", "registrationFailed": "Registrierung fehlgeschlagen:\n${message}", "registrationForwardingFailed": "Registrierung angenommen, aber die Weiterleitung an die Gesellschaft ist verzögert. Wir versuchen es automatisch erneut.", "registrationRejected": "Der Server hat Ihre Registrierung abgelehnt:\n${reason}\nIhre Daten wurden nicht gespeichert — bitte prüfen Sie Ihre Angaben und senden Sie erneut ab.", @@ -466,4 +465,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 24998fbfb..58a7d2b84 100644 --- a/assets/languages/strings_en.arb +++ b/assets/languages/strings_en.arb @@ -28,9 +28,6 @@ "buyBackToMain": "Back to main area", "buyMinAmount": "Minimum amount: ${amount} ${currency}", "buyPaymentConfirm": "Buy now bindingly", - "buyPaymentConfirmFailed": "There is a technical problem. Please try again later. If the error persists, contact our support team.", - "buyPaymentConfirmFailedAktionariat": "There is a technical problem. Please check your email inbox — you may still need to confirm your blockchain address. Otherwise, please try again later. If the error persists, contact our support team.", - "buyPaymentConfirmFailedAmountTooLow": "The amount is below the minimum purchase amount. Please increase the amount and try again.", "buyPaymentDetailsTitle": "Payment details", "buyPaymentInstructionEmail": "Please transfer the purchase amount via bank transfer. We have just sent you the following payment instructions by email as well.", "buyPaymentPurposeHint": "Note: The purpose of payment is important! Don't forget to enter it as the payment reference.", @@ -286,6 +283,8 @@ "registerEmailVerificationTitle": "Welcome back!", "registerPhoneNumberInvalid": "Phone number is required", "registerPhoneNumberOnlyDigits": "Only numbers are allowed", + "registerPhoneNumberPrefixFormat": "Country code must be 1 to 3 digits", + "registerPhoneNumberPrefixInvalid": "Country code is required", "registrationFailed": "Registration failed:\n${message}", "registrationForwardingFailed": "Registration accepted, but forwarding to the company is delayed. We will retry automatically.", "registrationRejected": "The server rejected your registration:\n${reason}\nYour data has not been saved — please check your entries and submit again.", @@ -466,4 +465,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 e929bad84..88fe69b5a 100644 --- a/docs/handbook/README.md +++ b/docs/handbook/README.md @@ -21,7 +21,7 @@ deployten Image (`handbook.realunit.app`). ## Screenshots regenerieren -Es gibt keinen separaten Regeneration-Schritt: Die 279 Handbook-Screenshots +Es gibt keinen separaten Regeneration-Schritt: Die 278 Handbook-Screenshots sind direkt die Golden-Baselines unter `test/goldens/` (gemappt in `scripts/assemble-handbook-screenshots.sh`). Eine UI-Änderung an einer der gemappten Pages produziert beim `flutter test test/goldens` einen Diff — @@ -225,7 +225,7 @@ eigenen `EXPECTED_PDF_COUNT`-Guard des balance-Steps — dann die Zahl in Die Sektion **W — Web / realunit.app** (`#spec-web`) zeigt die Visual-Regression-Baselines der öffentlichen Website `realunit.app` -(Landingpage + Aktionariat-Adressbestätigungs-Flow). Sie sind das Web-Pendant +(Landingpage + Aktionariat-Adressbestätigungs-Flow + Adresse-hinzufügen-Flow unter `/account-merge/`). Sie sind das Web-Pendant zu den Golden-Screenshots der App: Jedes Bild ist eine **Playwright-Baseline**, die im `RealUnitCH/web`-Repo unter `tests/__screenshots__/` committet und von dessen Visual-Regression-CI abgesichert ist — driftet das Seitenrendering, wird diff --git a/docs/handbook/de/index.html b/docs/handbook/de/index.html index f8e42e4c8..5c52b111b 100644 --- a/docs/handbook/de/index.html +++ b/docs/handbook/de/index.html @@ -4269,11 +4269,11 @@

37Kauf — Freigabe-Hürden vor dem Angebot

Kursanbieter Aktionariat liefert keine Kurse
- Der externe Kursanbieter Aktionariat liefert aktuell keine Kurse: ein Info-Block (Problem beim Kursanbieter (Aktionariat) samt Erklärung) weist darauf hin, dass das Problem beim Anbieter liegt. Es gibt hier keine Aktions-Schaltfläche, sobald Aktionariat wieder Kurse liefert, funktioniert alles automatisch. + Die Kursabfrage ist fehlgeschlagen. Der Info-Block zeigt die API-Fehlermeldung unverändert. Die App wählt keinen eigenen Text.
@@ -4329,11 +4329,11 @@

38Kauf — Verbindliche Bestätigung

Snackbar-Fehler, Blockchain-Adresse evtl. unbestätigt
- Die Bestätigung ist fehlgeschlagen mit einer Snackbar, die auf Aktionariat verweist: möglicherweise fehlt noch die Bestätigung der Blockchain-Adresse im E-Mail-Postfach. Die Kauf-Schaltfläche darunter ist wieder bedienbar. + Die Bestätigung ist fehlgeschlagen. Die Snackbar zeigt die API-Fehlermeldung unverändert (Beispiel: Kauf konnte nicht bestätigt werden, bitte später erneut versuchen). Die App wählt keinen eigenen Text. Die Kauf-Schaltfläche darunter ist wieder bedienbar.
@@ -4345,27 +4345,27 @@

38Kauf — Verbindliche Bestätigung

Snackbar-Fehler, Betrag unter Mindestbetrag
- Die Bestätigung ist fehlgeschlagen, weil der Betrag unter dem Mindestbetrag für den Kauf liegt. Die Snackbar bittet, den Betrag zu erhöhen und es erneut zu versuchen. + Die Bestätigung ist fehlgeschlagen. Die Snackbar zeigt die API-Meldung zum Mindestbetrag unverändert. Die App wählt keinen eigenen Text. Die Kauf-Schaltfläche darunter ist wieder bedienbar.
-
+
- - - screens/buy/goldens/macos/buy_confirm_failed_unknown.png + + + screens/buy/goldens/macos/buy_confirm_failed_primary_email_required.png
Snackbar-Fehler, allgemeines technisches Problem
- Die Bestätigung ist mit einem allgemeinen technischen Fehler fehlgeschlagen: die Snackbar bittet, es später erneut zu versuchen und bei anhaltendem Fehler den Support zu kontaktieren. Dieser Fall deckt unbekannte Fehlerursachen ab. + Die Bestätigung ist fehlgeschlagen. Die Snackbar zeigt die API-Meldung zur Primary-E-Mail unverändert. Die App wählt keinen eigenen Text. Die Kauf-Schaltfläche darunter ist wieder bedienbar.
@@ -5588,22 +5588,6 @@

53KYC — Registrierung

Das geöffnete Kontotyp-Auswahlmenü im persönlichen Schritt. Das Overlay zeigt die auswählbaren Kontotypen. -
-
- - - screens/kyc/goldens/macos/kyc_registration_personal_step_phone_prefix_open.png -
-
- Registrierung — Vorwahl-Dropdown offen -
-
- Das geöffnete Auswahlmenü für die Telefon-Ländervorwahl (z. B. +41 / +49). Der Nutzer wählt hier die Vorwahl vor der Eingabe der Telefonnummer. -
-
@@ -8095,7 +8079,8 @@

WWeb · realunit.app

Die öffentliche Website realunit.app (Landingpage plus der Aktionariat-Adressbestätigungs-Flow unter - /confirm-aktionariat) hat ihre eigene Visual-Regression-Suite. + /confirm-aktionariat und der Adresse-hinzufügen-Flow unter + /account-merge/) hat ihre eigene Visual-Regression-Suite. Jedes Bild hier ist eine Playwright-Baseline aus dem RealUnitCH/web-Repo (tests/__screenshots__/), aufgenommen im gepinnten @@ -8440,6 +8425,252 @@

Adressbestätigung — Dienst nicht verfügbar

+
+
+
+ + + desktop-chromium/merge-confirmed.png +
+
+ Adresse hinzugefügt — Desktop (DE) +
+
+ Erfolgs-Zustand von /account-merge/ nach gültigem + Link: die Wallet-Adresse ist dem Konto hinzugefügt. Desktop sagt, + auf dem Smartphone zur App zurückzukehren. Desktop, Deutsch. +
+
+
+
+ + + desktop-chromium/merge-confirmed-en.png +
+
+ Address added — Desktop (EN) +
+
+ Derselbe Erfolgs-Zustand auf Englisch (?lang=en). +
+
+
+
+ + + tablet-chromium/merge-confirmed.png +
+
+ Adresse hinzugefügt — Tablet +
+
+ Erfolgs-Zustand im Tablet-Layout. +
+
+
+
+ + + mobile-safari/merge-confirmed-mobile.png +
+
+ Adresse hinzugefügt — Mobile +
+
+ Erfolgs-Zustand in der Mobile-Ansicht; CTA «Zurück zur App». +
+
+
+ +

Adresse hinzufügen — ungültiger Link

+
+
+
+ + + desktop-chromium/merge-invalid.png +
+
+ Ungültiger Adresse-hinzufügen-Link — Desktop (DE) +
+
+ Fehler-Zustand bei ungültigem oder abgelaufenem Link, Desktop, + Deutsch. Nutzer soll in der App eine neue Bestätigung anfordern. +
+
+
+
+ + + desktop-chromium/merge-invalid-en.png +
+
+ Invalid add-address link — Desktop (EN) +
+
+ Derselbe Fehler-Zustand auf Englisch. +
+
+
+
+ + + tablet-chromium/merge-invalid.png +
+
+ Ungültiger Adresse-hinzufügen-Link — Tablet +
+
+ Fehler-Zustand im Tablet-Layout. +
+
+
+
+ + + mobile-safari/merge-invalid.png +
+
+ Ungültiger Adresse-hinzufügen-Link — Mobile +
+
+ Fehler-Zustand in der Mobile-Ansicht. +
+
+
+ +

Adresse hinzufügen — bereits hinzugefügt

+
+
+
+ + + desktop-chromium/merge-already-completed.png +
+
+ Adresse bereits hinzugefügt — Desktop (DE) +
+
+ Zustand, wenn die Wallet-Adresse dem Konto schon zugeordnet ist + (HTTP 409). Desktop sagt, auf dem Smartphone zur App + zurückzukehren. Desktop, Deutsch. +
+
+
+
+ + + desktop-chromium/merge-already-completed-en.png +
+
+ Address already added — Desktop (EN) +
+
+ Derselbe Zustand auf Englisch. +
+
+
+
+ + + mobile-safari/merge-already-completed.png +
+
+ Adresse bereits hinzugefügt — Mobile +
+
+ Derselbe Zustand in der Mobile-Ansicht; CTA «Zurück zur App». +
+
+
+ +

Adresse hinzufügen — Dienst nicht verfügbar

+
+
+
+ + + desktop-chromium/merge-unavailable.png +
+
+ Dienst nicht erreichbar — Desktop (DE) +
+
+ Zustand, wenn die Adresse gerade nicht hinzugefügt werden konnte, + mit Retry-Button. Desktop, Deutsch. +
+
+
+
+ + + desktop-chromium/merge-unavailable-en.png +
+
+ Service temporarily unavailable — Desktop (EN) +
+
+ Derselbe Zustand auf Englisch. +
+
+
+
+ + + mobile-safari/merge-unavailable.png +
+
+ Dienst nicht erreichbar — Mobile +
+
+ Derselbe Zustand in der Mobile-Ansicht. +
+
+
+

404 — Seite nicht gefunden

diff --git a/docs/screens.md b/docs/screens.md index aceea585c..9784edae2 100644 --- a/docs/screens.md +++ b/docs/screens.md @@ -16,7 +16,7 @@ Column meaning: screen, or `—` if the screen has no Golden baseline. Each slot is a Visual-Regression Golden under `test/goldens/`, mapped to its handbook position by `scripts/assemble-handbook-screenshots.sh`. The handbook now - covers **all 279 Golden baselines** — every screen **plus every tested + covers **all 278 Golden baselines** — every screen **plus every tested state variant** (Default / Loading / Error / Snackbar / Dropdown / Validation / Confirm / Success / Failure …), including the areas that were previously absent: Support (email capture, tickets, chat), Settings @@ -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 279 handbook slots is a Visual-Regression +- **Handbook numbering.** Each of the 278 handbook slots is a Visual-Regression Golden under `test/goldens/`, mapped to its handbook position by `scripts/assemble-handbook-screenshots.sh`. A parallel Tier-3 Maestro flow (`.maestro/handbook/NN-*.yaml`) covers navigation/tap-routing smoke for the diff --git a/lib/packages/service/dfx/exceptions/api_exception.dart b/lib/packages/service/dfx/exceptions/api_exception.dart index caa70dec8..74f3e3db9 100644 --- a/lib/packages/service/dfx/exceptions/api_exception.dart +++ b/lib/packages/service/dfx/exceptions/api_exception.dart @@ -31,4 +31,29 @@ class ApiException implements Exception { @override String toString() => 'RealUnitApiException: $message (code: $code, statusCode: $statusCode)'; + + /// User-visible text for an error thrown from a DFX API call. + /// + /// [ApiException] is shown 1:1 as [message]. Any other object has no API + /// text; [Object.toString] is the remainder (transport, parse, local). + static String userFacingMessage(Object error) { + if (error is ApiException) { + return error.message; + } + return error.toString(); + } + + /// Normalizes the JSON `message` field. Null/empty means the API sent no + /// user-facing text — callers must not invent a substitute. + static String? userFacingMessageFromJson(Object? message) { + if (message == null) { + return null; + } + if (message is List) { + final joined = message.map((item) => item.toString()).join(', '); + return joined.isEmpty ? null : joined; + } + final text = message.toString(); + return text.isEmpty ? null : text; + } } diff --git a/lib/packages/service/dfx/exceptions/payment/transfer_exceptions.dart b/lib/packages/service/dfx/exceptions/payment/transfer_exceptions.dart index d97d403e3..4a4b9aa76 100644 --- a/lib/packages/service/dfx/exceptions/payment/transfer_exceptions.dart +++ b/lib/packages/service/dfx/exceptions/payment/transfer_exceptions.dart @@ -41,15 +41,15 @@ class TransferSignatureUnsupportedException implements Exception { /// threshold). Surfaced from the API's `ServiceUnavailable` (503) as a friendly /// "temporarily unavailable" state — the user's REALU is untouched. class TransferGasFundingUnavailableException implements Exception { - /// Diagnostic detail (e.g. the API message), for logs. - final String detail; + /// API `message` when the 503 body carried one; null when the API sent no + /// user-facing text (the view then uses local copy for this reason). + final String? detail; - const TransferGasFundingUnavailableException([ - this.detail = 'gas funding for transfers is temporarily unavailable', - ]); + const TransferGasFundingUnavailableException([this.detail]); @override - String toString() => 'TransferGasFundingUnavailableException: $detail'; + String toString() => + 'TransferGasFundingUnavailableException: ${detail ?? 'gas funding for transfers is temporarily unavailable'}'; } /// The prepare response's recipient/amount does not match what the user diff --git a/lib/packages/service/dfx/real_unit_transfer_service.dart b/lib/packages/service/dfx/real_unit_transfer_service.dart index e6c1a5b87..7bf57f727 100644 --- a/lib/packages/service/dfx/real_unit_transfer_service.dart +++ b/lib/packages/service/dfx/real_unit_transfer_service.dart @@ -53,7 +53,7 @@ class RealUnitTransferService extends DFXAuthService { final errorJson = jsonDecode(response.body) as Map; if (response.statusCode == 503) { throw TransferGasFundingUnavailableException( - (errorJson['message'] ?? 'gas funding for transfers is temporarily unavailable').toString(), + ApiException.userFacingMessageFromJson(errorJson['message']), ); } throw ApiException.fromJson(errorJson, httpStatusCode: response.statusCode); @@ -182,8 +182,7 @@ class RealUnitTransferService extends DFXAuthService { final errorJson = jsonDecode(response.body) as Map; if (response.statusCode == 503) { throw TransferGasFundingUnavailableException( - (errorJson['message'] ?? 'gas funding for transfers is temporarily unavailable') - .toString(), + ApiException.userFacingMessageFromJson(errorJson['message']), ); } final error = ApiException.fromJson(errorJson, httpStatusCode: response.statusCode); diff --git a/lib/screens/buy/cubits/buy_confirm/buy_confirm_cubit.dart b/lib/screens/buy/cubits/buy_confirm/buy_confirm_cubit.dart index 8f1c51d15..abb0af943 100644 --- a/lib/screens/buy/cubits/buy_confirm/buy_confirm_cubit.dart +++ b/lib/screens/buy/cubits/buy_confirm/buy_confirm_cubit.dart @@ -7,16 +7,6 @@ import 'package:realunit_wallet/packages/service/dfx/real_unit_buy_payment_info_ part 'buy_confirm_state.dart'; -// Backend error code for a confirm rejected by Aktionariat because the -// purchase is below its minimum (HTTP 400). Dispatch on the code, not the -// message text — the message is Aktionariat's and may change. -const String _errorCodeAmountTooLow = 'AmountTooLow'; - -// Backend error code for a confirm rejected upstream because the buyer's -// primary email is missing or not yet confirmed at the share register -// (HTTP 400). Dispatch on the code, not the message text. -const String _errorCodePrimaryEmailRequired = 'PrimaryEmailRequired'; - class BuyConfirmCubit extends Cubit { final RealUnitBuyPaymentInfoService _buyPaymentInfoService; @@ -37,17 +27,10 @@ class BuyConfirmCubit extends Cubit { ); } on ApiException catch (e) { developer.log(e.toString()); - final error = e.statusCode == 503 - ? BuyConfirmError.aktionariat - : e.code == _errorCodeAmountTooLow - ? BuyConfirmError.amountTooLow - : e.code == _errorCodePrimaryEmailRequired - ? BuyConfirmError.primaryEmailRequired - : BuyConfirmError.unknown; - emit(BuyConfirmFailure(error)); + emit(BuyConfirmFailure(e.message)); } catch (e) { developer.log(e.toString()); - emit(const BuyConfirmFailure(BuyConfirmError.unknown)); + emit(BuyConfirmFailure(e.toString())); } } } diff --git a/lib/screens/buy/cubits/buy_confirm/buy_confirm_state.dart b/lib/screens/buy/cubits/buy_confirm/buy_confirm_state.dart index 1c36f33b0..3ec2c5911 100644 --- a/lib/screens/buy/cubits/buy_confirm/buy_confirm_state.dart +++ b/lib/screens/buy/cubits/buy_confirm/buy_confirm_state.dart @@ -1,7 +1,5 @@ part of 'buy_confirm_cubit.dart'; -enum BuyConfirmError { aktionariat, amountTooLow, primaryEmailRequired, unknown } - abstract class BuyConfirmState extends Equatable { const BuyConfirmState(); @@ -36,10 +34,12 @@ class BuyConfirmSuccess extends BuyConfirmState { } class BuyConfirmFailure extends BuyConfirmState { - final BuyConfirmError error; + /// User-facing text from the API error body, or `Exception.toString()` when + /// there is no API body. The app does not substitute copy. + final String message; - const BuyConfirmFailure(this.error); + const BuyConfirmFailure(this.message); @override - List get props => [error]; + List get props => [message]; } 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 0eb8891f2..e97acfa99 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 @@ -79,7 +79,10 @@ class BuyPaymentInfoCubit extends Cubit { return const BuyPaymentInfoFailure(PaymentInfoError.primaryEmailRequired); } if (paymentInfo.error == _quoteErrorPrimaryEmailNotConfirmed) { - return const BuyPaymentInfoFailure(PaymentInfoError.primaryEmailNotConfirmed); + return const BuyPaymentInfoFailure( + PaymentInfoError.primaryEmailNotConfirmed, + context: 'RealunitBuy', + ); } return const BuyPaymentInfoFailure(PaymentInfoError.unknown); } @@ -103,13 +106,16 @@ class BuyPaymentInfoCubit extends Cubit { // 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') { - return const BuyPaymentInfoFailure(PaymentInfoError.priceSourceUnavailable); + return BuyPaymentInfoFailure( + PaymentInfoError.priceSourceUnavailable, + message: e.message, + ); } developer.log(e.toString()); - return const BuyPaymentInfoFailure(PaymentInfoError.unknown); + return BuyPaymentInfoFailure(PaymentInfoError.unknown, message: e.message); } catch (e) { developer.log(e.toString()); - return const BuyPaymentInfoFailure(PaymentInfoError.unknown); + return BuyPaymentInfoFailure(PaymentInfoError.unknown, message: e.toString()); } } 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 66acf14bd..06fd49a6e 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 @@ -29,10 +29,20 @@ class BuyPaymentInfoFailure extends BuyPaymentInfoState { final int? requiredLevel; final String? context; - const BuyPaymentInfoFailure(this.error, {this.requiredLevel, this.context}); + /// 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). + final String message; + + const BuyPaymentInfoFailure( + this.error, { + this.requiredLevel, + this.context, + this.message = '', + }); @override - List get props => [error, requiredLevel, context]; + List get props => [error, requiredLevel, context, message]; } class BuyPaymentInfoMinAmountNotMetFailure extends BuyPaymentInfoFailure { diff --git a/lib/screens/buy/widgets/buy_confirm_button.dart b/lib/screens/buy/widgets/buy_confirm_button.dart index f3702ff13..e0cf2f25f 100644 --- a/lib/screens/buy/widgets/buy_confirm_button.dart +++ b/lib/screens/buy/widgets/buy_confirm_button.dart @@ -13,7 +13,8 @@ import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart'; /// Primary buy CTA shown once the API has returned a valid quote. Tapping it /// confirms the purchase (binding) via [BuyConfirmCubit]; on success it opens /// the `Zahlungsdetails` page with the bank-transfer instructions, on failure -/// it surfaces the typed error as a snackbar. +/// it surfaces the API error `message` (or `toString()` without an API body) +/// as a snackbar. class BuyConfirmButton extends StatelessWidget { final BuyPaymentInfo buyPaymentInfo; @@ -62,14 +63,8 @@ class BuyConfirmButtonView extends StatelessWidget { ); } if (state is BuyConfirmFailure) { - final text = switch (state.error) { - BuyConfirmError.aktionariat => S.of(context).buyPaymentConfirmFailedAktionariat, - BuyConfirmError.amountTooLow => S.of(context).buyPaymentConfirmFailedAmountTooLow, - BuyConfirmError.primaryEmailRequired => S.of(context).buyPaymentConfirmFailedAktionariat, - BuyConfirmError.unknown => S.of(context).buyPaymentConfirmFailed, - }; ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(text)), + SnackBar(content: Text(state.message)), ); } }, diff --git a/lib/screens/buy/widgets/payment_action_button.dart b/lib/screens/buy/widgets/payment_action_button.dart index f334f9cf3..972ed141b 100644 --- a/lib/screens/buy/widgets/payment_action_button.dart +++ b/lib/screens/buy/widgets/payment_action_button.dart @@ -122,13 +122,13 @@ class PaymentActionButton extends StatelessWidget { if (paymentState.error == PaymentInfoError.primaryEmailNotConfirmed) { // The buyer has a primary email on record but has not yet confirmed it. // The KYC page auto-routes to its confirm-email step in this case, so - // route there (no `extra`) instead of to email capture, then re-fetch + // 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); + await context.pushNamed(AppRoutes.kyc, extra: paymentState.context); if (context.mounted) { context.read().getPaymentInfo( amount: amountController.text, @@ -157,7 +157,8 @@ class PaymentActionButton extends StatelessWidget { ), ); } - if (paymentState.error == PaymentInfoError.unknown) { + if (paymentState.error == PaymentInfoError.unknown || + paymentState.error == PaymentInfoError.priceSourceUnavailable) { return Padding( padding: const EdgeInsets.symmetric(vertical: 20), child: AppFilledButton( diff --git a/lib/screens/buy/widgets/payment_action_required.dart b/lib/screens/buy/widgets/payment_action_required.dart index 8393cd30d..1f94ee7f1 100644 --- a/lib/screens/buy/widgets/payment_action_required.dart +++ b/lib/screens/buy/widgets/payment_action_required.dart @@ -35,10 +35,11 @@ class PaymentActionRequired extends StatelessWidget { fontWeight: .w600, ), ), - Text( - description, - style: Theme.of(context).textTheme.bodyMedium, - ), + if (description.isNotEmpty) + Text( + description, + style: Theme.of(context).textTheme.bodyMedium, + ), ], ), ), diff --git a/lib/screens/buy/widgets/payment_details_card.dart b/lib/screens/buy/widgets/payment_details_card.dart index a78933c61..cd71c7c4e 100644 --- a/lib/screens/buy/widgets/payment_details_card.dart +++ b/lib/screens/buy/widgets/payment_details_card.dart @@ -56,9 +56,7 @@ class PaymentDetailsCard extends StatelessWidget { }, style: Theme.of(context).textTheme.bodyMedium?.copyWith( fontWeight: isSelected ? .w600 : .normal, - color: isSelected - ? RealUnitColors.realUnitBlue - : RealUnitColors.neutral500, + color: isSelected ? RealUnitColors.realUnitBlue : RealUnitColors.neutral500, ), ); }, @@ -88,7 +86,7 @@ class PaymentDetailsCard extends StatelessWidget { _PaymentDetailsRow( description: S.of(context).iban, value: IbanTextFormatter.formatIban(buyPaymentInfo.iban), - copyValue: buyPaymentInfo.iban, + copyValue: buyPaymentInfo.iban.replaceAll(' ', ''), ), _PaymentDetailsRow( description: S.of(context).bic, @@ -218,14 +216,22 @@ class _PaymentDetailsRow extends StatelessWidget { ), ), ), - InkWell( - child: const Icon( + IconButton( + tooltip: MaterialLocalizations.of(context).copyButtonLabel, + iconSize: 16, + padding: EdgeInsets.zero, + constraints: const BoxConstraints.tightFor(width: 44, height: 44), + visualDensity: VisualDensity.standard, + style: IconButton.styleFrom( + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + minimumSize: const Size(44, 44), + ), + icon: const Icon( Icons.copy_outlined, color: RealUnitColors.realUnitBlue, - fontWeight: FontWeight.bold, size: 16, ), - onTap: () => Clipboard.setData( + onPressed: () => Clipboard.setData( ClipboardData( text: copyValue ?? value, ), diff --git a/lib/screens/buy/widgets/payment_information.dart b/lib/screens/buy/widgets/payment_information.dart index 68050e2ca..dd1908b55 100644 --- a/lib/screens/buy/widgets/payment_information.dart +++ b/lib/screens/buy/widgets/payment_information.dart @@ -37,15 +37,14 @@ class PaymentInformation extends StatelessWidget { title: S.of(context).bitboxDisconnectedTitle, description: S.of(context).bitboxDisconnectedDescription, ); - } else if (error == PaymentInfoError.priceSourceUnavailable) { + } else if (error == PaymentInfoError.priceSourceUnavailable || + error == PaymentInfoError.unknown) { + if (paymentInfoState.message.isEmpty) { + return const SizedBox.shrink(); + } return PaymentActionRequired( - title: S.of(context).priceProviderUnavailableTitle, - description: S.of(context).priceProviderUnavailableDescription, - ); - } else if (error == PaymentInfoError.unknown) { - return PaymentActionRequired( - title: S.of(context).paymentInformationFailed, - description: S.of(context).paymentInformationFailedDescription, + title: paymentInfoState.message, + description: '', ); } } diff --git a/lib/screens/kyc/cubits/kyc/kyc_cubit.dart b/lib/screens/kyc/cubits/kyc/kyc_cubit.dart index 779131da3..d4c34f658 100644 --- a/lib/screens/kyc/cubits/kyc/kyc_cubit.dart +++ b/lib/screens/kyc/cubits/kyc/kyc_cubit.dart @@ -70,7 +70,7 @@ class KycCubit extends Cubit { emit(const KycFailure('KYC backend did not respond in time')); } catch (e) { if (isClosed || generation != _runGeneration) return; - emit(KycFailure(e.toString())); + emit(KycFailure(ApiException.userFacingMessage(e))); } } @@ -277,7 +277,7 @@ class KycCubit extends Cubit { } } catch (e) { if (isClosed || generation != _runGeneration) return; - emit(KycFailure(e.toString())); + emit(KycFailure(ApiException.userFacingMessage(e))); } } @@ -299,12 +299,12 @@ class KycCubit extends Cubit { } on ApiException catch (e) { if (isClosed) return; if (e.statusCode != 404) { - emit(KycFailure(e.toString())); + emit(KycFailure(ApiException.userFacingMessage(e))); return; } } catch (e) { if (isClosed) return; - emit(KycFailure(e.toString())); + emit(KycFailure(ApiException.userFacingMessage(e))); return; } await checkKyc(); diff --git a/lib/screens/kyc/steps/2fa/cubits/kyc_2fa/kyc_2fa_cubit.dart b/lib/screens/kyc/steps/2fa/cubits/kyc_2fa/kyc_2fa_cubit.dart index fe8abb7cd..8d99e56ff 100644 --- a/lib/screens/kyc/steps/2fa/cubits/kyc_2fa/kyc_2fa_cubit.dart +++ b/lib/screens/kyc/steps/2fa/cubits/kyc_2fa/kyc_2fa_cubit.dart @@ -1,6 +1,7 @@ import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_kyc_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart'; part 'kyc_2fa_state.dart'; @@ -16,6 +17,8 @@ class Kyc2FaCubit extends Cubit { emit(const Kyc2FaLoading()); await _dfxKycService.request2FaCode(); emit(const Kyc2FaSuccess()); + } on ApiException catch (e) { + emit(Kyc2FaFailure(errorMessage: e.message)); } catch (e) { emit(Kyc2FaFailure(errorMessage: e.toString())); } diff --git a/lib/screens/kyc/steps/2fa/cubits/kyc_2fa_verify/kyc_2fa_verify_cubit.dart b/lib/screens/kyc/steps/2fa/cubits/kyc_2fa_verify/kyc_2fa_verify_cubit.dart index 796c1ef3a..d29efa90a 100644 --- a/lib/screens/kyc/steps/2fa/cubits/kyc_2fa_verify/kyc_2fa_verify_cubit.dart +++ b/lib/screens/kyc/steps/2fa/cubits/kyc_2fa_verify/kyc_2fa_verify_cubit.dart @@ -1,6 +1,7 @@ import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_kyc_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart'; part 'kyc_2fa_verify_state.dart'; @@ -16,6 +17,8 @@ class Kyc2FaVerifyCubit extends Cubit { emit(const Kyc2FaVerifyLoading()); await _dfxKycService.verify2FaCode(code); emit(const Kyc2FaVerifySuccess()); + } on ApiException catch (e) { + emit(Kyc2FaVerifyFailure(errorMessage: e.message)); } catch (e) { emit(Kyc2FaVerifyFailure(errorMessage: e.toString())); } diff --git a/lib/screens/kyc/steps/2fa/kyc_2fa_page.dart b/lib/screens/kyc/steps/2fa/kyc_2fa_page.dart index cf3176725..9b63d587b 100644 --- a/lib/screens/kyc/steps/2fa/kyc_2fa_page.dart +++ b/lib/screens/kyc/steps/2fa/kyc_2fa_page.dart @@ -66,7 +66,7 @@ class _Kyc2FaViewState extends State { if (state is Kyc2FaVerifyFailure) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('${S.of(context).twoFaWrongCode}: ${state.errorMessage}'), + content: Text(state.errorMessage), backgroundColor: RealUnitColors.status.red600, ), ); @@ -78,9 +78,7 @@ class _Kyc2FaViewState extends State { if (state is Kyc2FaFailure) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text( - '${S.of(context).twoFaSendCodeFailed}: ${state.errorMessage}', - ), + content: Text(state.errorMessage), backgroundColor: RealUnitColors.status.red600, ), ); diff --git a/lib/screens/kyc/steps/confirm_email/cubits/kyc_confirm_email_cubit.dart b/lib/screens/kyc/steps/confirm_email/cubits/kyc_confirm_email_cubit.dart index 3a06c688a..12693bac0 100644 --- a/lib/screens/kyc/steps/confirm_email/cubits/kyc_confirm_email_cubit.dart +++ b/lib/screens/kyc/steps/confirm_email/cubits/kyc_confirm_email_cubit.dart @@ -1,5 +1,6 @@ import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart'; import 'package:realunit_wallet/packages/service/dfx/real_unit_registration_service.dart'; part 'kyc_confirm_email_state.dart'; @@ -48,13 +49,12 @@ class KycConfirmEmailCubit extends Cubit { return; } emit(const KycConfirmEmailConfirmed()); - } catch (_) { + } catch (e) { if (isClosed || generation != _runGeneration) return; - // A failed re-check (network / backend) must not wedge the button in its - // loading state, and must not let the user through — fail closed. Surface - // the same retry affordance as a still-unconfirmed address so the user - // can tap again once connectivity returns. - emit(const KycConfirmEmailNotConfirmed()); + // A failed re-check must not wedge the button, and must not let the user + // through — fail closed. Do not invent "email not confirmed" for an API + // or transport error: show the API text 1:1 so the user can retry. + emit(KycConfirmEmailFailure(ApiException.userFacingMessage(e))); } } } diff --git a/lib/screens/kyc/steps/confirm_email/cubits/kyc_confirm_email_state.dart b/lib/screens/kyc/steps/confirm_email/cubits/kyc_confirm_email_state.dart index 0af35cacc..10093c163 100644 --- a/lib/screens/kyc/steps/confirm_email/cubits/kyc_confirm_email_state.dart +++ b/lib/screens/kyc/steps/confirm_email/cubits/kyc_confirm_email_state.dart @@ -21,8 +21,19 @@ class KycConfirmEmailConfirmed extends KycConfirmEmailState { const KycConfirmEmailConfirmed(); } -/// The address is still not confirmed, or the re-check failed. The page shows a -/// retry hint and keeps the user on the confirm step. +/// The address is still not confirmed. The page shows a retry hint and keeps +/// the user on the confirm step. class KycConfirmEmailNotConfirmed extends KycConfirmEmailState { const KycConfirmEmailNotConfirmed(); } + +/// The re-check itself failed (API / transport). The page shows the API text +/// 1:1 and keeps the user on the confirm step so they can retry. +class KycConfirmEmailFailure extends KycConfirmEmailState { + final String message; + + const KycConfirmEmailFailure(this.message); + + @override + List get props => [message]; +} diff --git a/lib/screens/kyc/steps/confirm_email/kyc_confirm_email_page.dart b/lib/screens/kyc/steps/confirm_email/kyc_confirm_email_page.dart index 32e5e87ae..059aa3458 100644 --- a/lib/screens/kyc/steps/confirm_email/kyc_confirm_email_page.dart +++ b/lib/screens/kyc/steps/confirm_email/kyc_confirm_email_page.dart @@ -38,6 +38,14 @@ class KycConfirmEmailView extends StatelessWidget { ), ); } + if (state is KycConfirmEmailFailure) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(state.message), + backgroundColor: RealUnitColors.status.red600, + ), + ); + } if (state is KycConfirmEmailConfirmed) { // Confirmation flipped true (or the API reports no gate). Hand back to // the KYC flow, which re-fetches `getRegistrationInfo` and routes on diff --git a/lib/screens/kyc/steps/email/kyc_email_page.dart b/lib/screens/kyc/steps/email/kyc_email_page.dart index 528753ca8..848785006 100644 --- a/lib/screens/kyc/steps/email/kyc_email_page.dart +++ b/lib/screens/kyc/steps/email/kyc_email_page.dart @@ -58,12 +58,9 @@ class _KycEmailFormState extends State { return BlocListener( listener: (context, state) async { if (state is KycEmailStepFailure) { - final message = state.error == .emailDoesNotMatch - ? S.of(context).registerEmailDoesNotMatch - : state.message; ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(message), + content: Text(state.message), backgroundColor: RealUnitColors.status.red600, ), ); diff --git a/lib/screens/kyc/steps/financial_data/cubits/kyc_financial_data_cubit.dart b/lib/screens/kyc/steps/financial_data/cubits/kyc_financial_data_cubit.dart index b5e914dc8..63c8df552 100644 --- a/lib/screens/kyc/steps/financial_data/cubits/kyc_financial_data_cubit.dart +++ b/lib/screens/kyc/steps/financial_data/cubits/kyc_financial_data_cubit.dart @@ -34,7 +34,7 @@ class KycFinancialDataCubit extends Cubit { ), ); } catch (e) { - emit(KycFinancialDataFailure(e.toString())); + emit(KycFinancialDataFailure(ApiException.userFacingMessage(e))); } } @@ -96,7 +96,7 @@ class KycFinancialDataCubit extends Cubit { } // Keep the answers and stay on the questions UI so the user can retry, // instead of dropping them onto a dead-end failure page. - emit(KycFinancialDataSubmitFailure.from(current, e.toString())); + emit(KycFinancialDataSubmitFailure.from(current, ApiException.userFacingMessage(e))); } } diff --git a/lib/screens/kyc/steps/ident/kyc_ident_page.dart b/lib/screens/kyc/steps/ident/kyc_ident_page.dart index 0339f9bb7..91c7d7fc0 100644 --- a/lib/screens/kyc/steps/ident/kyc_ident_page.dart +++ b/lib/screens/kyc/steps/ident/kyc_ident_page.dart @@ -57,9 +57,7 @@ class KycIdentView extends StatelessWidget { } else if (state.status == FailureStatus.error) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text( - '${S.of(context).identityCheckFailed} ${state.errorMessage}.', - ), + content: Text(state.errorMessage ?? (throw StateError('ident error without message'))), backgroundColor: RealUnitColors.status.red600, ), ); diff --git a/lib/screens/kyc/steps/link_wallet/cubits/kyc_link_wallet_cubit.dart b/lib/screens/kyc/steps/link_wallet/cubits/kyc_link_wallet_cubit.dart index a6cd9161b..a6b49880e 100644 --- a/lib/screens/kyc/steps/link_wallet/cubits/kyc_link_wallet_cubit.dart +++ b/lib/screens/kyc/steps/link_wallet/cubits/kyc_link_wallet_cubit.dart @@ -1,5 +1,6 @@ import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart'; import 'package:realunit_wallet/packages/service/dfx/exceptions/bitbox_exception.dart'; import 'package:realunit_wallet/packages/service/dfx/models/user/dto/real_unit_user_data_dto.dart'; import 'package:realunit_wallet/packages/service/dfx/real_unit_registration_service.dart'; @@ -26,6 +27,8 @@ class KycLinkWalletCubit extends Cubit { emit(const KycLinkWalletSuccess()); } on BitboxNotConnectedException { emit(KycLinkWalletBitboxRequired(userData)); + } on ApiException catch (e) { + emit(KycLinkWalletFailure(e.message, cause: e)); } catch (e) { emit(KycLinkWalletFailure(e.toString(), cause: e)); } diff --git a/lib/screens/kyc/steps/link_wallet/kyc_link_wallet_page.dart b/lib/screens/kyc/steps/link_wallet/kyc_link_wallet_page.dart index eda37a264..a95b069b6 100644 --- a/lib/screens/kyc/steps/link_wallet/kyc_link_wallet_page.dart +++ b/lib/screens/kyc/steps/link_wallet/kyc_link_wallet_page.dart @@ -60,7 +60,7 @@ class KycLinkWalletView extends StatelessWidget { if (state is KycLinkWalletFailure) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(S.of(context).registrationFailed(state.message)), + content: Text(state.message), backgroundColor: RealUnitColors.status.red600, ), ); diff --git a/lib/screens/kyc/steps/nationality/cubit/kyc_nationality/kyc_nationality_cubit.dart b/lib/screens/kyc/steps/nationality/cubit/kyc_nationality/kyc_nationality_cubit.dart index 73df40d49..5988315a1 100644 --- a/lib/screens/kyc/steps/nationality/cubit/kyc_nationality/kyc_nationality_cubit.dart +++ b/lib/screens/kyc/steps/nationality/cubit/kyc_nationality/kyc_nationality_cubit.dart @@ -1,6 +1,7 @@ import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_kyc_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart'; import 'package:realunit_wallet/packages/service/dfx/models/country/country.dart'; part 'kyc_nationality_state.dart'; @@ -22,6 +23,8 @@ class KycNationalityCubit extends Cubit { 'nationality': {'id': nationality.id}, }); emit(const KycNationalitySuccess()); + } on ApiException catch (e) { + emit(KycNationalityFailure(e.message)); } catch (e) { emit(KycNationalityFailure(e.toString())); } diff --git a/lib/screens/kyc/steps/nationality/kyc_nationality_page.dart b/lib/screens/kyc/steps/nationality/kyc_nationality_page.dart index bdb7c6328..2b10120a9 100644 --- a/lib/screens/kyc/steps/nationality/kyc_nationality_page.dart +++ b/lib/screens/kyc/steps/nationality/kyc_nationality_page.dart @@ -51,7 +51,7 @@ class _KycNationalityViewState extends State { if (state is KycNationalityFailure) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(S.of(context).setNationalityFailed(state.message)), + content: Text(state.message), backgroundColor: RealUnitColors.status.red600, ), ); diff --git a/lib/screens/kyc/steps/personal_data/cubit/kyc_personal_data/kyc_personal_data_cubit.dart b/lib/screens/kyc/steps/personal_data/cubit/kyc_personal_data/kyc_personal_data_cubit.dart index d4aab5fe3..b6247548c 100644 --- a/lib/screens/kyc/steps/personal_data/cubit/kyc_personal_data/kyc_personal_data_cubit.dart +++ b/lib/screens/kyc/steps/personal_data/cubit/kyc_personal_data/kyc_personal_data_cubit.dart @@ -1,6 +1,7 @@ import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_kyc_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart'; import 'package:realunit_wallet/packages/service/dfx/models/country/country.dart'; import 'package:realunit_wallet/packages/service/dfx/models/registration/kyc/kyc_personal_data.dart'; @@ -48,6 +49,8 @@ class KycPersonalDataCubit extends Cubit { ).toJson(), ); emit(const KycPersonalDataSuccess()); + } on ApiException catch (e) { + emit(KycPersonalDataFailure(e.message)); } catch (e) { emit(KycPersonalDataFailure(e.toString())); } diff --git a/lib/screens/kyc/steps/personal_data/kyc_personal_data_page.dart b/lib/screens/kyc/steps/personal_data/kyc_personal_data_page.dart index f5994ba3a..a1a4c8b62 100644 --- a/lib/screens/kyc/steps/personal_data/kyc_personal_data_page.dart +++ b/lib/screens/kyc/steps/personal_data/kyc_personal_data_page.dart @@ -159,7 +159,7 @@ class _KycPersonalDataViewState extends State { if (state is KycPersonalDataFailure) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(S.of(context).kycPersonalDataFailed(state.message)), + content: Text(state.message), backgroundColor: RealUnitColors.status.red600, ), ); diff --git a/lib/screens/kyc/steps/registration/cubits/registration_submit/kyc_registration_submit_cubit.dart b/lib/screens/kyc/steps/registration/cubits/registration_submit/kyc_registration_submit_cubit.dart index a1dfab7ec..6f9c351f0 100644 --- a/lib/screens/kyc/steps/registration/cubits/registration_submit/kyc_registration_submit_cubit.dart +++ b/lib/screens/kyc/steps/registration/cubits/registration_submit/kyc_registration_submit_cubit.dart @@ -3,6 +3,7 @@ import 'dart:developer' as developer; import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_kyc_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart'; import 'package:realunit_wallet/packages/service/dfx/exceptions/bitbox_exception.dart'; import 'package:realunit_wallet/packages/service/dfx/models/country/country.dart'; import 'package:realunit_wallet/packages/service/dfx/models/registration/dto/real_unit_registration_request_dto.dart'; @@ -66,6 +67,10 @@ class KycRegistrationSubmitCubit extends Cubit { } else { emit(const KycRegistrationSubmitFailure('Mail could not be fetched')); } + } on ApiException catch (e) { + developer.log(e.toString()); + emit(KycRegistrationSubmitFailure(e.message, cause: e)); + return; } catch (e) { developer.log(e.toString()); emit(KycRegistrationSubmitFailure(e.toString(), cause: e)); @@ -86,6 +91,9 @@ class KycRegistrationSubmitCubit extends Cubit { emit( KycRegistrationSubmitBitboxRequired(registration: registration), ); + } on ApiException catch (e) { + developer.log(e.toString()); + emit(KycRegistrationSubmitFailure(e.message, cause: e)); } catch (e) { developer.log(e.toString()); emit(KycRegistrationSubmitFailure(e.toString(), cause: e)); diff --git a/lib/screens/kyc/steps/registration/kyc_registration_page.dart b/lib/screens/kyc/steps/registration/kyc_registration_page.dart index 72d0d1e9d..4bc984ba7 100644 --- a/lib/screens/kyc/steps/registration/kyc_registration_page.dart +++ b/lib/screens/kyc/steps/registration/kyc_registration_page.dart @@ -238,9 +238,9 @@ class _KycRegistrationViewState extends State { // pre-submit failures (getUser, register/date) stay on the // generic message: "check your entries" would be the wrong // instruction there. - message = S.of(context).registrationRejected(cause.message); + message = cause.message; } else { - message = S.of(context).registrationFailed(state.message); + message = state.message; } ScaffoldMessenger.of(context).showSnackBar( SnackBar( diff --git a/lib/screens/kyc/subpages/kyc_failure_page.dart b/lib/screens/kyc/subpages/kyc_failure_page.dart index c840801a9..d6bbea85a 100644 --- a/lib/screens/kyc/subpages/kyc_failure_page.dart +++ b/lib/screens/kyc/subpages/kyc_failure_page.dart @@ -31,7 +31,7 @@ class KycFailurePage extends StatelessWidget { ), ), Text( - S.of(context).kycFailureDescription(message), + message, textAlign: TextAlign.center, style: const TextStyle( color: RealUnitColors.neutral500, diff --git a/lib/screens/pay/cubits/pay_process/pay_process_cubit.dart b/lib/screens/pay/cubits/pay_process/pay_process_cubit.dart index f8f4993cd..763711c8f 100644 --- a/lib/screens/pay/cubits/pay_process/pay_process_cubit.dart +++ b/lib/screens/pay/cubits/pay_process/pay_process_cubit.dart @@ -10,6 +10,7 @@ import 'package:realunit_wallet/packages/service/app_store.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_blockchain_api_service.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_faucet_service.dart'; import 'package:realunit_wallet/packages/service/dfx/eip1559_unsigned_tx_decoder.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart'; import 'package:realunit_wallet/packages/service/dfx/exceptions/bitbox_exception.dart'; import 'package:realunit_wallet/packages/service/dfx/exceptions/payment/pay_exceptions.dart'; import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/lnurlp_payment_dto.dart'; @@ -179,7 +180,7 @@ class PayProcessCubit extends Cubit { await _checkEthBalance(swap); } catch (e) { if (isClosed) return; - emit(PayProcessFailure(PayProcessFailureReason.generic, message: e.toString())); + emit(PayProcessFailure(PayProcessFailureReason.generic, message: ApiException.userFacingMessage(e))); } } @@ -199,7 +200,12 @@ class PayProcessCubit extends Cubit { _startEthPolling(swap); } catch (e) { if (isClosed) return; - emit(PayProcessFailure(PayProcessFailureReason.insufficientEth, message: e.toString())); + emit( + PayProcessFailure( + PayProcessFailureReason.insufficientEth, + message: ApiException.userFacingMessage(e), + ), + ); } } @@ -220,12 +226,7 @@ class PayProcessCubit extends Cubit { await _executeSwap(); } else if (_ethPollAttempts >= _ethPollMaxAttempts) { _ethPollingTimer?.cancel(); - emit( - const PayProcessFailure( - PayProcessFailureReason.insufficientEth, - message: 'eth balance polling exceeded max attempts', - ), - ); + emit(const PayProcessFailure(PayProcessFailureReason.insufficientEth)); } // else: balance still short — falls through to `finally`, which releases // `_swapInFlight` so the next tick can retry. @@ -233,12 +234,7 @@ class PayProcessCubit extends Cubit { if (isClosed) return; if (_ethPollAttempts >= _ethPollMaxAttempts) { _ethPollingTimer?.cancel(); - emit( - const PayProcessFailure( - PayProcessFailureReason.insufficientEth, - message: 'eth balance polling exceeded max attempts', - ), - ); + emit(const PayProcessFailure(PayProcessFailureReason.insufficientEth)); return; } // keep polling on transient errors (including per-request timeout) — `finally` below @@ -278,7 +274,7 @@ class PayProcessCubit extends Cubit { emit(const PayProcessFailure(PayProcessFailureReason.bitboxRequired)); } catch (e) { if (isClosed) return; - emit(PayProcessFailure(PayProcessFailureReason.generic, message: e.toString())); + emit(PayProcessFailure(PayProcessFailureReason.generic, message: ApiException.userFacingMessage(e))); } } @@ -307,7 +303,7 @@ class PayProcessCubit extends Cubit { // Transient/network error fetching the quote — NOT a genuine expiry. // Retry the pay leg; the swapped ZCHF stays in the wallet. if (isClosed) return; - emit(PayProcessPayRetry(PayRetryReason.transient, message: e.toString())); + emit(PayProcessPayRetry(PayRetryReason.transient, message: ApiException.userFacingMessage(e))); return; } @@ -337,13 +333,7 @@ class PayProcessCubit extends Cubit { final freshZchf = _zchfTransferAmount(details); if (freshZchf != null && _settlementExceedsAcquired(freshZchf.amount, freshZchf.raw, _acquiredZchf)) { - emit( - PayProcessPayRetry( - PayRetryReason.insufficientZchf, - message: - 'fresh settlement ${freshZchf.amount} ZCHF exceeds acquired $_acquiredZchf ZCHF', - ), - ); + emit(const PayProcessPayRetry(PayRetryReason.insufficientZchf)); return; } @@ -374,19 +364,19 @@ class PayProcessCubit extends Cubit { if (isClosed) return; emit(PayProcessAwaitingSettlement(txId)); _startStatusPolling(); - } on PayUnsignedTxMismatchException catch (e) { + } on PayUnsignedTxMismatchException { // The backend's own unsigned tx does not match its own metadata — never sign it. The swap // already happened; recovery retries the pay leg, which re-fetches AND re-validates a fresh // unsigned tx from scratch, so a bad tx can never slip through on retry. if (isClosed) return; - emit(PayProcessPayRetry(PayRetryReason.unsignedTxMismatch, message: e.toString())); + emit(const PayProcessPayRetry(PayRetryReason.unsignedTxMismatch)); } catch (e) { // The swap already happened; the user holds ZCHF. Any pay-leg failure here (signing // dropped, BitBox disconnect, transient submit error, settlement rejected) is recoverable // by retrying the pay leg — never by re-swapping. Surface the retryable state rather than a // terminal failure. if (isClosed) return; - emit(PayProcessPayRetry(PayRetryReason.transient, message: e.toString())); + emit(PayProcessPayRetry(PayRetryReason.transient, message: ApiException.userFacingMessage(e))); } } @@ -539,12 +529,7 @@ class PayProcessCubit extends Cubit { if (!status.status.isTerminal) { if (_statusPollAttempts >= _statusPollMaxAttempts) { _statusPollingTimer?.cancel(); - emit( - const PayProcessPayRetry( - PayRetryReason.transient, - message: 'status polling exceeded max attempts', - ), - ); + emit(const PayProcessPayRetry(PayRetryReason.transient)); return; } _statusPollInFlight = false; @@ -561,12 +546,7 @@ class PayProcessCubit extends Cubit { _statusPollAttempts++; if (_statusPollAttempts >= _statusPollMaxAttempts) { _statusPollingTimer?.cancel(); - emit( - const PayProcessPayRetry( - PayRetryReason.transient, - message: 'status polling exceeded max attempts', - ), - ); + emit(const PayProcessPayRetry(PayRetryReason.transient)); return; } _statusPollInFlight = false; diff --git a/lib/screens/pay/cubits/pay_process/pay_process_state.dart b/lib/screens/pay/cubits/pay_process/pay_process_state.dart index 9f501ff68..1a512e003 100644 --- a/lib/screens/pay/cubits/pay_process/pay_process_state.dart +++ b/lib/screens/pay/cubits/pay_process/pay_process_state.dart @@ -96,7 +96,8 @@ class PayProcessSuccess extends PayProcessState { class PayProcessPayRetry extends PayProcessState { final PayRetryReason reason; - /// Diagnostic detail for logs — not the user-facing copy. + /// API `message` when the failure came from the DFX API; otherwise null so + /// the view can fall back to local copy for process-local reasons. final String? message; const PayProcessPayRetry(this.reason, {this.message}); @@ -108,7 +109,8 @@ class PayProcessPayRetry extends PayProcessState { class PayProcessFailure extends PayProcessState { final PayProcessFailureReason reason; - /// Diagnostic detail for logs — not the user-facing copy. + /// API `message` when the failure came from the DFX API; otherwise null so + /// the view can fall back to local copy for hardware / process-local reasons. final String? message; const PayProcessFailure(this.reason, {this.message}); diff --git a/lib/screens/pay/cubits/pay_quote/pay_quote_cubit.dart b/lib/screens/pay/cubits/pay_quote/pay_quote_cubit.dart index 4e0d7e07d..ceeff9eb6 100644 --- a/lib/screens/pay/cubits/pay_quote/pay_quote_cubit.dart +++ b/lib/screens/pay/cubits/pay_quote/pay_quote_cubit.dart @@ -1,5 +1,6 @@ import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart'; import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/lnurlp_payment_dto.dart'; import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_swap_dto.dart'; import 'package:realunit_wallet/packages/service/dfx/real_unit_pay_service.dart'; @@ -62,7 +63,7 @@ class PayQuoteCubit extends Cubit { ); } catch (e) { if (isClosed) return; - emit(PayQuoteError(e.toString())); + emit(PayQuoteError(ApiException.userFacingMessage(e))); } } diff --git a/lib/screens/pay/pay_process_page.dart b/lib/screens/pay/pay_process_page.dart index 23ac7f8ff..277b8d756 100644 --- a/lib/screens/pay/pay_process_page.dart +++ b/lib/screens/pay/pay_process_page.dart @@ -59,13 +59,13 @@ class PayProcessView extends StatelessWidget { } else if (state is PayProcessPayRetry) { // The swap already succeeded — offer to retry the PAY leg only. The // ZCHF stays in the wallet; this never re-swaps. - await _showRetrySheet(context, state.reason); + await _showRetrySheet(context, state); } else if (state is PayProcessFailure) { await _showResultSheet( context, icon: Icons.error_rounded, title: S.of(context).payFailureTitle, - description: _failureMessage(context, state.reason), + description: _failureMessage(context, state), ); } }, @@ -105,20 +105,32 @@ class PayProcessView extends StatelessWidget { PayProcessFailure() => S.of(context).payFailureTitle, }; - String _failureMessage(BuildContext context, PayProcessFailureReason reason) => switch (reason) { - PayProcessFailureReason.insufficientZchf => S.of(context).payFailureInsufficientZchf, - PayProcessFailureReason.insufficientEth => S.of(context).payFailureInsufficientEth, - PayProcessFailureReason.signatureUnsupported => S.of(context).payFailureSignatureUnsupported, - PayProcessFailureReason.bitboxRequired => S.of(context).payFailureBitboxRequired, - PayProcessFailureReason.generic => S.of(context).payFailureGeneric, - }; + String _failureMessage(BuildContext context, PayProcessFailure state) { + final apiText = state.message; + if (apiText != null && apiText.isNotEmpty) { + return apiText; + } + return switch (state.reason) { + PayProcessFailureReason.insufficientZchf => S.of(context).payFailureInsufficientZchf, + PayProcessFailureReason.insufficientEth => S.of(context).payFailureInsufficientEth, + PayProcessFailureReason.signatureUnsupported => S.of(context).payFailureSignatureUnsupported, + PayProcessFailureReason.bitboxRequired => S.of(context).payFailureBitboxRequired, + PayProcessFailureReason.generic => S.of(context).payFailureGeneric, + }; + } - String _retryMessage(BuildContext context, PayRetryReason reason) => switch (reason) { - PayRetryReason.quoteExpired => S.of(context).payRetryQuoteExpired, - PayRetryReason.transient => S.of(context).payRetryTransient, - PayRetryReason.insufficientZchf => S.of(context).payRetryInsufficientZchf, - PayRetryReason.unsignedTxMismatch => S.of(context).payRetryUnsignedTxMismatch, - }; + String _retryMessage(BuildContext context, PayProcessPayRetry state) { + final apiText = state.message; + if (apiText != null && apiText.isNotEmpty) { + return apiText; + } + return switch (state.reason) { + PayRetryReason.quoteExpired => S.of(context).payRetryQuoteExpired, + PayRetryReason.transient => S.of(context).payRetryTransient, + PayRetryReason.insufficientZchf => S.of(context).payRetryInsufficientZchf, + PayRetryReason.unsignedTxMismatch => S.of(context).payRetryUnsignedTxMismatch, + }; + } Future _showResultSheet( BuildContext context, { @@ -161,7 +173,7 @@ class PayProcessView extends StatelessWidget { /// primary action retries the PAY leg only ([PayProcessCubit.retryPay]) — the /// swap is never redone, so the ZCHF already held is reused. Dismissing leaves /// that ZCHF safely in the wallet. - Future _showRetrySheet(BuildContext context, PayRetryReason reason) async { + Future _showRetrySheet(BuildContext context, PayProcessPayRetry state) async { 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. @@ -181,7 +193,7 @@ class PayProcessView extends StatelessWidget { style: Theme.of(sheetContext).textTheme.headlineMedium, ), Text( - _retryMessage(sheetContext, reason), + _retryMessage(sheetContext, state), textAlign: TextAlign.center, style: Theme.of(sheetContext).textTheme.bodyMedium?.copyWith( color: RealUnitColors.neutral500, diff --git a/lib/screens/pay/pay_quote_page.dart b/lib/screens/pay/pay_quote_page.dart index df9c33703..5ae79073d 100644 --- a/lib/screens/pay/pay_quote_page.dart +++ b/lib/screens/pay/pay_quote_page.dart @@ -40,8 +40,8 @@ class PayQuoteView extends StatelessWidget { PayQuoteReady() => _PayQuoteReadyView(state: state), PayQuoteExpired() => _PayQuoteMessage(message: S.of(context).payFailureQuoteExpired), PayQuoteUnavailable() => _PayQuoteMessage(message: S.of(context).payQuoteUnavailable), - PayQuoteError() => _PayQuoteMessage( - message: S.of(context).payFailureGeneric, + PayQuoteError(:final message) => _PayQuoteMessage( + message: message.isNotEmpty ? message : S.of(context).payFailureGeneric, onRetry: () => context.read().load(), ), }, diff --git a/lib/screens/sell/cubits/sell_bank_accounts/sell_bank_accounts_cubit.dart b/lib/screens/sell/cubits/sell_bank_accounts/sell_bank_accounts_cubit.dart index 71ac82b16..316733d8c 100644 --- a/lib/screens/sell/cubits/sell_bank_accounts/sell_bank_accounts_cubit.dart +++ b/lib/screens/sell/cubits/sell_bank_accounts/sell_bank_accounts_cubit.dart @@ -3,6 +3,7 @@ import 'dart:developer' as developer; import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_bank_account_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart'; import 'package:realunit_wallet/packages/service/dfx/models/bank_account/bank_account.dart'; part 'sell_bank_accounts_state.dart'; @@ -27,7 +28,7 @@ class SellBankAccountsCubit extends Cubit { } catch (e) { developer.log(e.toString()); if (isClosed) return; - emit(SellBankAccountsAddFailure(state.accounts, e.toString())); + emit(SellBankAccountsAddFailure(state.accounts, ApiException.userFacingMessage(e))); } } diff --git a/lib/screens/sell/cubits/sell_confirm/sell_confirm_cubit.dart b/lib/screens/sell/cubits/sell_confirm/sell_confirm_cubit.dart index 2cde02d51..259442d5e 100644 --- a/lib/screens/sell/cubits/sell_confirm/sell_confirm_cubit.dart +++ b/lib/screens/sell/cubits/sell_confirm/sell_confirm_cubit.dart @@ -1,5 +1,6 @@ import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart'; import 'package:realunit_wallet/packages/service/dfx/exceptions/payment/sell_exceptions.dart'; import 'package:realunit_wallet/packages/service/dfx/models/payment/sell/sell_payment_info.dart'; import 'package:realunit_wallet/packages/service/dfx/real_unit_sell_payment_info_service.dart'; @@ -25,7 +26,7 @@ class SellConfirmCubit extends Cubit { emit(SellConfirmSuccess()); } catch (e) { if (isClosed) return; - emit(SellConfirmFailure(e.toString())); + emit(SellConfirmFailure(ApiException.userFacingMessage(e))); } } } 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 8324b35c0..2c4ba5d82 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 @@ -61,12 +61,7 @@ class SellPaymentInfoCubit extends Cubit { ); return; } - emit( - SellPaymentInfoFailure( - PaymentInfoError.unknown, - message: paymentInfo.error ?? '', - ), - ); + emit(const SellPaymentInfoFailure(PaymentInfoError.unknown)); return; } @@ -77,7 +72,7 @@ class SellPaymentInfoCubit extends Cubit { emit( SellPaymentInfoFailure( PaymentInfoError.kycRequired, - message: e.toString(), + message: e.message, requiredLevel: e.requiredLevel, context: e.context, ), @@ -87,7 +82,7 @@ class SellPaymentInfoCubit extends Cubit { emit( SellPaymentInfoFailure( PaymentInfoError.registrationRequired, - message: e.toString(), + message: e.message, context: e.context, ), ); @@ -117,7 +112,7 @@ class SellPaymentInfoCubit extends Cubit { emit( SellPaymentInfoFailure( PaymentInfoError.unknown, - message: e.toString(), + message: e.message, ), ); } catch (e) { diff --git a/lib/screens/sell/widgets/sell_add_bank_account_sheet.dart b/lib/screens/sell/widgets/sell_add_bank_account_sheet.dart index 9d1afccbf..0d73c8a18 100644 --- a/lib/screens/sell/widgets/sell_add_bank_account_sheet.dart +++ b/lib/screens/sell/widgets/sell_add_bank_account_sheet.dart @@ -30,7 +30,7 @@ class _SellAddBankAccountSheetState extends State { if (state is SellBankAccountsAddFailure) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Error: ${state.message}'), + content: Text(state.message), backgroundColor: RealUnitColors.status.red600, ), ); diff --git a/lib/screens/sell/widgets/sell_button.dart b/lib/screens/sell/widgets/sell_button.dart index f4227d759..81dcdca74 100644 --- a/lib/screens/sell/widgets/sell_button.dart +++ b/lib/screens/sell/widgets/sell_button.dart @@ -44,18 +44,7 @@ class SellButton extends StatelessWidget { } return; } - if (state.error == .priceSourceUnavailable) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(S.of(context).priceProviderUnavailableTitle), - backgroundColor: RealUnitColors.status.red600, - ), - ); - } - return; - } - if (context.mounted) { + if (context.mounted && state.message.isNotEmpty) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(state.message), diff --git a/lib/screens/sell_bitbox/cubit/sell_bitbox_cubit.dart b/lib/screens/sell_bitbox/cubit/sell_bitbox_cubit.dart index a6ed6b1e1..281e96bdc 100644 --- a/lib/screens/sell_bitbox/cubit/sell_bitbox_cubit.dart +++ b/lib/screens/sell_bitbox/cubit/sell_bitbox_cubit.dart @@ -8,6 +8,7 @@ import 'package:realunit_wallet/packages/hardware_wallet/bitbox_credentials.dart import 'package:realunit_wallet/packages/service/app_store.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_blockchain_api_service.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_faucet_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart'; import 'package:realunit_wallet/packages/service/dfx/exceptions/bitbox_exception.dart'; import 'package:realunit_wallet/packages/service/dfx/exceptions/payment/sell_exceptions.dart'; import 'package:realunit_wallet/packages/service/dfx/models/payment/sell/dto/broadcast_transaction_request_dto.dart'; @@ -55,7 +56,7 @@ class SellBitboxCubit extends Cubit { await _requestFaucet(); } } catch (e) { - emit(SellBitboxError(e.toString())); + emit(SellBitboxError(ApiException.userFacingMessage(e))); } } @@ -68,7 +69,7 @@ class SellBitboxCubit extends Cubit { emit(SellBitboxWaitingForEth()); _startEthPolling(); } catch (e) { - emit(SellBitboxError(e.toString())); + emit(SellBitboxError(ApiException.userFacingMessage(e))); } } @@ -95,7 +96,7 @@ class SellBitboxCubit extends Cubit { final transactions = await _sellService.createUnsignedTransactions(_paymentInfo.id); emit(SellBitboxAwaitingSwapConfirm(transactions.swap, transactions.deposit)); } catch (e) { - emit(SellBitboxError(e.toString())); + emit(SellBitboxError(ApiException.userFacingMessage(e))); } } @@ -116,7 +117,7 @@ class SellBitboxCubit extends Cubit { } on BitboxNotConnectedException { emit(SellBitboxBitboxRequired()); } catch (e) { - emit(SellBitboxError(e.toString())); + emit(SellBitboxError(ApiException.userFacingMessage(e))); } } @@ -141,7 +142,7 @@ class SellBitboxCubit extends Cubit { } on BitboxNotConnectedException { emit(SellBitboxBitboxRequired()); } catch (e) { - emit(SellBitboxError(e.toString())); + emit(SellBitboxError(ApiException.userFacingMessage(e))); } } @@ -170,7 +171,7 @@ class SellBitboxCubit extends Cubit { txHash = await _sellService.broadcastTransaction(_paymentInfo.id, signedDeposit); } catch (e) { // The tx may or may not be on-chain; retry re-sends the same signed bytes — never re-sign. - emit(SellBitboxDepositRetry(signedSwap, signedDeposit, e.toString())); + emit(SellBitboxDepositRetry(signedSwap, signedDeposit, ApiException.userFacingMessage(e))); return; } } @@ -181,7 +182,12 @@ class SellBitboxCubit extends Cubit { emit(SellBitboxSuccess()); } catch (e) { emit( - SellBitboxDepositRetry(signedSwap, signedDeposit, e.toString(), broadcastTxHash: txHash), + SellBitboxDepositRetry( + signedSwap, + signedDeposit, + ApiException.userFacingMessage(e), + broadcastTxHash: txHash, + ), ); } } diff --git a/lib/screens/sell_bitbox/widgets/sell_bitbox_deposit_step.dart b/lib/screens/sell_bitbox/widgets/sell_bitbox_deposit_step.dart index 036964424..5064fc7af 100644 --- a/lib/screens/sell_bitbox/widgets/sell_bitbox_deposit_step.dart +++ b/lib/screens/sell_bitbox/widgets/sell_bitbox_deposit_step.dart @@ -94,7 +94,9 @@ class SellBitboxDepositStep extends StatelessWidget { textAlign: .center, ), Text( - confirmOnly + state.errorMessage.isNotEmpty + ? state.errorMessage + : confirmOnly ? S.of(context).sellBitboxConfirmRetryDescription : S.of(context).sellBitboxDepositRetryDescription, style: Theme.of( 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 6923478fa..4ac9ad76c 100644 --- a/lib/screens/send/cubits/send_process/send_process_cubit.dart +++ b/lib/screens/send/cubits/send_process/send_process_cubit.dart @@ -81,8 +81,11 @@ class SendProcessCubit extends Cubit { _preparedInfo = info; } on TransferSignatureUnsupportedException { prepareFailure = const SendProcessFailure(SendProcessFailureReason.signatureUnsupported); - } on TransferGasFundingUnavailableException { - prepareFailure = const SendProcessFailure(SendProcessFailureReason.gasFundingUnavailable); + } on TransferGasFundingUnavailableException catch (e) { + prepareFailure = SendProcessFailure( + SendProcessFailureReason.gasFundingUnavailable, + message: e.detail, + ); } on SigningCancelledException { prepareFailure = const SendProcessFailure(SendProcessFailureReason.signatureCancelled); } on BitboxNotConnectedException { @@ -175,15 +178,15 @@ class SendProcessCubit extends Cubit { } else { nextState = const SendProcessSuccess(''); } - } on TransferConfirmMismatchException catch (e) { - nextState = SendProcessFailure( - SendProcessFailureReason.confirmMismatch, - message: e.toString(), - ); + } on TransferConfirmMismatchException { + nextState = const SendProcessFailure(SendProcessFailureReason.confirmMismatch); } on TransferSignatureUnsupportedException { nextState = const SendProcessFailure(SendProcessFailureReason.signatureUnsupported); - } on TransferGasFundingUnavailableException { - nextState = const SendProcessFailure(SendProcessFailureReason.gasFundingUnavailable); + } on TransferGasFundingUnavailableException catch (e) { + nextState = SendProcessFailure( + SendProcessFailureReason.gasFundingUnavailable, + message: e.detail, + ); } on SigningCancelledException { nextState = const SendProcessFailure(SendProcessFailureReason.signatureCancelled); } on BitboxNotConnectedException { diff --git a/lib/screens/send/cubits/send_process/send_process_state.dart b/lib/screens/send/cubits/send_process/send_process_state.dart index 7bcafda19..6995091fe 100644 --- a/lib/screens/send/cubits/send_process/send_process_state.dart +++ b/lib/screens/send/cubits/send_process/send_process_state.dart @@ -66,7 +66,9 @@ class SendProcessSuccess extends SendProcessState { class SendProcessFailure extends SendProcessState { final SendProcessFailureReason reason; - /// Diagnostic detail for logs — not the user-facing copy. + /// User-facing text from the API error body when this failure came from + /// the API. Local hardware/signature failures leave this null; the view + /// then uses its own copy for those reasons only. final String? message; /// When true, the prepared transfer `id` is retained and the user may call diff --git a/lib/screens/send/send_process_page.dart b/lib/screens/send/send_process_page.dart index 7820036c7..260be1053 100644 --- a/lib/screens/send/send_process_page.dart +++ b/lib/screens/send/send_process_page.dart @@ -93,16 +93,22 @@ class SendProcessView extends StatelessWidget { SendProcessFailure() => S.of(context).sendFailureTitle, }; - String _failureMessage(BuildContext context, SendProcessFailure state) => switch (state.reason) { - SendProcessFailureReason.signatureUnsupported => S.of(context).sendFailureSignatureUnsupported, - SendProcessFailureReason.signatureCancelled => S.of(context).sendFailureSignatureCancelled, - SendProcessFailureReason.gasFundingUnavailable => S.of(context).sendFailureGasUnavailable, - SendProcessFailureReason.invalidRequest => S.of(context).sendFailureInvalidRequest, - SendProcessFailureReason.registrationOrKycRequired => - S.of(context).sendFailureRegistrationOrKycRequired, - SendProcessFailureReason.confirmMismatch => S.of(context).sendFailureConfirmMismatch, - SendProcessFailureReason.generic => S.of(context).sendFailureGeneric, - }; + String _failureMessage(BuildContext context, SendProcessFailure state) { + final apiText = state.message; + if (apiText != null && apiText.isNotEmpty) { + return apiText; + } + return switch (state.reason) { + SendProcessFailureReason.signatureUnsupported => S.of(context).sendFailureSignatureUnsupported, + SendProcessFailureReason.signatureCancelled => S.of(context).sendFailureSignatureCancelled, + SendProcessFailureReason.gasFundingUnavailable => S.of(context).sendFailureGasUnavailable, + SendProcessFailureReason.invalidRequest => S.of(context).sendFailureInvalidRequest, + SendProcessFailureReason.registrationOrKycRequired => + S.of(context).sendFailureRegistrationOrKycRequired, + SendProcessFailureReason.confirmMismatch => S.of(context).sendFailureConfirmMismatch, + SendProcessFailureReason.generic => S.of(context).sendFailureGeneric, + }; + } /// Shows the terminal result sheet. Returns after the sheet is dismissed. /// diff --git a/lib/screens/settings_tax_report/cubit/settings_tax_report_cubit.dart b/lib/screens/settings_tax_report/cubit/settings_tax_report_cubit.dart index fea45954c..821bf720c 100644 --- a/lib/screens/settings_tax_report/cubit/settings_tax_report_cubit.dart +++ b/lib/screens/settings_tax_report/cubit/settings_tax_report_cubit.dart @@ -7,6 +7,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:intl/intl.dart'; import 'package:realunit_wallet/packages/io/documents_directory_port.dart'; import 'package:realunit_wallet/packages/io/path_provider_adapter.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart'; import 'package:realunit_wallet/packages/service/dfx/real_unit_pdf_service.dart'; import 'package:realunit_wallet/styles/currency.dart'; import 'package:realunit_wallet/styles/language.dart'; @@ -45,7 +46,7 @@ class SettingsTaxReportCubit extends Cubit { emit(SettingsTaxReportSuccess(file.path)); } catch (e) { if (isClosed) return; - emit(SettingsTaxReportFailure(e.toString())); + emit(SettingsTaxReportFailure(ApiException.userFacingMessage(e))); } } diff --git a/lib/screens/settings_user_data/subpages/edit_address/cubit/settings_edit_address_cubit.dart b/lib/screens/settings_user_data/subpages/edit_address/cubit/settings_edit_address_cubit.dart index fc6c1a5e5..856d48b82 100644 --- a/lib/screens/settings_user_data/subpages/edit_address/cubit/settings_edit_address_cubit.dart +++ b/lib/screens/settings_user_data/subpages/edit_address/cubit/settings_edit_address_cubit.dart @@ -1,6 +1,7 @@ import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_kyc_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart'; import 'package:realunit_wallet/packages/service/dfx/models/kyc/kyc_level.dart'; part 'settings_edit_address_state.dart'; @@ -31,7 +32,7 @@ class SettingsEditAddressCubit extends Cubit { } emit(SettingsEditAddressReady(url)); } catch (e) { - emit(SettingsEditAddressFailure(e.toString())); + emit(SettingsEditAddressFailure(ApiException.userFacingMessage(e))); } } @@ -65,7 +66,7 @@ class SettingsEditAddressCubit extends Cubit { }); emit(const SettingsEditAddressSuccess()); } catch (e) { - emit(SettingsEditAddressFailure(e.toString())); + emit(SettingsEditAddressFailure(ApiException.userFacingMessage(e))); } } } diff --git a/lib/screens/settings_user_data/subpages/edit_name/cubit/settings_edit_name_cubit.dart b/lib/screens/settings_user_data/subpages/edit_name/cubit/settings_edit_name_cubit.dart index 120761924..a2bfb61fa 100644 --- a/lib/screens/settings_user_data/subpages/edit_name/cubit/settings_edit_name_cubit.dart +++ b/lib/screens/settings_user_data/subpages/edit_name/cubit/settings_edit_name_cubit.dart @@ -1,6 +1,7 @@ import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_kyc_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart'; import 'package:realunit_wallet/packages/service/dfx/models/kyc/kyc_level.dart'; part 'settings_edit_name_state.dart'; @@ -31,7 +32,7 @@ class SettingsEditNameCubit extends Cubit { } emit(SettingsEditNameReady(url)); } catch (e) { - emit(SettingsEditNameFailure(e.toString())); + emit(SettingsEditNameFailure(ApiException.userFacingMessage(e))); } } @@ -57,7 +58,7 @@ class SettingsEditNameCubit extends Cubit { }); emit(const SettingsEditNameSuccess()); } catch (e) { - emit(SettingsEditNameFailure(e.toString())); + emit(SettingsEditNameFailure(ApiException.userFacingMessage(e))); } } } diff --git a/lib/screens/settings_user_data/subpages/edit_phone_number/cubit/settings_edit_phone_number_cubit.dart b/lib/screens/settings_user_data/subpages/edit_phone_number/cubit/settings_edit_phone_number_cubit.dart index 90e9d9d72..1e12456b2 100644 --- a/lib/screens/settings_user_data/subpages/edit_phone_number/cubit/settings_edit_phone_number_cubit.dart +++ b/lib/screens/settings_user_data/subpages/edit_phone_number/cubit/settings_edit_phone_number_cubit.dart @@ -1,6 +1,7 @@ import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_kyc_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart'; part 'settings_edit_phone_number_state.dart'; @@ -17,7 +18,7 @@ class SettingsEditPhoneNumberCubit extends Cubit { await _kycService.updateUser({'phone': phone}); emit(const SettingsEditPhoneNumberSuccess()); } catch (e) { - emit(SettingsEditPhoneNumberFailure(e.toString())); + emit(SettingsEditPhoneNumberFailure(ApiException.userFacingMessage(e))); } } } diff --git a/lib/screens/settings_user_data/subpages/others/settings_edit_failure_page.dart b/lib/screens/settings_user_data/subpages/others/settings_edit_failure_page.dart index 603eb0f53..0c2ab6225 100644 --- a/lib/screens/settings_user_data/subpages/others/settings_edit_failure_page.dart +++ b/lib/screens/settings_user_data/subpages/others/settings_edit_failure_page.dart @@ -34,7 +34,7 @@ class SettingsEditFailurePage extends StatelessWidget { style: Theme.of(context).textTheme.headlineMedium, ), Text( - S.of(context).kycFailureDescription(message), + message, textAlign: .center, style: Theme.of( context, diff --git a/lib/screens/support/cubits/support_chat/support_chat_cubit.dart b/lib/screens/support/cubits/support_chat/support_chat_cubit.dart index 6342aa876..474f5488b 100644 --- a/lib/screens/support/cubits/support_chat/support_chat_cubit.dart +++ b/lib/screens/support/cubits/support_chat/support_chat_cubit.dart @@ -3,6 +3,7 @@ import 'dart:developer' as developer; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:image_picker/image_picker.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_support_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart'; import 'package:realunit_wallet/packages/service/dfx/models/support/support_issue.dart'; import 'package:realunit_wallet/packages/utils/xfile_extension.dart'; import 'package:realunit_wallet/screens/support/cubits/support_chat/support_chat_state.dart'; @@ -29,7 +30,7 @@ class SupportChatCubit extends Cubit { } catch (e) { developer.log('Could not load ticket: $e', name: '$SupportChatCubit'); if (isClosed) return; - emit(SupportChatError(e.toString())); + emit(SupportChatError(ApiException.userFacingMessage(e))); } } diff --git a/lib/screens/support/cubits/support_create_ticket/support_create_ticket_cubit.dart b/lib/screens/support/cubits/support_create_ticket/support_create_ticket_cubit.dart index d9f0c6506..1c6f4adc3 100644 --- a/lib/screens/support/cubits/support_create_ticket/support_create_ticket_cubit.dart +++ b/lib/screens/support/cubits/support_create_ticket/support_create_ticket_cubit.dart @@ -3,6 +3,7 @@ import 'dart:developer' as developer; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:image_picker/image_picker.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_support_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart'; import 'package:realunit_wallet/packages/service/dfx/models/support/support_issue_reason.dart'; import 'package:realunit_wallet/packages/service/dfx/models/support/support_issue_type.dart'; import 'package:realunit_wallet/packages/utils/xfile_extension.dart'; @@ -70,7 +71,7 @@ class SupportCreateTicketCubit extends Cubit { } catch (e) { developer.log('Could not create ticket: $e', name: '$SupportCreateTicketCubit'); if (isClosed) return; - emit(state.copyWith(isSubmitting: false, error: e.toString())); + emit(state.copyWith(isSubmitting: false, error: ApiException.userFacingMessage(e))); } } diff --git a/lib/screens/support/cubits/support_tickets/support_tickets_cubit.dart b/lib/screens/support/cubits/support_tickets/support_tickets_cubit.dart index b71568244..9217851eb 100644 --- a/lib/screens/support/cubits/support_tickets/support_tickets_cubit.dart +++ b/lib/screens/support/cubits/support_tickets/support_tickets_cubit.dart @@ -2,6 +2,7 @@ import 'dart:developer' as developer; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_support_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart'; import 'package:realunit_wallet/packages/service/dfx/models/support/support_issue.dart'; import 'package:realunit_wallet/screens/support/cubits/support_tickets/support_tickets_state.dart'; @@ -24,7 +25,7 @@ class SupportTicketsCubit extends Cubit { emit(SupportTicketsLoaded(tickets.map(SupportIssue.fromDto).toList())); } catch (e) { developer.log('Could not load tickets: $e', name: '$SupportTicketsCubit'); - emit(SupportTicketsError(e.toString())); + emit(SupportTicketsError(ApiException.userFacingMessage(e))); } } } diff --git a/lib/screens/transaction_history/cubits/filter/transaction_history_filter_cubit.dart b/lib/screens/transaction_history/cubits/filter/transaction_history_filter_cubit.dart index fb3e5762c..c124f003e 100644 --- a/lib/screens/transaction_history/cubits/filter/transaction_history_filter_cubit.dart +++ b/lib/screens/transaction_history/cubits/filter/transaction_history_filter_cubit.dart @@ -58,14 +58,23 @@ class TransactionHistoryFilterCubit extends Cubit DateTime? startDate, DateTime? endDate, }) { + // The picker hands over the selected day at local midnight, so an inclusive end bound + // has to cover that entire day — compare against the next day's midnight exclusively. + final endBound = endDate == null ? null : _startOfNextLocalDay(endDate); + return transactions.where((transaction) { final transactionDate = transaction.timestamp; final afterStart = startDate == null || !transactionDate.isBefore(startDate); - final beforeEnd = endDate == null || !transactionDate.isAfter(endDate); + final beforeEnd = endBound == null || transactionDate.isBefore(endBound); return afterStart && beforeEnd; }).toList(); } + DateTime _startOfNextLocalDay(DateTime date) { + final local = date.toLocal(); + return DateTime(local.year, local.month, local.day + 1); + } + @override Future close() { _subscription?.cancel(); diff --git a/lib/screens/transaction_history/cubits/multi_receipt/transaction_history_multi_receipt_cubit.dart b/lib/screens/transaction_history/cubits/multi_receipt/transaction_history_multi_receipt_cubit.dart index e9da80dd7..374957e26 100644 --- a/lib/screens/transaction_history/cubits/multi_receipt/transaction_history_multi_receipt_cubit.dart +++ b/lib/screens/transaction_history/cubits/multi_receipt/transaction_history_multi_receipt_cubit.dart @@ -5,6 +5,7 @@ import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:realunit_wallet/packages/io/documents_directory_port.dart'; import 'package:realunit_wallet/packages/io/path_provider_adapter.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart'; import 'package:realunit_wallet/packages/service/dfx/real_unit_pdf_service.dart'; import 'package:realunit_wallet/styles/currency.dart'; import 'package:realunit_wallet/styles/language.dart'; @@ -42,7 +43,7 @@ class TransactionHistoryMultiReceiptCubit extends Cubit { - final prefixes = ['+41', '+49']; + // Used only to decompose a seeded value. Input is free-form and not limited to this list. + // `+41` stays first: it is the fallback default (`prefix ??= prefixes.first`). + final prefixes = ['+41', '+49', '+43', '+423']; String? prefix; String? number; @@ -21,7 +23,10 @@ class _PhoneNumberFieldState extends State { void initState() { super.initState(); final value = widget.controller.value; - for (final p in prefixes) { + // Longest first. Keep this whenever the list mixes lengths: a shorter listed + // code that is a true prefix of a longer one would steal the match. + final knownPrefixes = List.of(prefixes)..sort((a, b) => b.length.compareTo(a.length)); + for (final p in knownPrefixes) { if (value != null && value.startsWith(p)) { prefix = p; number = value.substring(p.length); @@ -29,10 +34,10 @@ class _PhoneNumberFieldState extends State { } } - // A seeded value this field cannot decompose (empty, or a dial code it does not offer) must not - // leave `prefix` null: the dropdown carries no validator, so `Form.validate()` would pass while - // `updatePhoneNumber()` silently refused to write, and the stale value would be submitted - // instead of what the user typed. Fall back to the first prefix; the number field starts empty, + // A seeded value this field cannot decompose (empty, or a dial code it does not + // recognize) must not leave `prefix` null: `updatePhoneNumber()` silently refuses + // to write when prefix is null, and the stale value would be submitted instead of + // what the user typed. Fall back to the first prefix; the number field starts empty, // so the validator still blocks submit until it is re-entered. prefix ??= prefixes.first; } @@ -66,16 +71,29 @@ class _PhoneNumberFieldState extends State { children: [ Expanded( flex: 3, - child: DropdownField( - initialValue: prefix, - items: prefixes - .map((d) => DropdownMenuItem(value: d, child: Text(d.toString()))) - .toList(), + child: LabeledTextField( + initialValue: prefix?.substring(1), + prefixText: '+', + keyboardType: .phone, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(3), + ], onChanged: (v) { - if (v != null) { - prefix = v; - updatePhoneNumber(); + prefix = '+$v'; + updatePhoneNumber(); + }, + hideErrorText: false, + validator: (value) { + if (value == null || value.isEmpty) { + return S.of(context).registerPhoneNumberPrefixInvalid; } + if (!RegExp(r'^[0-9]{1,3}$').hasMatch(value)) { + return S.of(context).registerPhoneNumberPrefixFormat; + } + // Existence of the dial code is validated by the API (libphonenumber); the + // client must not gate on it — see CONTRIBUTING "the API decides". + return null; }, ), ), diff --git a/scripts/assemble-handbook-screenshots.sh b/scripts/assemble-handbook-screenshots.sh index 33e716baa..19f173b7d 100755 --- a/scripts/assemble-handbook-screenshots.sh +++ b/scripts/assemble-handbook-screenshots.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # -# Assemble the 279 handbook screenshots from the visual-regression Golden +# Assemble the 278 handbook screenshots from the visual-regression Golden # baselines. The flat `NN-name.png` output layout matches what # docs/handbook/de/index.html links to (`` # — the relative path resolves to `docs/handbook/screenshots/NN-name.png`). @@ -160,7 +160,7 @@ MAPPING=( "110-buy-confirm-loading=screens/buy/goldens/macos/buy_confirm_loading.png" "111-buy-confirm-failed-aktionariat=screens/buy/goldens/macos/buy_confirm_failed_aktionariat.png" "112-buy-confirm-failed-amount-too-low=screens/buy/goldens/macos/buy_confirm_failed_amount_too_low.png" - "113-buy-confirm-failed-unknown=screens/buy/goldens/macos/buy_confirm_failed_unknown.png" + "113-buy-confirm-failed-primary-email-required=screens/buy/goldens/macos/buy_confirm_failed_primary_email_required.png" "114-buy-payment-details-qr-details-tab=screens/buy/goldens/macos/buy_payment_details_qr_details_tab.png" "115-buy-payment-details-qr-code-tab=screens/buy/goldens/macos/buy_payment_details_qr_code_tab.png" "116-buy-payment-details-qr-code-tab-svg=screens/buy/goldens/macos/buy_payment_details_qr_code_tab_svg.png" @@ -211,7 +211,6 @@ MAPPING=( "161-kyc-registration-page-default=screens/kyc/goldens/macos/kyc_registration_page_default.png" "162-kyc-registration-page-prefilled=screens/kyc/goldens/macos/kyc_registration_page_prefilled.png" "163-kyc-registration-personal-step-account-type-open=screens/kyc/goldens/macos/kyc_registration_personal_step_account_type_open.png" - "164-kyc-registration-personal-step-phone-prefix-open=screens/kyc/goldens/macos/kyc_registration_personal_step_phone_prefix_open.png" "165-kyc-registration-personal-step-validation-error=screens/kyc/goldens/macos/kyc_registration_personal_step_validation_error.png" "166-kyc-registration-page-address-step=screens/kyc/goldens/macos/kyc_registration_page_address_step.png" "167-kyc-registration-address-step-validation-error=screens/kyc/goldens/macos/kyc_registration_address_step_validation_error.png" diff --git a/test/goldens/screens/buy/buy_golden_test.dart b/test/goldens/screens/buy/buy_golden_test.dart index f56940b75..25791c972 100644 --- a/test/goldens/screens/buy/buy_golden_test.dart +++ b/test/goldens/screens/buy/buy_golden_test.dart @@ -254,7 +254,10 @@ void main() { constraints: const BoxConstraints.tightFor(width: 390, height: 844), builder: () { when(() => paymentInfoCubit.state).thenReturn( - const BuyPaymentInfoFailure(PaymentInfoError.unknown), + const BuyPaymentInfoFailure( + PaymentInfoError.unknown, + message: 'The purchase could not be quoted. Please try again later.', + ), ); when(() => converterCubit.state).thenReturn( const BuyConverterState( @@ -268,12 +271,15 @@ void main() { ); goldenTest( - 'price source (Aktionariat) unavailable failure', + 'price source unavailable failure shows API message', fileName: 'buy_price_source_unavailable', constraints: const BoxConstraints.tightFor(width: 390, height: 844), builder: () { when(() => paymentInfoCubit.state).thenReturn( - const BuyPaymentInfoFailure(PaymentInfoError.priceSourceUnavailable), + const BuyPaymentInfoFailure( + PaymentInfoError.priceSourceUnavailable, + message: 'RealUnit price source (Aktionariat) is currently unavailable', + ), ); when(() => converterCubit.state).thenReturn( const BuyConverterState( diff --git a/test/goldens/screens/buy/buy_states_golden_test.dart b/test/goldens/screens/buy/buy_states_golden_test.dart index dcb51c035..20c199d76 100644 --- a/test/goldens/screens/buy/buy_states_golden_test.dart +++ b/test/goldens/screens/buy/buy_states_golden_test.dart @@ -126,20 +126,24 @@ void main() { }, ); - // Emitting `BuyConfirmFailure` drives the BlocConsumer listener - // (`buy_confirm_button.dart:64-73`) to show the failure SnackBar. Each - // error code's user-facing text is captured (aktionariat and - // primaryEmailRequired intentionally share the same copy). - // The `.state` getter follows the emission (BuyConfirmFailure) — the builder - // still renders the idle button, which is the real post-failure UI. - for (final (error, name) in const [ - (BuyConfirmError.aktionariat, 'buy_confirm_failed_aktionariat'), - (BuyConfirmError.amountTooLow, 'buy_confirm_failed_amount_too_low'), - (BuyConfirmError.primaryEmailRequired, 'buy_confirm_failed_primary_email_required'), - (BuyConfirmError.unknown, 'buy_confirm_failed_unknown'), + // Emitting `BuyConfirmFailure` drives the BlocConsumer listener to show + // the API `message` in a SnackBar. The app does not substitute local copy. + for (final (message, name) in const [ + ( + 'The purchase could not be confirmed. Please try again later.', + 'buy_confirm_failed_aktionariat', + ), + ( + 'Purchases by bank transfer require a minimum of 100 nominal in base currency', + 'buy_confirm_failed_amount_too_low', + ), + ( + 'User must have a primary email', + 'buy_confirm_failed_primary_email_required', + ), ]) { goldenTest( - 'confirm failed SnackBar — ${error.name}', + 'confirm failed SnackBar — $name', fileName: name, constraints: phoneConstraints, pumpBeforeTest: (tester) async { @@ -149,7 +153,7 @@ void main() { builder: () { whenListen( confirmCubit, - Stream.value(BuyConfirmFailure(error)), + Stream.value(BuyConfirmFailure(message)), initialState: const BuyConfirmInitial(), ); return buildCta(); diff --git a/test/goldens/screens/buy/goldens/macos/buy_confirm_failed_aktionariat.png b/test/goldens/screens/buy/goldens/macos/buy_confirm_failed_aktionariat.png index 5684fa073..b6c14f65d 100644 Binary files a/test/goldens/screens/buy/goldens/macos/buy_confirm_failed_aktionariat.png and b/test/goldens/screens/buy/goldens/macos/buy_confirm_failed_aktionariat.png differ diff --git a/test/goldens/screens/buy/goldens/macos/buy_confirm_failed_amount_too_low.png b/test/goldens/screens/buy/goldens/macos/buy_confirm_failed_amount_too_low.png index 3cb61109b..a2dc02f69 100644 Binary files a/test/goldens/screens/buy/goldens/macos/buy_confirm_failed_amount_too_low.png and b/test/goldens/screens/buy/goldens/macos/buy_confirm_failed_amount_too_low.png differ diff --git a/test/goldens/screens/buy/goldens/macos/buy_confirm_failed_primary_email_required.png b/test/goldens/screens/buy/goldens/macos/buy_confirm_failed_primary_email_required.png index 5684fa073..024934265 100644 Binary files a/test/goldens/screens/buy/goldens/macos/buy_confirm_failed_primary_email_required.png and b/test/goldens/screens/buy/goldens/macos/buy_confirm_failed_primary_email_required.png differ diff --git a/test/goldens/screens/buy/goldens/macos/buy_confirm_failed_unknown.png b/test/goldens/screens/buy/goldens/macos/buy_confirm_failed_unknown.png deleted file mode 100644 index 85a2cc606..000000000 Binary files a/test/goldens/screens/buy/goldens/macos/buy_confirm_failed_unknown.png and /dev/null differ diff --git a/test/goldens/screens/buy/goldens/macos/buy_payment_details_default.png b/test/goldens/screens/buy/goldens/macos/buy_payment_details_default.png index 67aa29342..31fb4681e 100644 Binary files a/test/goldens/screens/buy/goldens/macos/buy_payment_details_default.png and b/test/goldens/screens/buy/goldens/macos/buy_payment_details_default.png differ diff --git a/test/goldens/screens/buy/goldens/macos/buy_payment_details_no_purpose.png b/test/goldens/screens/buy/goldens/macos/buy_payment_details_no_purpose.png index d85d0e8b1..237379e4b 100644 Binary files a/test/goldens/screens/buy/goldens/macos/buy_payment_details_no_purpose.png and b/test/goldens/screens/buy/goldens/macos/buy_payment_details_no_purpose.png differ diff --git a/test/goldens/screens/buy/goldens/macos/buy_payment_details_qr_details_tab.png b/test/goldens/screens/buy/goldens/macos/buy_payment_details_qr_details_tab.png index 0bcacfcd4..4cc5673a5 100644 Binary files a/test/goldens/screens/buy/goldens/macos/buy_payment_details_qr_details_tab.png and b/test/goldens/screens/buy/goldens/macos/buy_payment_details_qr_details_tab.png differ diff --git a/test/goldens/screens/buy/goldens/macos/buy_price_source_unavailable.png b/test/goldens/screens/buy/goldens/macos/buy_price_source_unavailable.png index 368a0a963..2a37b9f07 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_unknown_error.png b/test/goldens/screens/buy/goldens/macos/buy_unknown_error.png index 7098da5fb..a8250c286 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/kyc/goldens/macos/kyc_2fa_page_request_code_failure.png b/test/goldens/screens/kyc/goldens/macos/kyc_2fa_page_request_code_failure.png index ba2181242..c6cda3000 100644 Binary files a/test/goldens/screens/kyc/goldens/macos/kyc_2fa_page_request_code_failure.png and b/test/goldens/screens/kyc/goldens/macos/kyc_2fa_page_request_code_failure.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_2fa_page_verify_failure.png b/test/goldens/screens/kyc/goldens/macos/kyc_2fa_page_verify_failure.png index f146b4720..1c9ca1dc3 100644 Binary files a/test/goldens/screens/kyc/goldens/macos/kyc_2fa_page_verify_failure.png and b/test/goldens/screens/kyc/goldens/macos/kyc_2fa_page_verify_failure.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_email_page_error_snackbar_does_not_match.png b/test/goldens/screens/kyc/goldens/macos/kyc_email_page_error_snackbar_does_not_match.png index 2921c30f7..e530e3981 100644 Binary files a/test/goldens/screens/kyc/goldens/macos/kyc_email_page_error_snackbar_does_not_match.png and b/test/goldens/screens/kyc/goldens/macos/kyc_email_page_error_snackbar_does_not_match.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_failure_page_default.png b/test/goldens/screens/kyc/goldens/macos/kyc_failure_page_default.png index 1fd3de010..e0736d901 100644 Binary files a/test/goldens/screens/kyc/goldens/macos/kyc_failure_page_default.png and b/test/goldens/screens/kyc/goldens/macos/kyc_failure_page_default.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_ident_page_error.png b/test/goldens/screens/kyc/goldens/macos/kyc_ident_page_error.png index 8bcfcfb1c..0701b57c4 100644 Binary files a/test/goldens/screens/kyc/goldens/macos/kyc_ident_page_error.png and b/test/goldens/screens/kyc/goldens/macos/kyc_ident_page_error.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_link_wallet_page_failure.png b/test/goldens/screens/kyc/goldens/macos/kyc_link_wallet_page_failure.png index 4cb1a075a..e0ad6ca55 100644 Binary files a/test/goldens/screens/kyc/goldens/macos/kyc_link_wallet_page_failure.png and b/test/goldens/screens/kyc/goldens/macos/kyc_link_wallet_page_failure.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_nationality_page_submit_failure.png b/test/goldens/screens/kyc/goldens/macos/kyc_nationality_page_submit_failure.png index 9a1da3cb4..88f67b46f 100644 Binary files a/test/goldens/screens/kyc/goldens/macos/kyc_nationality_page_submit_failure.png and b/test/goldens/screens/kyc/goldens/macos/kyc_nationality_page_submit_failure.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_personal_data_page_default.png b/test/goldens/screens/kyc/goldens/macos/kyc_personal_data_page_default.png index 8c72f9ff2..5ca09a388 100644 Binary files a/test/goldens/screens/kyc/goldens/macos/kyc_personal_data_page_default.png and b/test/goldens/screens/kyc/goldens/macos/kyc_personal_data_page_default.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_registration_page_default.png b/test/goldens/screens/kyc/goldens/macos/kyc_registration_page_default.png index b765ed6c1..2c0628823 100644 Binary files a/test/goldens/screens/kyc/goldens/macos/kyc_registration_page_default.png and b/test/goldens/screens/kyc/goldens/macos/kyc_registration_page_default.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_registration_page_forwarding_failed_snackbar.png b/test/goldens/screens/kyc/goldens/macos/kyc_registration_page_forwarding_failed_snackbar.png index 0efa30889..8aca29f55 100644 Binary files a/test/goldens/screens/kyc/goldens/macos/kyc_registration_page_forwarding_failed_snackbar.png and b/test/goldens/screens/kyc/goldens/macos/kyc_registration_page_forwarding_failed_snackbar.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_registration_page_prefilled.png b/test/goldens/screens/kyc/goldens/macos/kyc_registration_page_prefilled.png index 3c2272d60..eb52acc20 100644 Binary files a/test/goldens/screens/kyc/goldens/macos/kyc_registration_page_prefilled.png and b/test/goldens/screens/kyc/goldens/macos/kyc_registration_page_prefilled.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_registration_page_submit_failure_snackbar.png b/test/goldens/screens/kyc/goldens/macos/kyc_registration_page_submit_failure_snackbar.png index 2ffe0203c..18dc06a34 100644 Binary files a/test/goldens/screens/kyc/goldens/macos/kyc_registration_page_submit_failure_snackbar.png and b/test/goldens/screens/kyc/goldens/macos/kyc_registration_page_submit_failure_snackbar.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_registration_page_submit_rejected_snackbar.png b/test/goldens/screens/kyc/goldens/macos/kyc_registration_page_submit_rejected_snackbar.png index 7d98dff00..e928230b7 100644 Binary files a/test/goldens/screens/kyc/goldens/macos/kyc_registration_page_submit_rejected_snackbar.png and b/test/goldens/screens/kyc/goldens/macos/kyc_registration_page_submit_rejected_snackbar.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_registration_personal_step_account_type_open.png b/test/goldens/screens/kyc/goldens/macos/kyc_registration_personal_step_account_type_open.png index 4cf953c4a..05e529c69 100644 Binary files a/test/goldens/screens/kyc/goldens/macos/kyc_registration_personal_step_account_type_open.png and b/test/goldens/screens/kyc/goldens/macos/kyc_registration_personal_step_account_type_open.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_registration_personal_step_default.png b/test/goldens/screens/kyc/goldens/macos/kyc_registration_personal_step_default.png index 49683a12c..dbea943d6 100644 Binary files a/test/goldens/screens/kyc/goldens/macos/kyc_registration_personal_step_default.png and b/test/goldens/screens/kyc/goldens/macos/kyc_registration_personal_step_default.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_registration_personal_step_phone_prefix_open.png b/test/goldens/screens/kyc/goldens/macos/kyc_registration_personal_step_phone_prefix_open.png deleted file mode 100644 index aeefe0118..000000000 Binary files a/test/goldens/screens/kyc/goldens/macos/kyc_registration_personal_step_phone_prefix_open.png and /dev/null differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_registration_personal_step_validation_error.png b/test/goldens/screens/kyc/goldens/macos/kyc_registration_personal_step_validation_error.png index 7329b397a..9ddf97dc1 100644 Binary files a/test/goldens/screens/kyc/goldens/macos/kyc_registration_personal_step_validation_error.png and b/test/goldens/screens/kyc/goldens/macos/kyc_registration_personal_step_validation_error.png differ diff --git a/test/goldens/screens/kyc/kyc_email_states_golden_test.dart b/test/goldens/screens/kyc/kyc_email_states_golden_test.dart index a71caf9f4..6f0be9960 100644 --- a/test/goldens/screens/kyc/kyc_email_states_golden_test.dart +++ b/test/goldens/screens/kyc/kyc_email_states_golden_test.dart @@ -56,9 +56,7 @@ void main() { } group('$KycEmailView', () { - // error == emailDoesNotMatch → the listener shows the localized - // `registerEmailDoesNotMatch` copy (kyc_email_page.dart:61-63), not - // `state.message`. + // Snackbar renders the API `message` as-is. goldenTest( 'emailDoesNotMatch failure — red error SnackBar', fileName: 'kyc_email_page_error_snackbar_does_not_match', @@ -70,7 +68,7 @@ void main() { Stream.value( const KycEmailStepFailure( KycEmailStepError.emailDoesNotMatch, - 'unused — the listener renders registerEmailDoesNotMatch', + 'Provided email does not match verified email', ), ), initialState: const KycEmailStepInitial(), diff --git a/test/goldens/screens/kyc/kyc_nationality_states_golden_test.dart b/test/goldens/screens/kyc/kyc_nationality_states_golden_test.dart index 118e675a0..bce431e8c 100644 --- a/test/goldens/screens/kyc/kyc_nationality_states_golden_test.dart +++ b/test/goldens/screens/kyc/kyc_nationality_states_golden_test.dart @@ -94,8 +94,7 @@ void main() { }, ); - // KycNationalityFailure → red `setNationalityFailed` SnackBar (page:51-58) - // over the loaded, idle body. + // KycNationalityFailure → red SnackBar with the API message as-is. goldenTest( 'submit failure — red snackbar', fileName: 'kyc_nationality_page_submit_failure', diff --git a/test/goldens/screens/kyc/kyc_registration_personal_step_states_golden_test.dart b/test/goldens/screens/kyc/kyc_registration_personal_step_states_golden_test.dart index 69c380b6d..ae411ca5a 100644 --- a/test/goldens/screens/kyc/kyc_registration_personal_step_states_golden_test.dart +++ b/test/goldens/screens/kyc/kyc_registration_personal_step_states_golden_test.dart @@ -93,24 +93,5 @@ void main() { }, builder: buildSubject, ); - - goldenTest( - 'phone-prefix dropdown open — the +41 / +49 overlay menu', - fileName: 'kyc_registration_personal_step_phone_prefix_open', - constraints: phoneConstraints, - pumpBeforeTest: (tester) async { - await tester.pumpAndSettle(); - // The prefix dropdown renders its selected value '+41'; the birthday - // String dropdowns show day/month/year hints instead, so this is unique. - final field = find.widgetWithText( - DropdownButtonFormField, - '+41', - ); - await tester.ensureVisible(field); - await tester.tap(field); - await tester.pumpAndSettle(); - }, - builder: buildSubject, - ); }); } diff --git a/test/goldens/screens/sell_bitbox/goldens/macos/sell_bitbox_confirm_retry.png b/test/goldens/screens/sell_bitbox/goldens/macos/sell_bitbox_confirm_retry.png index 5215ebc07..4cc0311e0 100644 Binary files a/test/goldens/screens/sell_bitbox/goldens/macos/sell_bitbox_confirm_retry.png and b/test/goldens/screens/sell_bitbox/goldens/macos/sell_bitbox_confirm_retry.png differ diff --git a/test/goldens/screens/sell_bitbox/goldens/macos/sell_bitbox_deposit_retry.png b/test/goldens/screens/sell_bitbox/goldens/macos/sell_bitbox_deposit_retry.png index fac885e91..d1af016da 100644 Binary files a/test/goldens/screens/sell_bitbox/goldens/macos/sell_bitbox_deposit_retry.png and b/test/goldens/screens/sell_bitbox/goldens/macos/sell_bitbox_deposit_retry.png differ diff --git a/test/goldens/screens/settings_user_data/goldens/macos/settings_edit_failure_page_default.png b/test/goldens/screens/settings_user_data/goldens/macos/settings_edit_failure_page_default.png index 7e91fc417..18a83f327 100644 Binary files a/test/goldens/screens/settings_user_data/goldens/macos/settings_edit_failure_page_default.png and b/test/goldens/screens/settings_user_data/goldens/macos/settings_edit_failure_page_default.png differ diff --git a/test/goldens/screens/settings_user_data/goldens/macos/settings_edit_phone_number_page_default.png b/test/goldens/screens/settings_user_data/goldens/macos/settings_edit_phone_number_page_default.png index 5ceecb549..36b0bb97d 100644 Binary files a/test/goldens/screens/settings_user_data/goldens/macos/settings_edit_phone_number_page_default.png and b/test/goldens/screens/settings_user_data/goldens/macos/settings_edit_phone_number_page_default.png differ diff --git a/test/goldens/screens/support/goldens/macos/support_create_ticket_page_filled.png b/test/goldens/screens/support/goldens/macos/support_create_ticket_page_filled.png index 945e988c3..6b311c66b 100644 Binary files a/test/goldens/screens/support/goldens/macos/support_create_ticket_page_filled.png and b/test/goldens/screens/support/goldens/macos/support_create_ticket_page_filled.png differ diff --git a/test/goldens/screens/support/support_create_ticket_golden_test.dart b/test/goldens/screens/support/support_create_ticket_golden_test.dart index 0a1f7f558..5f2b88c28 100644 --- a/test/goldens/screens/support/support_create_ticket_golden_test.dart +++ b/test/goldens/screens/support/support_create_ticket_golden_test.dart @@ -26,6 +26,10 @@ class _MockSupportCreateTicketCubit extends MockCubit /// Stable name shown in the filled-attachment golden (no random/timestamp). const _attachedFileName = 'beleg.png'; +/// Message for the filled golden — state and enterText must share one value so +/// the snapshot does not show different text than the state this golden claims. +const _filledMessage = 'Ich habe eine Frage zu meinem Konto.'; + void main() { late _MockSupportCreateTicketCubit cubit; late Directory tempDir; @@ -109,19 +113,44 @@ void main() { // `selectType` sets `selectedReason` to `other` alongside `selectedType`, // and a non-empty `message` flips `canSubmit` to true, so the Send button - // renders enabled. The message TextField renders empty because the field is - // not bound to state (it drives the cubit via `onChanged` only); the - // enabled button is what proves the filled state. + // renders enabled. The message TextField is uncontrolled (onChanged only), + // so it does not render `state.message` — pumpBeforeTest types the same + // text into the field. State still drives canSubmit / the enabled button. goldenTest( 'filled form — type tag selected, send button enabled', fileName: 'support_create_ticket_page_filled', constraints: phoneConstraints, + pumpBeforeTest: (tester) async { + await tester.enterText(find.byType(TextField), _filledMessage); + FocusManager.instance.primaryFocus?.unfocus(); + // InputDecorator animates focusedBorder → enabledBorder + // (_kTransitionDuration = 167 ms). A single pump() advances one frame + // with no elapsed time, so the border freezes blue. unfocus keeps + // cursor and focus ring out of the capture; pumpAndSettle drains the + // border animation before snapshot. + await tester.pumpAndSettle(); + expect( + find.text(_filledMessage), + findsOneWidget, + reason: 'Message TextField should show the typed filled-message text', + ); + // Field focus only — not the border animation. primaryFocus stays non-null + // (FocusScope), so check the EditableText node. + expect( + tester + .widget(find.byType(EditableText)) + .focusNode + .hasFocus, + isFalse, + reason: 'Message field must not hold focus (cursor / focusedBorder)', + ); + }, builder: () { when(() => cubit.state).thenReturn( const SupportCreateTicketState( selectedType: SupportIssueType.genericIssue, selectedReason: SupportIssueReason.other, - message: 'Ich habe eine Frage zu meinem Konto.', + message: _filledMessage, ), ); return wrapForGolden(buildSubject()); diff --git a/test/goldens/widgets/form/goldens/macos/phone_number_field_default.png b/test/goldens/widgets/form/goldens/macos/phone_number_field_default.png index 400c89f80..afa9d27b4 100644 Binary files a/test/goldens/widgets/form/goldens/macos/phone_number_field_default.png and b/test/goldens/widgets/form/goldens/macos/phone_number_field_default.png differ diff --git a/test/packages/service/dfx/exceptions/api_exception_test.dart b/test/packages/service/dfx/exceptions/api_exception_test.dart index 85d7d7da7..586d5a981 100644 --- a/test/packages/service/dfx/exceptions/api_exception_test.dart +++ b/test/packages/service/dfx/exceptions/api_exception_test.dart @@ -109,5 +109,53 @@ void main() { expect(reg.context, 'RealunitSell'); }); }); + + group('userFacingMessage', () { + test('returns ApiException.message 1:1', () { + const error = ApiException( + statusCode: 503, + code: 'AKTIONARIAT_UNAVAILABLE', + message: 'Price source is temporarily unavailable', + ); + + expect( + ApiException.userFacingMessage(error), + 'Price source is temporarily unavailable', + ); + expect( + ApiException.userFacingMessage(error), + isNot(contains('RealUnitApiException')), + ); + }); + + test('returns Object.toString for non-API errors', () { + expect( + ApiException.userFacingMessage(Exception('socket closed')), + 'Exception: socket closed', + ); + }); + }); + + group('userFacingMessageFromJson', () { + test('returns null when the API sent no message', () { + expect(ApiException.userFacingMessageFromJson(null), isNull); + expect(ApiException.userFacingMessageFromJson(''), isNull); + expect(ApiException.userFacingMessageFromJson([]), isNull); + }); + + test('joins a list message the same way fromJson does', () { + expect( + ApiException.userFacingMessageFromJson(['error1', 'error2']), + 'error1, error2', + ); + }); + + test('returns a string message 1:1', () { + expect( + ApiException.userFacingMessageFromJson('Price source is temporarily unavailable'), + 'Price source is temporarily unavailable', + ); + }); + }); }); } 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 475bffe31..13627707b 100644 --- a/test/packages/service/dfx/real_unit_transfer_service_test.dart +++ b/test/packages/service/dfx/real_unit_transfer_service_test.dart @@ -167,6 +167,25 @@ void main() { expect(info.eip7702.recipient, '0xRecipient'); }); + test('503 without message → gas-funding exception with no user-facing text', () async { + final client = MockClient( + (_) async => http.Response(jsonEncode({'statusCode': 503, 'code': 'X'}), 503), + ); + + expect( + () => build(client).prepareTransfer( + const RealUnitTransferDto(toAddress: '0xRecipient', amount: 5), + ), + throwsA( + isA().having( + (e) => e.detail, + 'detail', + isNull, + ), + ), + ); + }); + test('503 → TransferGasFundingUnavailableException', () async { final client = MockClient( (_) async => http.Response( @@ -255,6 +274,23 @@ void main() { ); }); + test('503 on confirm without message → gas-funding exception with no user-facing text', () async { + final client = MockClient( + (_) async => http.Response(jsonEncode({'statusCode': 503, 'code': 'X'}), 503), + ); + + expect( + () => _confirm(build(client), _info()), + throwsA( + isA().having( + (e) => e.detail, + 'detail', + isNull, + ), + ), + ); + }); + test('503 on confirm → TransferGasFundingUnavailableException', () async { final client = MockClient( (_) async => http.Response(jsonEncode({'message': 'unavailable'}), 503), diff --git a/test/screens/buy/buy_page_test.dart b/test/screens/buy/buy_page_test.dart index 44da89ea2..cbfe19d12 100644 --- a/test/screens/buy/buy_page_test.dart +++ b/test/screens/buy/buy_page_test.dart @@ -230,7 +230,10 @@ void main() { testWidgets('retries payment info when unknown error is shown', (tester) async { when(() => buyPaymentInfoCubit.state).thenReturn( - const BuyPaymentInfoFailure(PaymentInfoError.unknown), + const BuyPaymentInfoFailure( + PaymentInfoError.unknown, + message: 'The purchase could not be quoted. Please try again later.', + ), ); when(() => converterCubit.state).thenReturn( const BuyConverterState(currency: Currency.eur), @@ -238,7 +241,10 @@ void main() { await tester.pumpApp(buildSubject(const BuyView())); - expect(find.text(S.current.paymentInformationFailed), findsOne); + expect( + find.text('The purchase could not be quoted. Please try again later.'), + findsOne, + ); expect(find.text(S.current.retry), findsOne); await tester.tap(find.text(S.current.retry)); diff --git a/test/screens/buy/cubits/buy_confirm/buy_confirm_state_test.dart b/test/screens/buy/cubits/buy_confirm/buy_confirm_state_test.dart index 24738872a..e527bd333 100644 --- a/test/screens/buy/cubits/buy_confirm/buy_confirm_state_test.dart +++ b/test/screens/buy/cubits/buy_confirm/buy_confirm_state_test.dart @@ -71,27 +71,16 @@ void main() { }); group('BuyConfirmFailure', () { - test('same error variant is equal and props match', () { - final a = BuyConfirmFailure(BuyConfirmError.aktionariat); - final b = BuyConfirmFailure(BuyConfirmError.aktionariat); + test('same message is equal and props match', () { + final a = BuyConfirmFailure('The purchase could not be confirmed. Please try again later.'); + final b = BuyConfirmFailure('The purchase could not be confirmed. Please try again later.'); expect(a, equals(b)); - expect(a.props, [BuyConfirmError.aktionariat]); + expect(a.props, ['The purchase could not be confirmed. Please try again later.']); }); - test('different error variant is unequal', () { - final a = BuyConfirmFailure(BuyConfirmError.aktionariat); - final b = BuyConfirmFailure(BuyConfirmError.unknown); - expect(a, isNot(equals(b))); - }); - - test('primaryEmailRequired props match', () { - final a = BuyConfirmFailure(BuyConfirmError.primaryEmailRequired); - expect(a.props, [BuyConfirmError.primaryEmailRequired]); - }); - - test('primaryEmailRequired is unequal to aktionariat', () { - final a = BuyConfirmFailure(BuyConfirmError.primaryEmailRequired); - final b = BuyConfirmFailure(BuyConfirmError.aktionariat); + test('different message is unequal', () { + final a = BuyConfirmFailure('The purchase could not be confirmed. Please try again later.'); + final b = BuyConfirmFailure('User must have a primary email'); expect(a, isNot(equals(b))); }); }); @@ -103,7 +92,7 @@ void main() { test('Success vs Failure are unequal', () { final s = BuyConfirmSuccess(reference: 'ref'); - final f = BuyConfirmFailure(BuyConfirmError.unknown); + final f = BuyConfirmFailure('oops'); expect(s, isNot(equals(f))); }); }); diff --git a/test/screens/buy/cubits/buy_confirm_cubit_test.dart b/test/screens/buy/cubits/buy_confirm_cubit_test.dart index b5b51654f..90a7d2e11 100644 --- a/test/screens/buy/cubits/buy_confirm_cubit_test.dart +++ b/test/screens/buy/cubits/buy_confirm_cubit_test.dart @@ -59,12 +59,12 @@ void main() { expect(success.paymentRequest, isNull); }); - test('confirmPayment emits Failure(aktionariat) on ApiException 503', () async { + test('confirmPayment emits Failure with the API message on ApiException 503', () async { when(() => service.confirmPayment(any())).thenAnswer( (_) async => throw const ApiException( statusCode: 503, - code: 'SERVICE_UNAVAILABLE', - message: 'Aktionariat down', + code: 'AKTIONARIAT_UNAVAILABLE', + message: 'The purchase could not be confirmed. Please try again later.', ), ); @@ -73,12 +73,13 @@ void main() { await cubit.confirmPayment(7); await done; - expect(cubit.state, isA()); - expect((cubit.state as BuyConfirmFailure).error, BuyConfirmError.aktionariat); + expect( + (cubit.state as BuyConfirmFailure).message, + 'The purchase could not be confirmed. Please try again later.', + ); }); - test('confirmPayment emits Failure(amountTooLow) on ApiException 400 with ' - 'code AmountTooLow', () async { + test('confirmPayment emits Failure with the API message on AmountTooLow', () async { when(() => service.confirmPayment(any())).thenAnswer( (_) async => throw const ApiException( statusCode: 400, @@ -92,61 +93,18 @@ void main() { await cubit.confirmPayment(7); await done; - expect((cubit.state as BuyConfirmFailure).error, BuyConfirmError.amountTooLow); - }); - - test('confirmPayment prefers aktionariat over amountTooLow when a 503 also ' - 'carries code AmountTooLow', () async { - // 503 keeps precedence over the AmountTooLow code — pins the branch order - // so a future refactor can't surface a min-purchase message for a genuine - // service outage. - when(() => service.confirmPayment(any())).thenAnswer( - (_) async => throw const ApiException( - statusCode: 503, - code: 'AmountTooLow', - message: 'Aktionariat down', - ), - ); - - final cubit = BuyConfirmCubit(service); - final done = cubit.stream.firstWhere((s) => s is BuyConfirmFailure); - await cubit.confirmPayment(7); - await done; - - expect((cubit.state as BuyConfirmFailure).error, BuyConfirmError.aktionariat); - }); - - test('confirmPayment emits Failure(primaryEmailRequired) on ApiException 400 ' - 'with code PrimaryEmailRequired', () async { - when(() => service.confirmPayment(any())).thenAnswer( - (_) async => throw const ApiException( - statusCode: 400, - code: 'PrimaryEmailRequired', - message: 'Primary email is required', - ), - ); - - final cubit = BuyConfirmCubit(service); - final done = cubit.stream.firstWhere((s) => s is BuyConfirmFailure); - await cubit.confirmPayment(7); - await done; - expect( - (cubit.state as BuyConfirmFailure).error, - BuyConfirmError.primaryEmailRequired, + (cubit.state as BuyConfirmFailure).message, + 'Purchases by bank transfer require a minimum of 100 nominal in base currency', ); }); - test('confirmPayment prefers aktionariat over primaryEmailRequired when a ' - '503 also carries code PrimaryEmailRequired', () async { - // 503 keeps precedence over the PrimaryEmailRequired code — pins the - // branch order so a future refactor can't surface an email-required - // message for a genuine service outage. + test('confirmPayment emits Failure with the API message on PrimaryEmailRequired', () async { when(() => service.confirmPayment(any())).thenAnswer( (_) async => throw const ApiException( - statusCode: 503, + statusCode: 400, code: 'PrimaryEmailRequired', - message: 'Aktionariat down', + message: 'User must have a primary email', ), ); @@ -155,10 +113,10 @@ void main() { await cubit.confirmPayment(7); await done; - expect((cubit.state as BuyConfirmFailure).error, BuyConfirmError.aktionariat); + expect((cubit.state as BuyConfirmFailure).message, 'User must have a primary email'); }); - test('confirmPayment emits Failure(unknown) on other ApiException', () async { + test('confirmPayment emits Failure with the API message on other ApiException', () async { when(() => service.confirmPayment(any())).thenAnswer( (_) async => throw const ApiException( statusCode: 500, @@ -172,10 +130,10 @@ void main() { await cubit.confirmPayment(7); await done; - expect((cubit.state as BuyConfirmFailure).error, BuyConfirmError.unknown); + expect((cubit.state as BuyConfirmFailure).message, 'oops'); }); - test('confirmPayment emits Failure(unknown) on generic exception', () async { + test('confirmPayment emits Failure with the exception text when there is no API body', () async { when(() => service.confirmPayment(any())) .thenAnswer((_) async => throw Exception('network')); @@ -184,7 +142,7 @@ void main() { await cubit.confirmPayment(7); await done; - expect((cubit.state as BuyConfirmFailure).error, BuyConfirmError.unknown); + expect((cubit.state as BuyConfirmFailure).message, 'Exception: network'); }); }); } 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 f844ad269..168459aa9 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 @@ -62,7 +62,7 @@ void main() { final a = BuyPaymentInfoFailure(PaymentInfoError.kycRequired, requiredLevel: 30); final b = BuyPaymentInfoFailure(PaymentInfoError.kycRequired, requiredLevel: 30); expect(a, equals(b)); - expect(a.props, [PaymentInfoError.kycRequired, 30, null]); + expect(a.props, [PaymentInfoError.kycRequired, 30, null, '']); }); test('null requiredLevel is allowed and equal across instances', () { @@ -90,7 +90,7 @@ void main() { context: 'RealunitBuy', ); expect(a, equals(b)); - expect(a.props, [PaymentInfoError.kycRequired, 30, 'RealunitBuy']); + expect(a.props, [PaymentInfoError.kycRequired, 30, 'RealunitBuy', '']); }); test('different context is unequal', () { 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 9b9b88450..486ba9242 100644 --- a/test/screens/buy/cubits/buy_payment_info_cubit_test.dart +++ b/test/screens/buy/cubits/buy_payment_info_cubit_test.dart @@ -132,6 +132,20 @@ 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'), + ); + + final cubit = build(); + await cubit.getPaymentInfo(amount: '300'); + + expect(cubit.state, isA()); + expect((cubit.state as BuyPaymentInfoFailure).context, 'RealunitBuy'); + }); + 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)); @@ -279,6 +293,10 @@ void main() { await cubit.getPaymentInfo(amount: '300'); expect((cubit.state as BuyPaymentInfoFailure).error, PaymentInfoError.priceSourceUnavailable); + expect( + (cubit.state as BuyPaymentInfoFailure).message, + 'RealUnit price source (Aktionariat) is currently unavailable', + ); }); test('ApiException with code PRICE_SOURCE_UNAVAILABLE (non-503) → priceSourceUnavailable', () async { @@ -295,6 +313,7 @@ void main() { 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 { @@ -307,6 +326,7 @@ void main() { await cubit.getPaymentInfo(amount: '300'); expect((cubit.state as BuyPaymentInfoFailure).error, PaymentInfoError.unknown); + expect((cubit.state as BuyPaymentInfoFailure).message, 'bad'); }); test('does not emit after close', () async { diff --git a/test/screens/buy/widgets/buy_confirm_button_test.dart b/test/screens/buy/widgets/buy_confirm_button_test.dart index f97ff8b23..87cbdabe3 100644 --- a/test/screens/buy/widgets/buy_confirm_button_test.dart +++ b/test/screens/buy/widgets/buy_confirm_button_test.dart @@ -112,28 +112,13 @@ void main() { expect(find.byType(CupertinoActivityIndicator), findsOneWidget); }); - testWidgets('shows a snackbar with the generic error on failure', - (tester) async { - whenListen( - cubit, - Stream.fromIterable([ - const BuyConfirmFailure(BuyConfirmError.unknown), - ]), - initialState: const BuyConfirmInitial(), - ); - - await tester.pumpWidget(host()); - await tester.pump(); - - expect(find.text(S.current.buyPaymentConfirmFailed), findsOneWidget); - }); - - testWidgets('shows the aktionariat-specific error on a 503 failure', - (tester) async { + testWidgets('shows a snackbar with the API error message', (tester) async { whenListen( cubit, Stream.fromIterable([ - const BuyConfirmFailure(BuyConfirmError.aktionariat), + const BuyConfirmFailure( + 'The purchase could not be confirmed. Please try again later.', + ), ]), initialState: const BuyConfirmInitial(), ); @@ -141,32 +126,18 @@ void main() { await tester.pumpWidget(host()); await tester.pump(); - expect(find.text(S.current.buyPaymentConfirmFailedAktionariat), findsOneWidget); - }); - - testWidgets('shows the minimum-purchase error on an amount-too-low failure', - (tester) async { - whenListen( - cubit, - Stream.fromIterable([ - const BuyConfirmFailure(BuyConfirmError.amountTooLow), - ]), - initialState: const BuyConfirmInitial(), + expect( + find.text('The purchase could not be confirmed. Please try again later.'), + findsOneWidget, ); - - await tester.pumpWidget(host()); - await tester.pump(); - - expect(find.text(S.current.buyPaymentConfirmFailedAmountTooLow), findsOneWidget); }); - testWidgets( - 'shows the aktionariat-specific error on a primary-email-required failure', + testWidgets('shows a different API message without substituting local copy', (tester) async { whenListen( cubit, Stream.fromIterable([ - const BuyConfirmFailure(BuyConfirmError.primaryEmailRequired), + const BuyConfirmFailure('User must have a primary email'), ]), initialState: const BuyConfirmInitial(), ); @@ -174,7 +145,7 @@ void main() { await tester.pumpWidget(host()); await tester.pump(); - expect(find.text(S.current.buyPaymentConfirmFailedAktionariat), findsOneWidget); + expect(find.text('User must have a primary email'), findsOneWidget); }); GoRouter detailsRouter({BuyPaymentInfo info = _info}) => GoRouter( diff --git a/test/screens/buy/widgets/payment_action_button_test.dart b/test/screens/buy/widgets/payment_action_button_test.dart index 5bb775a30..a05cdc96a 100644 --- a/test/screens/buy/widgets/payment_action_button_test.dart +++ b/test/screens/buy/widgets/payment_action_button_test.dart @@ -31,6 +31,7 @@ void main() { // value (mirrors the registration / KYC gates), so the re-fetch is // asserted on both paths. bool? emailCaptureResult; + String? kycExtra; setUpAll(() { registerFallbackValue(Currency.chf); @@ -42,6 +43,7 @@ void main() { amountController = TextEditingController(text: '250'); pushedRoutes = []; emailCaptureResult = true; + kycExtra = null; when(() => converterCubit.state) .thenReturn(const BuyConverterState(currency: Currency.eur)); @@ -88,8 +90,9 @@ void main() { GoRoute( name: AppRoutes.kyc, path: '/kyc', - builder: (_, _) { + builder: (_, state) { pushedRoutes.add(AppRoutes.kyc); + kycExtra = state.extra as String?; return _EmailCaptureStub( onReady: (popContext) { WidgetsBinding.instance.addPostFrameCallback((_) { @@ -183,7 +186,10 @@ void main() { 'tap pushes kyc (never email capture) and re-fetches the quote', (tester) async { when(() => paymentInfoCubit.state).thenReturn( - const BuyPaymentInfoFailure(PaymentInfoError.primaryEmailNotConfirmed), + const BuyPaymentInfoFailure( + PaymentInfoError.primaryEmailNotConfirmed, + context: 'RealunitBuy', + ), ); await pumpButton(tester); @@ -193,6 +199,7 @@ void main() { // Routed to KYC confirm-email, not to email capture. 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. verify( diff --git a/test/screens/buy/widgets/payment_details_card_test.dart b/test/screens/buy/widgets/payment_details_card_test.dart index f8a88336b..94021f45a 100644 --- a/test/screens/buy/widgets/payment_details_card_test.dart +++ b/test/screens/buy/widgets/payment_details_card_test.dart @@ -38,23 +38,22 @@ const _rawIbanInfo = BuyPaymentInfo( currency: Currency.chf, ); -// The card uses InkWell (needs a Material ancestor) and renders a tall list of +// 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({ String purposeOfPayment = '', String? paymentRequest, BuyPaymentInfo buyPaymentInfo = _info, -}) => - Scaffold( - body: SingleChildScrollView( - child: PaymentDetailsCard( - buyPaymentInfo: buyPaymentInfo, - amount: '100', - purposeOfPayment: purposeOfPayment, - paymentRequest: paymentRequest, - ), - ), - ); +}) => Scaffold( + body: SingleChildScrollView( + child: PaymentDetailsCard( + buyPaymentInfo: buyPaymentInfo, + amount: '100', + purposeOfPayment: purposeOfPayment, + paymentRequest: paymentRequest, + ), + ), +); void main() { group('$PaymentDetailsCard', () { @@ -68,16 +67,14 @@ void main() { expect(find.text('${S.current.amountIn} ${Currency.chf.code}'), findsOneWidget); }); - testWidgets('renders the purpose of payment when purposeOfPayment is set', - (tester) async { + testWidgets('renders the purpose of payment when purposeOfPayment is set', (tester) async { await tester.pumpApp(_host(purposeOfPayment: 'REF-XYZ')); expect(find.text(S.current.purposeOfPayment), findsOneWidget); expect(find.text('REF-XYZ'), findsOneWidget); }); - testWidgets('omits the purpose row when purposeOfPayment is empty', - (tester) async { + testWidgets('omits the purpose row when purposeOfPayment is empty', (tester) async { await tester.pumpApp(_host()); expect(find.text(S.current.purposeOfPayment), findsNothing); @@ -89,8 +86,7 @@ void main() { expect(find.byIcon(Icons.copy_outlined), findsWidgets); }); - testWidgets('tapping a copy icon writes the value to the clipboard', - (tester) async { + testWidgets('tapping a copy icon writes the value to the clipboard', (tester) async { String? copied; final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; messenger.setMockMethodCallHandler(SystemChannels.platform, (call) async { @@ -145,22 +141,51 @@ void main() { expect( copied, - _rawIbanInfo.iban, - reason: 'The clipboard must receive the raw, ungrouped IBAN — a grouped IBAN with ' + _rawIbanInfo.iban.replaceAll(' ', ''), + reason: + 'The clipboard must receive the raw, ungrouped IBAN — a grouped IBAN with ' 'spaces can fail validation in some banking transfer forms.', ); }, ); - testWidgets('shows no tab selector when there is no payment request', - (tester) async { + testWidgets('strips spaces when the IBAN arrives already grouped', (tester) async { + String? copied; + final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler(SystemChannels.platform, (call) async { + if (call.method == 'Clipboard.setData') { + copied = (call.arguments as Map)['text'] as String?; + } + return null; + }); + addTearDown(() { + messenger.setMockMethodCallHandler(SystemChannels.platform, null); + }); + + await tester.pumpApp(_host()); + + final ibanRow = find.ancestor( + of: find.text(S.current.iban), + matching: find.byType(Row), + ); + await tester.tap( + find.descendant(of: ibanRow, matching: find.byIcon(Icons.copy_outlined)), + ); + await tester.pump(); + + expect(copied, _info.iban.replaceAll(' ', '')); + expect(copied!.contains(' '), isFalse); + }); + + testWidgets('shows no tab selector when there is no payment request', (tester) async { await tester.pumpApp(_host()); expect(find.byType(TabSelector), findsNothing); }); - testWidgets('shows the tab selector and a QR code when a payment request exists', - (tester) async { + testWidgets('shows the tab selector and a QR code when a payment request exists', ( + tester, + ) async { await tester.pumpApp( _host(paymentRequest: 'SPC\n0200\nsome-payload'), ); @@ -173,5 +198,67 @@ void main() { expect(find.byType(QrImageView), findsOneWidget); }); + + testWidgets( + 'copy control meets the 44×44 minimum tap target so it is hittable on an iPhone (2026-08-13)', + (tester) async { + await tester.pumpApp(_host(purposeOfPayment: 'DA6E-2904-5F41')); + + final copyButton = find.widgetWithIcon(IconButton, Icons.copy_outlined).first; + expect(copyButton, findsOneWidget); + + final size = tester.getSize(copyButton); + expect( + size.width, + greaterThanOrEqualTo(44), + reason: + 'Copy tap target width ${size.width} < 44 — too small for an iPhone finger ' + '(incident 2026-08-13: payment details could not be copied into the bank app).', + ); + expect( + size.height, + greaterThanOrEqualTo(44), + reason: + 'Copy tap target height ${size.height} < 44 — too small for an iPhone finger ' + '(incident 2026-08-13: payment details could not be copied into the bank app).', + ); + + await expectFullyTappable( + tester, + copyButton, + within: find.byType(PaymentDetailsCard), + ); + }, + ); + + testWidgets( + 'tapping copy on the purpose-of-payment row writes the remittance reference', + (tester) async { + String? copied; + final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler(SystemChannels.platform, (call) async { + if (call.method == 'Clipboard.setData') { + copied = (call.arguments as Map)['text'] as String?; + } + return null; + }); + addTearDown(() { + messenger.setMockMethodCallHandler(SystemChannels.platform, null); + }); + + await tester.pumpApp(_host(purposeOfPayment: 'DA6E-2904-5F41')); + + final purposeRow = find.ancestor( + of: find.text(S.current.purposeOfPayment), + matching: find.byType(Row), + ); + await tester.tap( + find.descendant(of: purposeRow, matching: find.byIcon(Icons.copy_outlined)), + ); + await tester.pump(); + + expect(copied, 'DA6E-2904-5F41'); + }, + ); }); } diff --git a/test/screens/buy/widgets/payment_information_test.dart b/test/screens/buy/widgets/payment_information_test.dart index 5c9d3004a..f08995ce3 100644 --- a/test/screens/buy/widgets/payment_information_test.dart +++ b/test/screens/buy/widgets/payment_information_test.dart @@ -3,6 +3,7 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; import 'package:realunit_wallet/packages/service/dfx/models/payment/buy/buy_payment_info.dart'; import 'package:realunit_wallet/packages/service/dfx/models/payment/payment_info_error.dart'; import 'package:realunit_wallet/screens/buy/cubits/buy_payment_info/buy_payment_info_cubit.dart'; @@ -58,14 +59,22 @@ void main() { expect(find.byType(PaymentActionRequired), findsOneWidget); }); - testWidgets('Failure(unknown): PaymentActionRequired', (tester) async { + testWidgets('Failure(unknown): PaymentActionRequired shows API message', (tester) async { when(() => cubit.state).thenReturn( - const BuyPaymentInfoFailure(PaymentInfoError.unknown), + const BuyPaymentInfoFailure( + PaymentInfoError.unknown, + message: 'The purchase could not be quoted. Please try again later.', + ), ); await tester.pumpApp(_host(cubit)); expect(find.byType(PaymentActionRequired), findsOneWidget); + expect( + find.text('The purchase could not be quoted. Please try again later.'), + findsOneWidget, + ); + expect(find.text(S.current.paymentInformationFailed), findsNothing); }); testWidgets( diff --git a/test/screens/buy_sell_converter_confirm_states_test.dart b/test/screens/buy_sell_converter_confirm_states_test.dart index 74414480f..35e9aa7e3 100644 --- a/test/screens/buy_sell_converter_confirm_states_test.dart +++ b/test/screens/buy_sell_converter_confirm_states_test.dart @@ -65,24 +65,14 @@ void main() { expect(a, isNot(c)); }); - test('Failure carries the BuyConfirmError', () { - const a = BuyConfirmFailure(BuyConfirmError.aktionariat); - const b = BuyConfirmFailure(BuyConfirmError.aktionariat); - const c = BuyConfirmFailure(BuyConfirmError.unknown); + test('Failure carries the API message', () { + const a = BuyConfirmFailure('The purchase could not be confirmed. Please try again later.'); + const b = BuyConfirmFailure('The purchase could not be confirmed. Please try again later.'); + const c = BuyConfirmFailure('User must have a primary email'); expect(a, b); expect(a, isNot(c)); }); - test('BuyConfirmError enum has exactly aktionariat + amountTooLow + primaryEmailRequired + unknown', () { - // Pin the variants — the listener in BuyButton switches on these. - expect(BuyConfirmError.values.toSet(), { - BuyConfirmError.aktionariat, - BuyConfirmError.amountTooLow, - BuyConfirmError.primaryEmailRequired, - BuyConfirmError.unknown, - }); - }); - test('Initial and Loading are distinct singletons (by value)', () { expect(const BuyConfirmInitial(), const BuyConfirmInitial()); expect(const BuyConfirmLoading(), const BuyConfirmLoading()); diff --git a/test/screens/kyc/kyc_static_pages_responsive_matrix_test.dart b/test/screens/kyc/kyc_static_pages_responsive_matrix_test.dart index bb33480bb..3504df63e 100644 --- a/test/screens/kyc/kyc_static_pages_responsive_matrix_test.dart +++ b/test/screens/kyc/kyc_static_pages_responsive_matrix_test.dart @@ -14,9 +14,8 @@ import 'package:realunit_wallet/styles/themes.dart'; import '../../helper/helper.dart'; -/// Long DE failure detail interpolated into the already-long -/// `kycFailureDescription` template — long enough to force scrolling on small -/// phones at high text scale. +/// Long DE failure detail rendered 1:1 — long enough to force scrolling on +/// small phones at high text scale. const _longFailureMessage = 'Die Verbindung zum Server wurde unerwartet unterbrochen, während Ihre ' 'Dokumente zur Identitätsprüfung hochgeladen wurden. Möglicherweise liegt ' diff --git a/test/screens/kyc/steps/2fa/kyc_2fa_cubit_test.dart b/test/screens/kyc/steps/2fa/kyc_2fa_cubit_test.dart index 24c0bd45b..fe074ff69 100644 --- a/test/screens/kyc/steps/2fa/kyc_2fa_cubit_test.dart +++ b/test/screens/kyc/steps/2fa/kyc_2fa_cubit_test.dart @@ -2,6 +2,7 @@ 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/exceptions/api_exception.dart'; import 'package:realunit_wallet/screens/kyc/steps/2fa/cubits/kyc_2fa/kyc_2fa_cubit.dart'; class _MockKycService extends Mock implements DfxKycService {} @@ -47,6 +48,23 @@ void main() { ), ], ); + + blocTest( + 'ApiException → Failure with API message as-is', + setUp: () => when(() => service.request2FaCode()).thenAnswer( + (_) async => throw const ApiException( + statusCode: 400, + code: 'X', + message: 'Too many requests', + ), + ), + build: build, + act: (c) => c.requestCode(), + expect: () => [ + const Kyc2FaLoading(), + const Kyc2FaFailure(errorMessage: 'Too many requests'), + ], + ); }); group('$Kyc2FaFailure', () { diff --git a/test/screens/kyc/steps/2fa/kyc_2fa_verify_cubit_test.dart b/test/screens/kyc/steps/2fa/kyc_2fa_verify_cubit_test.dart index b3027ecd2..7787ad2c9 100644 --- a/test/screens/kyc/steps/2fa/kyc_2fa_verify_cubit_test.dart +++ b/test/screens/kyc/steps/2fa/kyc_2fa_verify_cubit_test.dart @@ -2,6 +2,7 @@ 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/exceptions/api_exception.dart'; import 'package:realunit_wallet/screens/kyc/steps/2fa/cubits/kyc_2fa_verify/kyc_2fa_verify_cubit.dart'; class _MockKycService extends Mock implements DfxKycService {} @@ -49,6 +50,23 @@ void main() { ), ], ); + + blocTest( + 'ApiException → Failure with API message as-is', + setUp: () => when(() => service.verify2FaCode(any())).thenAnswer( + (_) async => throw const ApiException( + statusCode: 400, + code: 'X', + message: 'Invalid code', + ), + ), + build: build, + act: (c) => c.verifyCode('000000'), + expect: () => [ + const Kyc2FaVerifyLoading(), + const Kyc2FaVerifyFailure(errorMessage: 'Invalid code'), + ], + ); }); group('$Kyc2FaVerifyFailure', () { diff --git a/test/screens/kyc/steps/confirm_email/kyc_confirm_email_cubit_test.dart b/test/screens/kyc/steps/confirm_email/kyc_confirm_email_cubit_test.dart index 5c806942e..6bed48106 100644 --- a/test/screens/kyc/steps/confirm_email/kyc_confirm_email_cubit_test.dart +++ b/test/screens/kyc/steps/confirm_email/kyc_confirm_email_cubit_test.dart @@ -6,6 +6,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:realunit_wallet/packages/service/dfx/models/wallet/real_unit_registration_info_dto.dart'; import 'package:realunit_wallet/packages/service/dfx/models/wallet/real_unit_registration_state.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart'; import 'package:realunit_wallet/packages/service/dfx/real_unit_registration_service.dart'; import 'package:realunit_wallet/screens/kyc/steps/confirm_email/cubits/kyc_confirm_email_cubit.dart'; @@ -80,7 +81,7 @@ void main() { ); blocTest( - 'getRegistrationInfo throws → NotConfirmed (fail closed, retryable)', + 'getRegistrationInfo throws → Failure with the error text (not "email unconfirmed")', setUp: () { when(() => registrationService.getRegistrationInfo()).thenThrow( Exception('network error'), @@ -90,7 +91,32 @@ void main() { act: (c) => c.recheck(), expect: () => [ isA(), - isA(), + isA(), + ], + verify: (c) { + expect( + (c.state as KycConfirmEmailFailure).message, + contains('network error'), + ); + }, + ); + + blocTest( + 'ApiException → Failure with the API message 1:1', + setUp: () { + when(() => registrationService.getRegistrationInfo()).thenThrow( + const ApiException( + statusCode: 503, + code: 'AKTIONARIAT_UNAVAILABLE', + message: 'Price source is temporarily unavailable', + ), + ); + }, + build: build, + act: (c) => c.recheck(), + expect: () => [ + isA(), + const KycConfirmEmailFailure('Price source is temporarily unavailable'), ], ); @@ -123,7 +149,7 @@ void main() { // budget without a wallclock sleep. Mirrors the `KycCubit` outer-timeout // test. test( - 'stalled getRegistrationInfo -> NotConfirmed after the timeout (not stuck loading)', + 'stalled getRegistrationInfo -> Failure after the timeout (not stuck loading)', () { fakeAsync((async) { when(() => registrationService.getRegistrationInfo()).thenAnswer( @@ -137,10 +163,9 @@ void main() { unawaited(cubit.recheck()); async.elapse(const Duration(seconds: 31)); - expect(states, const [ - KycConfirmEmailLoading(), - KycConfirmEmailNotConfirmed(), - ]); + expect(states, hasLength(2)); + expect(states.first, const KycConfirmEmailLoading()); + expect(states.last, isA()); sub.cancel(); cubit.close(); diff --git a/test/screens/kyc/steps/confirm_email/kyc_confirm_email_page_test.dart b/test/screens/kyc/steps/confirm_email/kyc_confirm_email_page_test.dart index e9f9a72bf..78c86b81e 100644 --- a/test/screens/kyc/steps/confirm_email/kyc_confirm_email_page_test.dart +++ b/test/screens/kyc/steps/confirm_email/kyc_confirm_email_page_test.dart @@ -8,6 +8,7 @@ import 'package:realunit_wallet/packages/service/dfx/real_unit_registration_serv import 'package:realunit_wallet/screens/kyc/cubits/kyc/kyc_cubit.dart'; import 'package:realunit_wallet/screens/kyc/steps/confirm_email/cubits/kyc_confirm_email_cubit.dart'; import 'package:realunit_wallet/screens/kyc/steps/confirm_email/kyc_confirm_email_page.dart'; +import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart'; import '../../../../helper/pump_app.dart'; @@ -84,8 +85,8 @@ void main() { await tester.pumpApp(buildSubject(const KycConfirmEmailView())); - final button = tester.widget(find.byType(FilledButton)); - expect(button.onPressed, isNull); + final button = tester.widget(find.byType(AppFilledButton)); + expect(button.state, FilledButtonState.loading); }); }); @@ -106,6 +107,22 @@ void main() { verifyNever(() => kycCubit.checkKyc()); }); + testWidgets('shows the API message 1:1 when the re-check fails', ( + tester, + ) async { + whenListen( + confirmCubit, + Stream.fromIterable([const KycConfirmEmailFailure('Price source is temporarily unavailable')]), + initialState: const KycConfirmEmailInitial(), + ); + + await tester.pumpApp(buildSubject(const KycConfirmEmailView())); + await tester.pump(); + + expect(find.text('Price source is temporarily unavailable'), findsOne); + verifyNever(() => kycCubit.checkKyc()); + }); + testWidgets('re-runs checkKyc when the address is confirmed', (tester) async { whenListen( confirmCubit, diff --git a/test/screens/kyc/steps/kyc_personal_data_page_test.dart b/test/screens/kyc/steps/kyc_personal_data_page_test.dart index 720475d93..9e75c1844 100644 --- a/test/screens/kyc/steps/kyc_personal_data_page_test.dart +++ b/test/screens/kyc/steps/kyc_personal_data_page_test.dart @@ -149,9 +149,9 @@ void main() { testWidgets('renders every field the personal-data step submits', (tester) async { await tester.pumpApp(buildSubject(KycPersonalDataView(url: url, initialUserData: userDataDto()))); - // six placed directly (first/last name, street, house number, postcode, city) plus the one - // PhoneNumberField nests for the number input - expect(find.byType(LabeledTextField), findsNWidgets(7)); + // six placed directly (first/last name, street, house number, postcode, city) plus the two + // PhoneNumberField nests for prefix and number + expect(find.byType(LabeledTextField), findsNWidgets(8)); expect(find.byType(PhoneNumberField), findsOne); expect(find.byType(CountryField), findsOne); expect(find.byType(FilledButton), findsOne); @@ -180,7 +180,7 @@ void main() { await tester.pumpApp(buildSubject(KycPersonalDataView(url: url, initialUserData: dto))); await tester.pumpAndSettle(); - expect(find.text('+41'), findsOne); + expect(find.text('41'), findsOne); }); // The country lookup is fire-and-forget; without a catch a failing GET escapes as an uncaught diff --git a/test/screens/kyc/steps/kyc_registration_page_test.dart b/test/screens/kyc/steps/kyc_registration_page_test.dart index 1b2987d9a..ad9182df6 100644 --- a/test/screens/kyc/steps/kyc_registration_page_test.dart +++ b/test/screens/kyc/steps/kyc_registration_page_test.dart @@ -414,7 +414,7 @@ void main() { await tester.pump(); expect(find.byType(SnackBar), findsOne); - expect(find.textContaining('Registration failed'), findsOne); + expect(find.textContaining('fail'), findsOne); // A failed submit must not re-arm the wallet services — the re-arm is // gated behind KycRegistrationSubmitSuccess. verifyNever(() => homeBloc.add(any(that: isA()))); @@ -450,7 +450,7 @@ void main() { expect(find.byType(SnackBar), findsOne); expect(find.textContaining('Registration date must be today'), findsOne); - expect(find.textContaining('Your data has not been saved'), findsOne); + expect(find.textContaining('Your data has not been saved'), findsNothing); expect(find.textContaining('RegistrationRejectedException'), findsNothing); verifyNever(() => homeBloc.add(any(that: isA()))); }, @@ -468,8 +468,7 @@ void main() { registrationSubmitCubit, Stream.fromIterable([ const KycRegistrationSubmitFailure( - 'RealUnitApiException: Unauthorized ' - '(code: UNKNOWN, statusCode: 401)', + 'Unauthorized', cause: ApiException( statusCode: 401, code: 'UNKNOWN', @@ -484,7 +483,7 @@ void main() { await tester.pump(); expect(find.byType(SnackBar), findsOne); - expect(find.textContaining('Registration failed'), findsOne); + expect(find.textContaining('Unauthorized'), findsOne); expect(find.textContaining('Your data has not been saved'), findsNothing); verifyNever(() => homeBloc.add(any(that: isA()))); }, @@ -498,8 +497,7 @@ void main() { registrationSubmitCubit, Stream.fromIterable([ const KycRegistrationSubmitFailure( - 'RealUnitApiException: Internal server error ' - '(code: UNKNOWN, statusCode: 500)', + 'Internal server error', cause: ApiException( statusCode: 500, code: 'UNKNOWN', @@ -514,7 +512,7 @@ void main() { await tester.pump(); expect(find.byType(SnackBar), findsOne); - expect(find.textContaining('Registration failed'), findsOne); + expect(find.textContaining('Internal server error'), findsOne); expect(find.textContaining('Your data has not been saved'), findsNothing); verifyNever(() => homeBloc.add(any(that: isA()))); }, diff --git a/test/screens/kyc/steps/link_wallet/kyc_link_wallet_cubit_test.dart b/test/screens/kyc/steps/link_wallet/kyc_link_wallet_cubit_test.dart index 5e1d1f50b..bf9925add 100644 --- a/test/screens/kyc/steps/link_wallet/kyc_link_wallet_cubit_test.dart +++ b/test/screens/kyc/steps/link_wallet/kyc_link_wallet_cubit_test.dart @@ -1,6 +1,7 @@ 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/exceptions/api_exception.dart'; import 'package:realunit_wallet/packages/service/dfx/exceptions/bitbox_exception.dart'; import 'package:realunit_wallet/packages/service/dfx/models/registration/kyc/kyc_personal_data.dart'; import 'package:realunit_wallet/packages/service/dfx/models/registration/registration_status.dart'; @@ -99,6 +100,23 @@ void main() { act: (c) => c.submit(_userData), expect: () => [const KycLinkWalletSubmitting(_userData), isA()], ); + + blocTest( + 'registerWallet throws ApiException → Failure with API message as-is', + setUp: () { + when( + () => registrationService.registerWallet(any()), + ).thenThrow( + const ApiException(statusCode: 400, code: 'X', message: 'Wallet already linked'), + ); + }, + build: build, + act: (c) => c.submit(_userData), + expect: () => [ + const KycLinkWalletSubmitting(_userData), + isA().having((s) => s.message, 'message', 'Wallet already linked'), + ], + ); }); group('retrySubmit', () { diff --git a/test/screens/kyc/steps/nationality/kyc_nationality_cubit_test.dart b/test/screens/kyc/steps/nationality/kyc_nationality_cubit_test.dart index c29a178fa..a087b5198 100644 --- a/test/screens/kyc/steps/nationality/kyc_nationality_cubit_test.dart +++ b/test/screens/kyc/steps/nationality/kyc_nationality_cubit_test.dart @@ -2,6 +2,7 @@ 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/exceptions/api_exception.dart'; import 'package:realunit_wallet/packages/service/dfx/models/country/country.dart'; import 'package:realunit_wallet/screens/kyc/steps/nationality/cubit/kyc_nationality/kyc_nationality_cubit.dart'; @@ -71,6 +72,26 @@ void main() { ), ], ); + + blocTest( + 'ApiException → Failure with API message as-is', + setUp: () => when(() => service.setData(any(), any())).thenAnswer( + (_) async => throw const ApiException( + statusCode: 400, + code: 'X', + message: 'Invalid nationality', + ), + ), + build: build, + act: (c) => c.registerNationality( + url: 'https://kyc/nat', + nationality: _switzerland, + ), + expect: () => [ + const KycNationalityLoading(), + const KycNationalityFailure('Invalid nationality'), + ], + ); }); group('$KycNationalityFailure', () { diff --git a/test/screens/kyc/steps/personal_data/kyc_personal_data_cubit_test.dart b/test/screens/kyc/steps/personal_data/kyc_personal_data_cubit_test.dart index f5b57bd01..a1f5982a5 100644 --- a/test/screens/kyc/steps/personal_data/kyc_personal_data_cubit_test.dart +++ b/test/screens/kyc/steps/personal_data/kyc_personal_data_cubit_test.dart @@ -2,6 +2,7 @@ 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/exceptions/api_exception.dart'; import 'package:realunit_wallet/packages/service/dfx/models/country/country.dart'; import 'package:realunit_wallet/packages/service/dfx/models/registration/kyc/kyc_personal_data.dart'; import 'package:realunit_wallet/screens/kyc/steps/personal_data/cubit/kyc_personal_data/kyc_personal_data_cubit.dart'; @@ -83,6 +84,23 @@ void main() { isA().having((s) => s.message, 'message', contains('boom')), ], ); + + blocTest( + 'ApiException → Failure with API message as-is', + setUp: () => when(() => service.setData(any(), any())).thenAnswer( + (_) async => throw const ApiException( + statusCode: 400, + code: 'X', + message: 'Invalid address', + ), + ), + build: build, + act: submit, + expect: () => [ + const KycPersonalDataLoading(), + const KycPersonalDataFailure('Invalid address'), + ], + ); }); group('$KycPersonalDataFailure', () { diff --git a/test/screens/kyc/steps/registration/cubits/registration_submit/kyc_registration_submit_cubit_test.dart b/test/screens/kyc/steps/registration/cubits/registration_submit/kyc_registration_submit_cubit_test.dart index 024f3c77a..7cc90a373 100644 --- a/test/screens/kyc/steps/registration/cubits/registration_submit/kyc_registration_submit_cubit_test.dart +++ b/test/screens/kyc/steps/registration/cubits/registration_submit/kyc_registration_submit_cubit_test.dart @@ -182,7 +182,11 @@ void main() { act: (cubit) => _submitFromRegistration(cubit, _registration()), expect: () => [ KycRegistrationSubmitLoading(), - isA(), + isA().having( + (s) => s.message, + 'message', + 'wallet linked to other account', + ), ], ); @@ -215,6 +219,21 @@ void main() { ], ); + blocTest( + 'emits Failure with API message when getUser throws ApiException', + setUp: () { + when(() => kycService.getUser()).thenThrow( + const ApiException(statusCode: 401, code: 'X', message: 'Unauthorized'), + ); + }, + build: buildCubit, + act: (cubit) => _submitFromRegistration(cubit, _registration()), + expect: () => [ + KycRegistrationSubmitLoading(), + isA().having((s) => s.message, 'message', 'Unauthorized'), + ], + ); + blocTest( 'emits Failure when getUser itself throws', setUp: () { diff --git a/test/screens/pay/pay_process_cubit_test.dart b/test/screens/pay/pay_process_cubit_test.dart index d75bfb0c1..cfcb40dbd 100644 --- a/test/screens/pay/pay_process_cubit_test.dart +++ b/test/screens/pay/pay_process_cubit_test.dart @@ -567,7 +567,7 @@ void main() { final state = await retry as PayProcessPayRetry; expect(state.reason, PayRetryReason.unsignedTxMismatch); - expect(state.message, contains('amountWei is not a valid integer')); + expect(state.message, isNull); verifyNever(() => payService.submitPay(any())); await cubit.close(); }); @@ -585,7 +585,7 @@ void main() { final state = await retry as PayProcessPayRetry; expect(state.reason, PayRetryReason.unsignedTxMismatch); - expect(state.message, contains('tokenAddress is not a valid 20-byte address')); + expect(state.message, isNull); verifyNever(() => payService.submitPay(any())); await cubit.close(); }, @@ -669,7 +669,7 @@ void main() { final state = cubit.state as PayProcessPayRetry; expect(state.reason, PayRetryReason.transient); - expect(state.message, 'status polling exceeded max attempts'); + expect(state.message, isNull); expect(pollCalls, 40); // Polling has genuinely stopped — elapsing further must not trigger another call. @@ -704,7 +704,7 @@ void main() { final state = cubit.state as PayProcessPayRetry; expect(state.reason, PayRetryReason.transient); - expect(state.message, 'status polling exceeded max attempts'); + expect(state.message, isNull); expect(pollCalls, 40); cubit.close(); @@ -823,7 +823,7 @@ void main() { final state = cubit.state as PayProcessFailure; expect(state.reason, PayProcessFailureReason.insufficientEth); expect(cubit.debugSwapInFlight, isFalse); - expect(state.message, 'eth balance polling exceeded max attempts'); + expect(state.message, isNull); expect(balanceCalls, 24); // Polling has genuinely stopped — elapsing further must not trigger another call. @@ -904,7 +904,7 @@ void main() { final state = cubit.state as PayProcessFailure; expect(state.reason, PayProcessFailureReason.insufficientEth); - expect(state.message, 'eth balance polling exceeded max attempts'); + expect(state.message, isNull); expect(balanceCalls, 24); cubit.close(); diff --git a/test/screens/pay/pay_process_page_test.dart b/test/screens/pay/pay_process_page_test.dart index 7c0fb6d66..42d2eb967 100644 --- a/test/screens/pay/pay_process_page_test.dart +++ b/test/screens/pay/pay_process_page_test.dart @@ -229,6 +229,19 @@ void main() { expect(find.text(S.current.payFailureGeneric), findsOne); }); + + testWidgets('generic failure with API message shows the API text 1:1', (tester) async { + await pumpWithState( + tester, + const PayProcessFailure( + PayProcessFailureReason.generic, + message: 'Price source is temporarily unavailable', + ), + ); + + expect(find.text('Price source is temporarily unavailable'), findsOne); + expect(find.text(S.current.payFailureGeneric), findsNothing); + }); }); group('$PayProcessView retry sheet', () { @@ -270,5 +283,18 @@ void main() { expect(find.text(S.current.payRetryUnsignedTxMismatch), findsOne); }); + + testWidgets('transient retry with API message shows the API text 1:1', (tester) async { + await pumpWithState( + tester, + const PayProcessPayRetry( + PayRetryReason.transient, + message: 'Quote is no longer valid', + ), + ); + + expect(find.text('Quote is no longer valid'), findsOne); + expect(find.text(S.current.payRetryTransient), findsNothing); + }); }); } diff --git a/test/screens/pay/pay_quote_cubit_test.dart b/test/screens/pay/pay_quote_cubit_test.dart index bf4789f4c..d92901741 100644 --- a/test/screens/pay/pay_quote_cubit_test.dart +++ b/test/screens/pay/pay_quote_cubit_test.dart @@ -150,6 +150,9 @@ void main() { ); }, act: (cubit) => cubit.load(), - expect: () => [isA(), isA()], + expect: () => [ + isA(), + const PayQuoteError('boom'), + ], ); } diff --git a/test/screens/pay/pay_quote_page_test.dart b/test/screens/pay/pay_quote_page_test.dart index e6496b5ea..a003fd44e 100644 --- a/test/screens/pay/pay_quote_page_test.dart +++ b/test/screens/pay/pay_quote_page_test.dart @@ -166,10 +166,18 @@ void main() { expect(find.text(S.current.payQuoteUnavailable), findsOne); }); - testWidgets('error state shows the generic failure message', (tester) async { + testWidgets('error state shows the API message 1:1', (tester) async { when(() => quoteCubit.state).thenReturn(const PayQuoteError('boom')); await tester.pumpApp(buildSubject()); + expect(find.text('boom'), findsOne); + expect(find.text(S.current.payFailureGeneric), findsNothing); + }); + + testWidgets('error state without API text falls back to the generic copy', (tester) async { + when(() => quoteCubit.state).thenReturn(const PayQuoteError('')); + await tester.pumpApp(buildSubject()); + expect(find.text(S.current.payFailureGeneric), findsOne); }); diff --git a/test/screens/sell/cubits/sell_confirm_cubit_test.dart b/test/screens/sell/cubits/sell_confirm_cubit_test.dart index 82590f704..826a692c8 100644 --- a/test/screens/sell/cubits/sell_confirm_cubit_test.dart +++ b/test/screens/sell/cubits/sell_confirm_cubit_test.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart'; import 'package:realunit_wallet/packages/service/dfx/exceptions/payment/sell_exceptions.dart'; import 'package:realunit_wallet/packages/service/dfx/models/payment/sell/dto/eip7702/eip7702_data_dto.dart'; import 'package:realunit_wallet/packages/service/dfx/models/payment/sell/dto/real_unit_sell_payment_info_dto.dart'; @@ -105,6 +106,25 @@ void main() { ); }); + test('confirmPayment emits the API message 1:1 on ApiException', () async { + when(() => service.confirmPayment(any())).thenAnswer( + (_) async => throw const ApiException( + statusCode: 503, + code: 'AKTIONARIAT_UNAVAILABLE', + message: 'Price source is temporarily unavailable', + ), + ); + final cubit = SellConfirmCubit(service); + + await cubit.confirmPayment(_stubPaymentInfo()); + + expect(cubit.state, isA()); + expect( + (cubit.state as SellConfirmFailure).error, + 'Price source is temporarily unavailable', + ); + }); + test( 'already-confirmed conflict resolves to Success — the sell completed server-side', () async { diff --git a/test/screens/sell/cubits/sell_payment_info_cubit_test.dart b/test/screens/sell/cubits/sell_payment_info_cubit_test.dart index 94e56656e..94be1457f 100644 --- a/test/screens/sell/cubits/sell_payment_info_cubit_test.dart +++ b/test/screens/sell/cubits/sell_payment_info_cubit_test.dart @@ -155,7 +155,7 @@ void main() { expect(s.currency, Currency.eur); }); - test('API isValid=false with unrelated error → Failure(unknown) carrying the error', () async { + test('API isValid=false with unrelated error code → Failure(unknown) without using the code as copy', () async { when( () => service.getPaymentInfo(any(), any(), currency: any(named: 'currency')), ).thenAnswer((_) async => _info(isValid: false, error: 'KycRequired')); @@ -165,7 +165,7 @@ void main() { final f = cubit.state as SellPaymentInfoFailure; expect(f.error, PaymentInfoError.unknown); - expect(f.message, 'KycRequired'); + expect(f.message, isEmpty); }); test('KycLevelRequiredException → Failure(kycRequired, requiredLevel)', () async { @@ -185,6 +185,7 @@ void main() { final f = cubit.state as SellPaymentInfoFailure; expect(f.error, PaymentInfoError.kycRequired); expect(f.requiredLevel, 30); + expect(f.message, 'KYC required'); }); test('KycLevelRequiredException with context → Failure carries context', () async { @@ -258,6 +259,7 @@ void main() { (cubit.state as SellPaymentInfoFailure).error, PaymentInfoError.registrationRequired, ); + expect((cubit.state as SellPaymentInfoFailure).message, 'Sign first'); }); test('RegistrationRequiredException with context → Failure carries context', () async { @@ -304,6 +306,10 @@ void main() { await cubit.getPaymentInfo(amount: '100', iban: 'CH56'); expect((cubit.state as SellPaymentInfoFailure).error, PaymentInfoError.priceSourceUnavailable); + expect( + (cubit.state as SellPaymentInfoFailure).message, + 'RealUnit price source (Aktionariat) is currently unavailable', + ); }); test('other ApiException (e.g. 400) → Failure(unknown)', () async { @@ -315,6 +321,7 @@ void main() { await cubit.getPaymentInfo(amount: '100', iban: 'CH56'); expect((cubit.state as SellPaymentInfoFailure).error, PaymentInfoError.unknown); + expect((cubit.state as SellPaymentInfoFailure).message, 'bad'); }); test( diff --git a/test/screens/send/cubits/send_process_cubit_test.dart b/test/screens/send/cubits/send_process_cubit_test.dart index 72cf02f0b..aee48a490 100644 --- a/test/screens/send/cubits/send_process_cubit_test.dart +++ b/test/screens/send/cubits/send_process_cubit_test.dart @@ -197,6 +197,7 @@ void main() { (cubit.state as SendProcessFailure).reason, SendProcessFailureReason.gasFundingUnavailable, ); + expect((cubit.state as SendProcessFailure).message, isNull); await cubit.close(); }); diff --git a/test/screens/send/send_process_page_test.dart b/test/screens/send/send_process_page_test.dart index dd45b5fc3..f057b4976 100644 --- a/test/screens/send/send_process_page_test.dart +++ b/test/screens/send/send_process_page_test.dart @@ -203,6 +203,19 @@ void main() { expect(find.text(S.current.sendFailureGasUnavailable), findsOne); }); + testWidgets('gas-unavailable with API message shows the API text', (tester) async { + await pumpWithState( + tester, + const SendProcessFailure( + SendProcessFailureReason.gasFundingUnavailable, + message: 'Transfers are currently unavailable', + ), + ); + + expect(find.text('Transfers are currently unavailable'), findsOne); + expect(find.text(S.current.sendFailureGasUnavailable), findsNothing); + }); + testWidgets('invalid-request failure message', (tester) async { await pumpWithState( tester, @@ -213,7 +226,7 @@ void main() { }); testWidgets( - 'registration/KYC failure always shows localized copy, never raw API message', + 'registration/KYC failure shows the API message as-is', (tester) async { await pumpWithState( tester, @@ -223,8 +236,8 @@ void main() { ), ); - expect(find.text(S.current.sendFailureRegistrationOrKycRequired), findsOne); - expect(find.text('Please complete KYC'), findsNothing); + expect(find.text('Please complete KYC'), findsOne); + expect(find.text(S.current.sendFailureRegistrationOrKycRequired), findsNothing); }, ); diff --git a/test/screens/transaction_history/cubits/transaction_history_filter_cubit_test.dart b/test/screens/transaction_history/cubits/transaction_history_filter_cubit_test.dart index 0fe8a3f77..4a04fdb8e 100644 --- a/test/screens/transaction_history/cubits/transaction_history_filter_cubit_test.dart +++ b/test/screens/transaction_history/cubits/transaction_history_filter_cubit_test.dart @@ -108,7 +108,7 @@ void main() { ); blocTest( - 'changeFilter includes the boundaries (isBefore / isAfter, not isAtSameMoment)', + 'changeFilter includes the boundary days', build: build, act: (cubit) async { stream.add([ @@ -128,6 +128,69 @@ void main() { }, ); + blocTest( + 'changeFilter keeps transactions timestamped later on the selected end day ' + '(issue #779 regression)', + build: build, + act: (cubit) async { + stream.add([ + _tx(DateTime(2026, 4, 1)), + _tx(DateTime(2026, 4, 1, 14)), + _tx(DateTime(2026, 4, 1, 23, 59, 59)), + ]); + await Future.delayed(Duration.zero); + + cubit.changeFilter( + startDate: DateTime(2026, 2, 1), + endDate: DateTime(2026, 4, 1), + ); + }, + verify: (cubit) { + // The end bound arrives as local midnight; the whole selected day belongs to it. + // Before the fix only the 00:00 transaction survived. + expect(cubit.state.filtered, hasLength(3)); + }, + ); + + blocTest( + 'changeFilter still excludes the day after the selected end date', + build: build, + act: (cubit) async { + stream.add([ + _tx(DateTime(2026, 4, 1, 23, 59, 59)), + _tx(DateTime(2026, 4, 2)), + ]); + await Future.delayed(Duration.zero); + + cubit.changeFilter( + startDate: DateTime(2026, 2, 1), + endDate: DateTime(2026, 4, 1), + ); + }, + verify: (cubit) { + expect(cubit.state.filtered, hasLength(1)); + expect(cubit.state.filtered.single.timestamp, DateTime(2026, 4, 1, 23, 59, 59)); + }, + ); + + blocTest( + 'changeFilter includes a UTC-flagged timestamp falling on the local end day', + build: build, + act: (cubit) async { + // Same instant as 14:00 local — the UTC flag must not shift it out of the window. + stream.add([_tx(DateTime(2026, 4, 1, 14).toUtc())]); + await Future.delayed(Duration.zero); + + cubit.changeFilter( + startDate: DateTime(2026, 2, 1), + endDate: DateTime(2026, 4, 1), + ); + }, + verify: (cubit) { + expect(cubit.state.filtered, hasLength(1)); + }, + ); + blocTest( 'changeFilter keeps the previously-set bound when only the other changes ' '(issue #657 P3 regression)', diff --git a/test/widgets/form/phone_number_field_test.dart b/test/widgets/form/phone_number_field_test.dart index f888c0965..656657bb4 100644 --- a/test/widgets/form/phone_number_field_test.dart +++ b/test/widgets/form/phone_number_field_test.dart @@ -35,12 +35,16 @@ Future<_PhoneFieldHarness> _pumpPhoneField( return _PhoneFieldHarness(formKey: formKey, controller: controller); } +Finder _prefixField() => find.byType(TextFormField).first; + +Finder _numberField() => find.byType(TextFormField).at(1); + Future _enterAndValidate( WidgetTester tester, _PhoneFieldHarness harness, String nationalNumber, ) async { - await tester.enterText(find.byType(TextFormField), nationalNumber); + await tester.enterText(_numberField(), nationalNumber); final isValid = harness.formKey.currentState!.validate(); await tester.pump(); @@ -68,6 +72,20 @@ void main() { expect(harness.controller.value, isNull); }); + testWidgets('shows the required error for an empty prefix', (tester) async { + final harness = await _pumpPhoneField(tester); + + await tester.enterText(_prefixField(), ''); + final isValid = harness.formKey.currentState!.validate(); + await tester.pump(); + + expect(isValid, isFalse); + expect( + find.text(_phoneError(tester, (s) => s.registerPhoneNumberPrefixInvalid)), + findsOneWidget, + ); + }); + testWidgets('shows the digits-only error for non-digit input', (tester) async { final harness = await _pumpPhoneField(tester); @@ -117,18 +135,80 @@ void main() { expect(isValid, isTrue); }); + testWidgets('accepts a non-CH/DE prefix and composes the stored number', (tester) async { + final harness = await _pumpPhoneField(tester); + + await tester.enterText(_prefixField(), '43'); + final isValid = await _enterAndValidate(tester, harness, '12345'); + + expect(harness.controller.value, '+4312345'); + expect(isValid, isTrue); + }); + testWidgets('switching the country prefix recomposes the stored number', (tester) async { final harness = await _pumpPhoneField(tester, initialPhoneNumber: '+41791234567'); - // Drive the prefix dropdown's onChanged directly (deterministic, no - // overlay menu): the national part is preserved and re-prefixed. - final dropdown = tester.widget>( - find.byType(DropdownButtonFormField), - ); - dropdown.onChanged!('+49'); - await tester.pumpAndSettle(); + await tester.enterText(_prefixField(), '49'); + await tester.pump(); expect(harness.controller.value, '+49791234567'); }); + + testWidgets('does not accept more than 3 prefix digits', (tester) async { + final harness = await _pumpPhoneField(tester); + + // LengthLimitingTextInputFormatter keeps the old value when the field is + // already at maxLength and the incoming edit is longer (collapsed + // selection). Clear first so '1234' is truncated to '123' rather than + // rejected against a 3-digit seed. + await tester.enterText(_prefixField(), ''); + await tester.enterText(_prefixField(), '1234'); + await tester.enterText(_numberField(), '791234567'); + await tester.pump(); + + final prefixEditable = tester.widget( + find.descendant(of: _prefixField(), matching: find.byType(EditableText)), + ); + expect(prefixEditable.controller.text, '123'); + expect(harness.controller.value, '+123791234567'); + }); + + testWidgets('accepts a 3-digit prefix and composes the stored number', (tester) async { + final harness = await _pumpPhoneField(tester); + + await tester.enterText(_prefixField(), '423'); + final isValid = await _enterAndValidate(tester, harness, '6641234567'); + + expect(harness.controller.value, '+4236641234567'); + expect(isValid, isTrue); + }); + + testWidgets('decomposes a seeded +423 number without letting +43 eat it', (tester) async { + await _pumpPhoneField(tester, initialPhoneNumber: '+4236641234567'); + + final prefixEditable = tester.widget( + find.descendant(of: _prefixField(), matching: find.byType(EditableText)), + ); + final numberEditable = tester.widget( + find.descendant(of: _numberField(), matching: find.byType(EditableText)), + ); + expect(prefixEditable.controller.text, '423'); + expect(numberEditable.controller.text, '6641234567'); + }); + + testWidgets('keeps + out of the prefix field text and on prefixText', (tester) async { + final harness = await _pumpPhoneField(tester); + + await tester.enterText(_prefixField(), '41'); + await tester.enterText(_numberField(), '791234567'); + await tester.pump(); + + expect(harness.controller.value, '+41791234567'); + expect(find.text('+41'), findsNothing); + final prefixTextField = tester.widget( + find.descendant(of: _prefixField(), matching: find.byType(TextField)), + ); + expect(prefixTextField.decoration?.prefixText, '+'); + }); }); }