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 @@
37 Kauf — Freigabe-Hürden vor dem Angebot
- 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 @@ 38 Kauf — Verbindliche Bestätigung
- 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 @@
38 Kauf — Verbindliche Bestätigung
- 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.
-
+
- 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 @@ 53 KYC — Registrierung
Das geöffnete Kontotyp-Auswahlmenü im persönlichen Schritt. Das Overlay zeigt die auswählbaren Kontotypen.
-
-
-
-
-
-
- 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.
-
-
165-kyc-registration-personal-step-validation-error
@@ -8095,7 +8079,8 @@
W Web · 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 ↗
+
+
+
+
+
+
+
+ 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.
+
+
+
+
+
+
+
+
+ Derselbe Erfolgs-Zustand auf Englisch (?lang=en).
+
+
+
+
+
+
+
+
+ Erfolgs-Zustand im Tablet-Layout.
+
+
+
+
+
+
+
+
+ Erfolgs-Zustand in der Mobile-Ansicht; CTA «Zurück zur App».
+
+
+
+
+
Adresse hinzufügen — ungültiger Link ↗
+
+
+
+
+
+
+
+ Fehler-Zustand bei ungültigem oder abgelaufenem Link, Desktop,
+ Deutsch. Nutzer soll in der App eine neue Bestätigung anfordern.
+
+
+
+
+
+
+
+
+ Derselbe Fehler-Zustand auf Englisch.
+
+
+
+
+
+
+
+
+ Fehler-Zustand im Tablet-Layout.
+
+
+
+
+
+
+
+
+ Fehler-Zustand in der Mobile-Ansicht.
+
+
+
+
+
Adresse hinzufügen — bereits hinzugefügt ↗
+
+
+
+
+
+
+
+ Zustand, wenn die Wallet-Adresse dem Konto schon zugeordnet ist
+ (HTTP 409). Desktop sagt, auf dem Smartphone zur App
+ zurückzukehren. Desktop, Deutsch.
+
+
+
+
+
+
+
+
+ Derselbe Zustand auf Englisch.
+
+
+
+
+
+
+
+
+ Derselbe Zustand in der Mobile-Ansicht; CTA «Zurück zur App».
+
+
+
+
+
Adresse hinzufügen — Dienst nicht verfügbar ↗
+
+
+
+
+
+
+
+ Zustand, wenn die Adresse gerade nicht hinzugefügt werden konnte,
+ mit Retry-Button. Desktop, Deutsch.
+
+
+
+
+
+
+
+
+ Derselbe Zustand auf Englisch.
+
+
+
+
+
+
+
+
+ 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, '+');
+ });
});
}