diff --git a/.github/workflows/handbook-build-check.yaml b/.github/workflows/handbook-build-check.yaml
index 8cf961580..415cdd579 100644
--- a/.github/workflows/handbook-build-check.yaml
+++ b/.github/workflows/handbook-build-check.yaml
@@ -68,8 +68,8 @@ jobs:
set -euo pipefail
bash scripts/assemble-handbook-screenshots.sh /tmp/handbook-shots
count=$(ls -1 /tmp/handbook-shots/*.png | wc -l | tr -d ' ')
- if [ "$count" != "278" ]; then
- echo "expected 278 screenshots, got $count" >&2
+ if [ "$count" != "279" ]; then
+ echo "expected 279 screenshots, got $count" >&2
exit 1
fi
@@ -149,11 +149,11 @@ jobs:
exit 1
fi
- # Screenshots dir must contain all 278 PNGs assembled from Goldens.
+ # Screenshots dir must contain all 279 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-268 batch (every Golden
+ # Mix of the original 01-61 range and the 62-269 batch (every Golden
# baseline) so a regression in either half surfaces here.
- for name in 01-welcome 11-dashboard 26-terms 35-dashboard-with-balance 46-buy-kyc-required 52-sell-unknown-error 53-buy-payment-details 61-kyc-registration-tax-tin-error 62-welcome-page-android 219-settings-security-page-default 268-phone-number-field-default; do
+ for name in 01-welcome 11-dashboard 26-terms 35-dashboard-with-balance 46-buy-kyc-required 52-sell-unknown-error 53-buy-payment-details 61-kyc-registration-tax-tin-error 62-welcome-page-android 219-settings-security-page-default 268-phone-number-field-default 269-dashboard-insider-unlocked; do
code=$(curl -s -o /dev/null -w '%{http_code}' -u "${HANDBOOK_USER:-x}:${HANDBOOK_PASS:-x}" "http://127.0.0.1:8080/screenshots/${name}.png")
# 200 (auth happens to match) or 401 (auth fails but file exists)
# both prove the file is on disk. 404 means it was not assembled.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 90547ff1f..da7f04aac 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -53,6 +53,7 @@ The auto-opened promotion PRs are idempotent — only one is open per branch pai
- **No third-party APIs**: no direct Ethereum JSON-RPC calls (Infura, Alchemy, public nodes, etc.), no block explorer APIs (Etherscan, …), no price feeds, no analytics endpoints, no third-party SDKs that call out over the network.
- If a feature needs on-chain data (e.g. native ETH balance, transaction status, token balance), add a new endpoint to [`DFXswiss/api`](https://github.com/DFXswiss/api) and let the app call that endpoint. The API is the single gateway.
- All network calls must go through `AppStore.httpClient` with `buildUri(_host, …)` — `_host` resolves to the DFX API host via `ApiConfig`. Do not instantiate `http.Client`/`Dio`/`Web3Client` against other hosts.
+- **One scoped exception — crash reporting.** Builds that inject `--dart-define=SENTRY_DSN=...` deliver crash reports to the company-operated crash-reporting service ([`lib/setup/error_handling/crash_reporting.dart`](lib/setup/error_handling/crash_reporting.dart)). This is first-party infrastructure telemetry, not a third-party service: without an injected DSN (all local and test builds) the SDK never starts and produces no network traffic, and the delivered data is limited to error events — no PII, no screenshots, no performance tracing, no session telemetry (the exact pinned option surface lives in `crash_reporting.dart`). Widening what is sent (breadcrumbs with request URLs, user context, attachments) is a review-blocking change, not a config tweak.
## API as Decision Authority — CRITICAL
diff --git a/Dockerfile.handbook b/Dockerfile.handbook
index fd09eaa95..a312bc852 100644
--- a/Dockerfile.handbook
+++ b/Dockerfile.handbook
@@ -7,7 +7,7 @@
# Build context is the repo root; only docs/handbook/, scripts/, and
# test/goldens/ are copied in.
#
-# The 61 screenshots (`screenshots/NN-name.png`) are assembled from the
+# The handbook screenshots (`screenshots/NN-name.png`) are assembled from the
# visual-regression Golden baselines under `test/goldens/screens/` via
# scripts/assemble-handbook-screenshots.sh — one Golden per handbook
# entry, see the mapping in that script. `docs/handbook/screenshots/` is
diff --git a/README.md b/README.md
index 2575e765d..abdc459b3 100644
--- a/README.md
+++ b/README.md
@@ -89,11 +89,12 @@ The transport is USB on Android and Bluetooth on iOS; the original BitBox 02 has
| Sell — DFX fiat off-ramp (IBAN) | always | mvp | widget (`sell/sell_page_test.dart`) + golden (`sell/sell_golden_test.dart`, `sell/sell_bank_account_selection_golden_test.dart`); added via [#321](https://github.com/RealUnitCH/app/pull/321) |
| KYC: Email + 2FA gate | always | mvp | widget (`kyc_email_page_test.dart`, `kyc_2fa_page_test.dart`) + golden (`kyc/kyc_email_golden_test.dart`, `kyc/kyc_email_verification_golden_test.dart`, `kyc/kyc_2fa_golden_test.dart`); cubit added via [#319](https://github.com/RealUnitCH/app/pull/319) |
| KYC: Registration + BitBox EIP-712 sign | always | mvp | widget (`kyc_registration_page_test.dart`) + golden (`kyc/kyc_registration_golden_test.dart`) + unit (`eip712_signer_test.dart`); cubit / `registration_submit` / sign-flow integration tests added via [#319](https://github.com/RealUnitCH/app/pull/319) + [#320](https://github.com/RealUnitCH/app/pull/320) |
+| KYC: Personal data (correction after an ident data mismatch) | always | mvp | widget (`kyc_personal_data_page_test.dart`) + golden (`kyc/kyc_personal_data_golden_test.dart`) + cubit (`kyc_personal_data_cubit_test.dart`) |
| KYC: Nationality | always | mvp | widget (`kyc_nationality_page_test.dart`) + golden (`kyc/kyc_nationality_golden_test.dart`) |
| KYC: Financial data | always | mvp | widget (`kyc_financial_data_page_test.dart`) + golden (`kyc/kyc_financial_data_golden_test.dart`, `kyc/kyc_financial_data_failure_golden_test.dart`, `kyc/kyc_financial_data_loading_golden_test.dart`, `kyc/kyc_financial_data_questions_golden_test.dart`) |
| KYC: Ident | always | mvp | widget (`kyc_ident_page_test.dart`) + golden (`kyc/kyc_ident_golden_test.dart`) |
| KYC: Pending / Completed / Failure | always | mvp | widget (`kyc/subpages/kyc_*_page_test.dart`) + golden (`kyc/kyc_pending_golden_test.dart`, `kyc/kyc_completed_golden_test.dart`, `kyc/kyc_failure_golden_test.dart`, `kyc/kyc_loading_golden_test.dart`) |
-| KYC: AccountMergeRequested / UnsupportedStepFailure | always | mvp | golden (`kyc/kyc_account_merge_golden_test.dart`); cubit paths added via [#319](https://github.com/RealUnitCH/app/pull/319) |
+| KYC: AccountMergeRequested / UnsupportedStepFailure | always | mvp | widget (`kyc/subpages/kyc_unsupported_step_page_test.dart`) + golden (`kyc/kyc_account_merge_golden_test.dart`, `kyc/kyc_unsupported_step_golden_test.dart`); cubit paths added via [#319](https://github.com/RealUnitCH/app/pull/319) |
| `DFXAuthService` (lazy auth + 401 retry) | always | mvp | — (unit tests added via [#319](https://github.com/RealUnitCH/app/pull/319) + [#321](https://github.com/RealUnitCH/app/pull/321)) |
| `balance_service` (balance fetch + cache) | always | mvp | unit (`balance_service_test.dart`) |
| `format_fixed` / `parse_fixed` (decimal helpers) | always | mvp | unit (`format_fixed_test.dart`, `parse_fixed_test.dart`) |
diff --git a/assets/languages/strings_de.arb b/assets/languages/strings_de.arb
index d27b1caea..a2da73a5c 100644
--- a/assets/languages/strings_de.arb
+++ b/assets/languages/strings_de.arb
@@ -130,9 +130,12 @@
"kycMergeProcessingTitle": "Konten werden zusammengeführt",
"kycPending": "Daten werden geprüft",
"kycPendingDescription": "Ihr folgender Schritt ist gerade noch unter Prüfung: ${step}. Bitte haben Sie noch ein wenig Geduld und schauen Sie zu einem späteren Zeitpunkt nochmal rein.",
+ "kycPersonalDataDescription": "Bitte überprüfen Sie Ihre Angaben und korrigieren Sie alles, was nicht mit Ihrem Ausweisdokument übereinstimmt.",
+ "kycPersonalDataFailed": "Persönliche Daten konnten nicht gespeichert werden:\n${message}",
"kycSignatureUnsupportedDescription": "Dieses Feature erfordert eine EIP-712-Signatur. Im Debug-Modus (Adresse + Signatur) ist dies technisch nicht möglich. Bitte verwenden Sie eine Software-Wallet oder BitBox, um RealUnit zu nutzen.",
"kycSignatureUnsupportedTitle": "Signatur nicht verfügbar",
- "kycUnsupportedStepDescription": "Der aktuelle KYC-Schritt (${step}) kann in dieser App nicht abgeschlossen werden. Bitte kontaktieren Sie den Support.",
+ "kycUnsupportedStepDescription": "Der nächste Schritt Ihrer Verifizierung kann in dieser App nicht abgeschlossen werden. Aktualisieren Sie, um zu prüfen, ob er weitergelaufen ist, oder wenden Sie sich an unser Support-Team, das dann übernimmt.",
+ "kycUnsupportedStepTitle": "Dieser Schritt wird anderswo fortgesetzt",
"label": "Bezeichnung",
"languageEnglish": "Englisch",
"languageGerman": "Deutsch",
@@ -160,7 +163,6 @@
"legalDocumentLoadFailedDescription": "Beim Laden des Dokuments ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.",
"legalDocuments": "Rechtsdokumente",
"location": "Ort",
- "logout": "Abmelden",
"max": "Max",
"month": "Monat",
"name": "Name",
@@ -372,6 +374,7 @@
"settingsCurrencyLoadFailed": "Währungsliste konnte nicht geladen werden",
"settingsCurrencyLoadFailedDescription": "Bitte überprüfen Sie Ihre Internetverbindung und versuchen Sie es erneut.",
"settingsDeleteWallet": "Wallet zurücksetzen",
+ "settingsInsiderFeaturesUnlocked": "Insider-Funktionen freigeschaltet",
"settingsLanguageLoadFailed": "Sprachliste konnte nicht geladen werden",
"settingsLanguageLoadFailedDescription": "Bitte überprüfen Sie Ihre Internetverbindung und versuchen Sie es erneut.",
"settingsLanguages": "Sprachen",
@@ -461,4 +464,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 913f4061a..5b9dab60a 100644
--- a/assets/languages/strings_en.arb
+++ b/assets/languages/strings_en.arb
@@ -130,9 +130,12 @@
"kycMergeProcessingTitle": "Merging your accounts",
"kycPending": "Data is being verified",
"kycPendingDescription": "Your next step is currently being reviewed: ${step}. Please be patient and check back later.",
+ "kycPersonalDataDescription": "Please check your details and correct anything that does not match your identity document.",
+ "kycPersonalDataFailed": "Could not save your personal data:\n${message}",
"kycSignatureUnsupportedDescription": "This feature requires an EIP-712 signature. The Debug mode (address + signature) cannot produce one. Please use a Software Wallet or a BitBox to use RealUnit.",
"kycSignatureUnsupportedTitle": "Signature not available",
- "kycUnsupportedStepDescription": "The current KYC step (${step}) cannot be completed in this app. Please contact support.",
+ "kycUnsupportedStepDescription": "The next step of your verification cannot be completed in this app. Refresh to check whether it has moved on, or contact our support team and they will take it from here.",
+ "kycUnsupportedStepTitle": "This step continues elsewhere",
"label": "Label",
"languageEnglish": "English",
"languageGerman": "German",
@@ -160,7 +163,6 @@
"legalDocumentLoadFailedDescription": "Something went wrong while loading this document. Please try again.",
"legalDocuments": "Legal documents",
"location": "Location",
- "logout": "Logout",
"max": "Max",
"month": "Month",
"name": "Name",
@@ -372,6 +374,7 @@
"settingsCurrencyLoadFailed": "Failed to load currencies",
"settingsCurrencyLoadFailedDescription": "Please check your internet connection and try again.",
"settingsDeleteWallet": "Reset wallet",
+ "settingsInsiderFeaturesUnlocked": "Insider features unlocked",
"settingsLanguageLoadFailed": "Failed to load languages",
"settingsLanguageLoadFailedDescription": "Please check your internet connection and try again.",
"settingsLanguages": "Languages",
@@ -461,4 +464,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 8eb55d72c..a5b903078 100644
--- a/docs/handbook/README.md
+++ b/docs/handbook/README.md
@@ -21,7 +21,7 @@ deployten Image (`handbook.realunit.app` / `dev-handbook.realunit.app`).
## Screenshots regenerieren
-Es gibt keinen separaten Regeneration-Schritt: Die 278 Handbook-Screenshots
+Es gibt keinen separaten Regeneration-Schritt: Die 279 Handbook-Screenshots
sind direkt die Golden-Baselines unter `test/goldens/` (gemappt in
`scripts/assemble-handbook-screenshots.sh`). Eine UI-Änderung an einer der
gemappten Pages produziert beim `flutter test test/goldens` einen Diff —
diff --git a/docs/handbook/de/index.html b/docs/handbook/de/index.html
index b9aaa3a28..a9c2e6351 100644
--- a/docs/handbook/de/index.html
+++ b/docs/handbook/de/index.html
@@ -472,6 +472,10 @@
justify-content: center;
padding: 14px;
}
+ .test .img.img-multi {
+ flex-wrap: wrap;
+ gap: 12px;
+ }
.test .img img {
max-width: 220px;
height: auto;
@@ -1002,6 +1006,9 @@
78 Telefonnummer-Eingabe
+
+ 79 Insider-Freischaltung — Bezahlen & Senden
+
W Web · realunit.app
@@ -7942,6 +7949,106 @@ 78 Telefonnummer-Eingabe
+
+
+
+
+
79 Insider-Freischaltung — Bezahlen & Senden
+
test/goldens/screens/dashboard/
+
+
+
+
+
+ Die Funktionen Bezahlen (OpenCryptoPay) und Senden (REALU-Transfer
+ an eine andere Wallet) sind bewusst versteckt (Soft-Launch) und erscheinen erst
+ nach einer einmaligen Freischaltung auf dem Gerät.
+
+
+
+
+
+
insider-unlock
+
🔗 Link
+
settings_page_default.png · dashboard_with_balance.png · dashboard_insider_unlocked.png
+
+
+
+
+
Einstellungen — die Versionsnummer ganz unten ist der Tap-Auslöser.
+
+
+
+
Vorher: nur „Kaufen" und „Verkaufen".
+
+
+
+
Nachher: alle vier Aktionen.
+
+
+
+
Anleitung:
+
+ Einstellungen öffnen (Hamburger-Icon rechts oben im Dashboard).
+ Ganz nach unten scrollen bis zur Versionsnummer.
+ Siebenmal kurz hintereinander auf die Versionsnummer tippen.
+ Es erscheint die Bestätigung „Insider-Funktionen freigeschaltet".
+
+ Zurück im Dashboard stehen neben Kaufen und Verkaufen neu
+ Bezahlen und Senden .
+
+
+
+ Die Freischaltung bleibt dauerhaft auf dem Gerät erhalten (auch nach
+ App-Neustart) — sie wird lokal per SharedPreferences
+ gespeichert. Eine Neuinstallation der App setzt sie zurück.
+ OpenCryptoPay-Zahlungslinks funktionieren unabhängig von der
+ Freischaltung — der Deeplink bleibt bewusst immer offen. Tippen auf die
+ Versionsnummer nach erfolgter Freischaltung hat keine Wirkung mehr.
+
+
+
+
+
+
+
+
+
+ Dashboard mit freigeschalteter Insider-Ansicht: neben Kaufen und
+ Verkaufen erscheinen zusätzlich Bezahlen und Senden .
+ Golden-Quelle: dashboard_insider_unlocked.png aus
+ dashboard_golden_test.dart.
+
+
+
+
+
+
+
diff --git a/docs/screens.md b/docs/screens.md
index 9784edae2..aceea585c 100644
--- a/docs/screens.md
+++ b/docs/screens.md
@@ -16,7 +16,7 @@ Column meaning:
screen, or `—` if the screen has no Golden baseline. Each slot is a
Visual-Regression Golden under `test/goldens/`, mapped to its handbook
position by `scripts/assemble-handbook-screenshots.sh`. The handbook now
- covers **all 278 Golden baselines** — every screen **plus every tested
+ covers **all 279 Golden baselines** — every screen **plus every tested
state variant** (Default / Loading / Error / Snackbar / Dropdown /
Validation / Confirm / Success / Failure …), including the areas that were
previously absent: Support (email capture, tickets, chat), Settings
@@ -136,7 +136,7 @@ one shared form-widget baseline (`PhoneNumberField`), which is not a screen.
`243`–`245`. `SetupPinPage` also backs the `settingsChangePin` route
(`/settings/security/changePin`) via a second constructor; that reuse has no
separate Golden and is not given its own row.
-- **Handbook numbering.** Each of the 278 handbook slots is a Visual-Regression
+- **Handbook numbering.** Each of the 279 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/main.dart b/lib/main.dart
index cb13b4100..47c34073c 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -9,6 +9,7 @@ import 'package:realunit_wallet/screens/home/bloc/home_bloc.dart';
import 'package:realunit_wallet/screens/pin/bloc/auth/pin_auth_cubit.dart';
import 'package:realunit_wallet/screens/settings/bloc/settings_bloc.dart';
import 'package:realunit_wallet/setup/di.dart';
+import 'package:realunit_wallet/setup/error_handling/crash_reporting.dart';
import 'package:realunit_wallet/setup/error_handling/error_handlers.dart';
import 'package:realunit_wallet/setup/lifecycle_initializer.dart';
import 'package:realunit_wallet/setup/routing/boot_navigation.dart';
@@ -19,10 +20,15 @@ Future
main() async {
final widgetsBinding = WidgetsFlutterBinding.ensureInitialized();
installErrorHandlers();
+ // Preserve before the first await: an async gap ahead of preserve() would
+ // let the native splash auto-dismiss and flash to blank.
+ if (kReleaseMode) FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding);
+ // After installErrorHandlers on purpose: the reporter chains the handlers
+ // installed above, while installErrorHandlers overwrites the async hook.
+ await initCrashReporting();
// only preserve splash screen for 3 seconds for release version
if (kReleaseMode) {
- FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding);
await _initializeWithSplashDuration();
FlutterNativeSplash.remove();
} else {
diff --git a/lib/packages/repository/settings_repository.dart b/lib/packages/repository/settings_repository.dart
index 79d513408..7031b8ada 100644
--- a/lib/packages/repository/settings_repository.dart
+++ b/lib/packages/repository/settings_repository.dart
@@ -47,4 +47,9 @@ class SettingsRepository {
set softwareTermsAccepted(bool accepted) =>
_sharedPreferences.setBool('softwareTermsAccepted', accepted);
+
+ bool get insiderFeaturesUnlocked =>
+ _sharedPreferences.getBool('insiderFeaturesUnlocked') ?? false;
+ set insiderFeaturesUnlocked(bool unlocked) =>
+ _sharedPreferences.setBool('insiderFeaturesUnlocked', unlocked);
}
diff --git a/lib/packages/service/dfx/models/wallet/real_unit_registration_info_dto.dart b/lib/packages/service/dfx/models/wallet/real_unit_registration_info_dto.dart
index 1a83a9a16..11743d74d 100644
--- a/lib/packages/service/dfx/models/wallet/real_unit_registration_info_dto.dart
+++ b/lib/packages/service/dfx/models/wallet/real_unit_registration_info_dto.dart
@@ -4,9 +4,15 @@ import 'package:realunit_wallet/packages/service/dfx/models/wallet/real_unit_reg
class RealUnitRegistrationInfoDto {
/// API-side routing decision for this wallet. Drives KYC dispatch in
/// `KycCubit._runCheckKyc` — see CONTRIBUTING.md "API as Decision
- /// Authority". `userData` is populated for `addWallet` (prior payload)
- /// and `newRegistration` (KYC pre-fill); `null` for `alreadyRegistered`
- /// (no UX needed).
+ /// Authority". `userData` is populated whenever a registration row exists —
+ /// `alreadyRegistered` and `addWallet` both carry the stored signed payload —
+ /// and for `newRegistration` when DFX KYC data can pre-fill the form. It is
+ /// `null` only when the stored registration has no signed payload, or when
+ /// there is no verified personal data to pre-fill from.
+ ///
+ /// `alreadyRegistered` is NOT a no-payload case: the personal-data KYC step
+ /// is reached only through that branch and seeds its correction form from
+ /// this payload, so treating it as null would dead-end that step.
final RealUnitRegistrationState state;
final RealUnitUserDataDto? realUnitUserDataDto;
diff --git a/lib/screens/dashboard/widgets/sections/dashboard_actions.dart b/lib/screens/dashboard/widgets/sections/dashboard_actions.dart
index 0c64a6a60..0e5076a06 100644
--- a/lib/screens/dashboard/widgets/sections/dashboard_actions.dart
+++ b/lib/screens/dashboard/widgets/sections/dashboard_actions.dart
@@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:realunit_wallet/generated/i18n.dart';
+import 'package:realunit_wallet/screens/settings/bloc/settings_bloc.dart';
import 'package:realunit_wallet/setup/routing/routes/app_routes.dart';
import 'package:realunit_wallet/styles/colors.dart';
import 'package:realunit_wallet/widgets/action_button.dart';
@@ -10,6 +12,9 @@ class DashboardActions extends StatelessWidget {
@override
Widget build(BuildContext context) {
+ final insiderFeaturesUnlocked =
+ context.watch().state.insiderFeaturesUnlocked;
+
return Row(
spacing: 10,
children: [
@@ -35,28 +40,30 @@ class DashboardActions extends StatelessWidget {
onPressed: () => context.pushNamed(AppRoutes.sell),
),
),
- Expanded(
- child: ActionButton(
- icon: Icon(
- Icons.qr_code_scanner_rounded,
- color: RealUnitColors.basic.white,
- size: 20,
+ if (insiderFeaturesUnlocked)
+ Expanded(
+ child: ActionButton(
+ icon: Icon(
+ Icons.qr_code_scanner_rounded,
+ color: RealUnitColors.basic.white,
+ size: 20,
+ ),
+ label: S.of(context).pay,
+ onPressed: () => context.pushNamed(AppRoutes.pay),
),
- label: S.of(context).pay,
- onPressed: () => context.pushNamed(AppRoutes.pay),
),
- ),
- Expanded(
- child: ActionButton(
- icon: Icon(
- Icons.send_rounded,
- color: RealUnitColors.basic.white,
- size: 20,
+ if (insiderFeaturesUnlocked)
+ Expanded(
+ child: ActionButton(
+ icon: Icon(
+ Icons.send_rounded,
+ color: RealUnitColors.basic.white,
+ size: 20,
+ ),
+ label: S.of(context).send,
+ onPressed: () => context.pushNamed(AppRoutes.send),
),
- label: S.of(context).send,
- onPressed: () => context.pushNamed(AppRoutes.send),
),
- ),
],
);
}
diff --git a/lib/screens/kyc/cubits/kyc/kyc_cubit.dart b/lib/screens/kyc/cubits/kyc/kyc_cubit.dart
index 89e3c50e0..779131da3 100644
--- a/lib/screens/kyc/cubits/kyc/kyc_cubit.dart
+++ b/lib/screens/kyc/cubits/kyc/kyc_cubit.dart
@@ -256,7 +256,7 @@ class KycCubit extends Cubit {
emit(KycPending(step));
return;
case KycProcessStatus.inProgress:
- await _continueKyc(generation);
+ await _continueKyc(generation, registrationInfo.realUnitUserDataDto);
return;
case KycProcessStatus.mergeProcessing:
// The user confirmed a merge and the backend is still processing it.
@@ -311,7 +311,7 @@ class KycCubit extends Cubit {
}
/// should only be called after realunit registration was completed
- Future _continueKyc(int generation) async {
+ Future _continueKyc(int generation, [RealUnitUserDataDto? realUnitUserData]) async {
final kycStatus = await _kycService.continueKyc(context: _kycContext);
if (isClosed || generation != _runGeneration) return;
@@ -338,12 +338,14 @@ class KycCubit extends Cubit {
KycSuccess(
currentStep: kycStep,
urlOrToken: currentStep.session.url,
+ realUnitUserData: realUnitUserData,
),
);
}
KycStep? _mapStepName(KycStepName name) => switch (name) {
KycStepName.contactData => KycStep.registration,
+ KycStepName.personalData => KycStep.personalData,
KycStepName.nationalityData => KycStep.nationality,
KycStepName.ident => KycStep.ident,
KycStepName.financialData => KycStep.financialData,
diff --git a/lib/screens/kyc/cubits/kyc/kyc_state.dart b/lib/screens/kyc/cubits/kyc/kyc_state.dart
index b5d0d47b1..dac1f5126 100644
--- a/lib/screens/kyc/cubits/kyc/kyc_state.dart
+++ b/lib/screens/kyc/cubits/kyc/kyc_state.dart
@@ -4,6 +4,7 @@ enum KycStep {
email,
confirmEmail,
registration,
+ personalData,
linkWallet,
legalDisclaimer,
nationality,
@@ -43,7 +44,9 @@ class KycSuccess extends KycState {
/// Server-side user record attached to the routing decision. Populated when
/// `RealUnitRegistrationService.getRegistrationInfo()` returns userData
- /// alongside the state (`AddWallet` always, `NewRegistration` when the
+ /// alongside the state (`AddWallet` always, `AlreadyRegistered` whenever the
+ /// registration carries a signed payload — which is what the `personalData`
+ /// step seeds its correction form from — and `NewRegistration` when the
/// backend has fallback data). The cubit forwards the DTO so downstream
/// pages do not need to re-fetch — see CONTRIBUTING.md "Single round-trip
/// per decision".
diff --git a/lib/screens/kyc/kyc_page_manager.dart b/lib/screens/kyc/kyc_page_manager.dart
index c5ae9013e..4f75ca0a1 100644
--- a/lib/screens/kyc/kyc_page_manager.dart
+++ b/lib/screens/kyc/kyc_page_manager.dart
@@ -1,9 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
-import 'package:realunit_wallet/generated/i18n.dart';
import 'package:realunit_wallet/packages/service/app_store.dart';
import 'package:realunit_wallet/packages/service/dfx/dfx_kyc_service.dart';
-import 'package:realunit_wallet/packages/service/dfx/models/kyc/kyc_level.dart';
import 'package:realunit_wallet/packages/service/dfx/real_unit_legal_service.dart';
import 'package:realunit_wallet/packages/service/dfx/real_unit_registration_service.dart';
import 'package:realunit_wallet/screens/kyc/cubits/kyc/kyc_cubit.dart';
@@ -14,6 +12,7 @@ import 'package:realunit_wallet/screens/kyc/steps/financial_data/kyc_financial_d
import 'package:realunit_wallet/screens/kyc/steps/ident/kyc_ident_page.dart';
import 'package:realunit_wallet/screens/kyc/steps/link_wallet/kyc_link_wallet_page.dart';
import 'package:realunit_wallet/screens/kyc/steps/nationality/kyc_nationality_page.dart';
+import 'package:realunit_wallet/screens/kyc/steps/personal_data/kyc_personal_data_page.dart';
import 'package:realunit_wallet/screens/kyc/steps/registration/kyc_registration_page.dart';
import 'package:realunit_wallet/screens/kyc/steps/signature_unsupported/kyc_signature_unsupported_page.dart';
import 'package:realunit_wallet/screens/kyc/subpages/kyc_account_merge_page.dart';
@@ -23,6 +22,7 @@ import 'package:realunit_wallet/screens/kyc/subpages/kyc_loading_page.dart';
import 'package:realunit_wallet/screens/kyc/subpages/kyc_manual_review_page.dart';
import 'package:realunit_wallet/screens/kyc/subpages/kyc_merge_processing_page.dart';
import 'package:realunit_wallet/screens/kyc/subpages/kyc_pending_page.dart';
+import 'package:realunit_wallet/screens/kyc/subpages/kyc_unsupported_step_page.dart';
import 'package:realunit_wallet/screens/legal/legal_disclaimer_page.dart';
import 'package:realunit_wallet/setup/di.dart';
@@ -55,9 +55,7 @@ class KycViewManager extends StatelessWidget {
KycLoading() => const KycLoadingPage(),
KycFailure(:final message) => KycFailurePage(message: message),
KycSignatureUnsupportedFailure() => const KycSignatureUnsupportedPage(),
- KycUnsupportedStepFailure(:final stepName) => KycFailurePage(
- message: S.of(context).kycUnsupportedStepDescription(stepName?.value ?? '-'),
- ),
+ KycUnsupportedStepFailure() => const KycUnsupportedStepPage(),
KycAccountMergeRequested() => const KycAccountMergePage(),
KycMergeProcessing() => const KycMergeProcessingPage(),
KycManualReview() => const KycManualReviewPage(),
@@ -75,6 +73,10 @@ class KycViewManager extends StatelessWidget {
},
),
KycStep.registration => KycRegistrationPage(initialUserData: realUnitUserData),
+ KycStep.personalData => KycPersonalDataPage(
+ url: urlOrToken ?? '',
+ initialUserData: realUnitUserData,
+ ),
KycStep.linkWallet => KycLinkWalletPage(userData: realUnitUserData),
KycStep.nationality => KycNationalityPage(url: urlOrToken ?? ''),
KycStep.twoFa => const Kyc2FaPage(),
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
new file mode 100644
index 000000000..d4aab5fe3
--- /dev/null
+++ b/lib/screens/kyc/steps/personal_data/cubit/kyc_personal_data/kyc_personal_data_cubit.dart
@@ -0,0 +1,55 @@
+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/models/country/country.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/registration/kyc/kyc_personal_data.dart';
+
+part 'kyc_personal_data_state.dart';
+
+class KycPersonalDataCubit extends Cubit {
+ final DfxKycService _kycService;
+
+ KycPersonalDataCubit(DfxKycService kycService)
+ : _kycService = kycService,
+ super(const KycPersonalDataInitial());
+
+ Future submit({
+ required String url,
+ required KycAccountType accountType,
+ required String firstName,
+ required String lastName,
+ required String phone,
+ required String street,
+ required String houseNumber,
+ required String zip,
+ required String city,
+ required Country country,
+ }) async {
+ try {
+ emit(const KycPersonalDataLoading());
+ await _kycService.setData(
+ url,
+ KycPersonalData(
+ // Passed in, never assumed: the page refuses to render for anything but a personal
+ // account, and sending the same value it checked keeps the two from drifting apart.
+ accountType: accountType,
+ firstName: firstName,
+ lastName: lastName,
+ phone: phone,
+ address: KycAddress(
+ street: street,
+ // Always sent. The form requires it, and an omitted key would leave the stored value
+ // unchanged rather than clearing it — wrong for a form whose purpose is correction.
+ houseNumber: houseNumber,
+ zip: zip,
+ city: city,
+ country: country.id,
+ ),
+ ).toJson(),
+ );
+ emit(const KycPersonalDataSuccess());
+ } catch (e) {
+ emit(KycPersonalDataFailure(e.toString()));
+ }
+ }
+}
diff --git a/lib/screens/kyc/steps/personal_data/cubit/kyc_personal_data/kyc_personal_data_state.dart b/lib/screens/kyc/steps/personal_data/cubit/kyc_personal_data/kyc_personal_data_state.dart
new file mode 100644
index 000000000..41e2fb90f
--- /dev/null
+++ b/lib/screens/kyc/steps/personal_data/cubit/kyc_personal_data/kyc_personal_data_state.dart
@@ -0,0 +1,29 @@
+part of 'kyc_personal_data_cubit.dart';
+
+abstract class KycPersonalDataState extends Equatable {
+ const KycPersonalDataState();
+
+ @override
+ List get props => [];
+}
+
+class KycPersonalDataInitial extends KycPersonalDataState {
+ const KycPersonalDataInitial();
+}
+
+class KycPersonalDataLoading extends KycPersonalDataState {
+ const KycPersonalDataLoading();
+}
+
+class KycPersonalDataSuccess extends KycPersonalDataState {
+ const KycPersonalDataSuccess();
+}
+
+class KycPersonalDataFailure extends KycPersonalDataState {
+ final String message;
+
+ const KycPersonalDataFailure(this.message);
+
+ @override
+ List get props => [message];
+}
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
new file mode 100644
index 000000000..f5994ba3a
--- /dev/null
+++ b/lib/screens/kyc/steps/personal_data/kyc_personal_data_page.dart
@@ -0,0 +1,335 @@
+import 'dart:async';
+
+import 'package:flutter/material.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:realunit_wallet/generated/i18n.dart';
+import 'package:realunit_wallet/packages/service/dfx/dfx_country_service.dart';
+import 'package:realunit_wallet/packages/service/dfx/dfx_kyc_service.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/packages/service/dfx/models/user/dto/real_unit_user_data_dto.dart';
+import 'package:realunit_wallet/packages/utils/swiss_payment_text.dart';
+import 'package:realunit_wallet/screens/kyc/cubits/kyc/kyc_cubit.dart';
+import 'package:realunit_wallet/screens/kyc/steps/personal_data/cubit/kyc_personal_data/kyc_personal_data_cubit.dart';
+import 'package:realunit_wallet/screens/kyc/subpages/kyc_unsupported_step_page.dart';
+import 'package:realunit_wallet/setup/di.dart';
+import 'package:realunit_wallet/styles/colors.dart';
+import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart';
+import 'package:realunit_wallet/widgets/form/country_field.dart';
+import 'package:realunit_wallet/widgets/form/labeled_text_field.dart';
+import 'package:realunit_wallet/widgets/form/phone_number_field.dart';
+import 'package:realunit_wallet/widgets/scrollable_actions_layout.dart';
+
+/// The PersonalData KYC step.
+///
+/// Registration normally satisfies this step without the user ever seeing it. It surfaces when the
+/// step was re-opened deliberately — identification rejected the submitted data as not matching the
+/// document — and the account has to correct it before identification can be retried. Until this
+/// page existed the API asked for a step the app could not render, and onboarding dead-ended.
+class KycPersonalDataPage extends StatelessWidget {
+ final String url;
+ final RealUnitUserDataDto? initialUserData;
+
+ const KycPersonalDataPage({super.key, required this.url, this.initialUserData});
+
+ @override
+ Widget build(BuildContext context) {
+ // The form can only express a personal account: submitting it sets `accountType` on the
+ // account, and the API nulls every organization field whenever that value is `Personal`. So an
+ // organization or sole-proprietorship account must never be offered this form — it would destroy
+ // its organization data and drop the org-only steps from its required set.
+ final userData = initialUserData;
+ if (userData == null) {
+ // Transient: the registration row carries no signed payload yet. Gets the dedicated
+ // missing-payload surface, mirroring KycLinkWalletPage — distinct from the shared handoff an
+ // unsupported account type gets below.
+ return const _PersonalDataMissingUserDataPage();
+ }
+ if (userData.kycData.accountType != KycAccountType.personal) {
+ // Same answer the app gives for any step it cannot render: an actionable handoff rather than a
+ // dead end, and no internal step identifier surfaced to the user.
+ return const KycUnsupportedStepPage();
+ }
+
+ return BlocProvider(
+ create: (_) => KycPersonalDataCubit(getIt()),
+ child: KycPersonalDataView(url: url, initialUserData: userData),
+ );
+ }
+}
+
+class KycPersonalDataView extends StatefulWidget {
+ final String url;
+ final RealUnitUserDataDto initialUserData;
+
+ const KycPersonalDataView({super.key, required this.url, required this.initialUserData});
+
+ @override
+ State createState() => _KycPersonalDataViewState();
+}
+
+class _KycPersonalDataViewState extends State {
+ final _formKey = GlobalKey();
+ final firstNameCtrl = TextEditingController();
+ final lastNameCtrl = TextEditingController();
+ final phoneCtrl = ValueNotifier(null);
+ final streetCtrl = TextEditingController();
+ final houseNumberCtrl = TextEditingController();
+ final zipCtrl = TextEditingController();
+ final cityCtrl = TextEditingController();
+ final countryCtrl = ValueNotifier(null);
+ Country? _initialCountry;
+
+ @override
+ void initState() {
+ super.initState();
+
+ // This is a correction form, not a fresh capture: the copy asks the user to check their details,
+ // and every submit rewrites all of them, so shipping it empty would force a from-memory re-entry
+ // and let a typo overwrite data that was already correct. Seeded from the payload the parent
+ // cubit already fetched — no extra round-trip. Same shape as KycRegistrationView.initState.
+ final kycData = widget.initialUserData.kycData;
+ firstNameCtrl.text = kycData.firstName;
+ lastNameCtrl.text = kycData.lastName;
+ phoneCtrl.value = kycData.phone;
+ streetCtrl.text = kycData.address.street;
+ houseNumberCtrl.text = kycData.address.houseNumber ?? '';
+ zipCtrl.text = kycData.address.zip;
+ cityCtrl.text = kycData.address.city;
+
+ // The DTO carries only the country id, so the field populates when the lookup resolves; the
+ // form renders immediately either way.
+ unawaited(_resolveInitialCountry(kycData.address.country));
+ }
+
+ Future _resolveInitialCountry(int countryId) async {
+ try {
+ final countries = await getIt().getAllCountries();
+ if (!mounted) return;
+
+ final country = countries.where((c) => c.id == countryId).firstOrNull;
+ // Never clobber a pick the user already made: CountryField runs its own lookup, and if that one
+ // resolves first the field is live before this seed arrives.
+ if (country == null || countryCtrl.value != null) return;
+
+ setState(() => _initialCountry = country);
+ countryCtrl.value = country;
+ } catch (_) {
+ // Degrade to an empty picker — CountryField renders its own error and retry.
+ }
+ }
+
+ String? _required(String? value) {
+ if (value == null || value.isEmpty) return '';
+ if (!isSwissPaymentText(value)) return S.of(context).swissPaymentTextInvalid;
+ return null;
+ }
+
+ void _submit() {
+ FocusManager.instance.primaryFocus?.unfocus();
+ // CountryField registers a validator with the enclosing Form, so a successful validate()
+ // guarantees countryCtrl is set. phoneCtrl is guaranteed by PhoneNumberField itself: it defaults
+ // to its first prefix when unseeded and only accepts a seeded value it can decompose.
+ if (!(_formKey.currentState?.validate() ?? false)) return;
+
+ context.read().submit(
+ url: widget.url,
+ accountType: widget.initialUserData.kycData.accountType,
+ firstName: firstNameCtrl.text,
+ lastName: lastNameCtrl.text,
+ phone: phoneCtrl.value!,
+ street: streetCtrl.text,
+ houseNumber: houseNumberCtrl.text,
+ zip: zipCtrl.text,
+ city: cityCtrl.text,
+ country: countryCtrl.value!,
+ );
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ appBar: AppBar(title: Text(S.of(context).personalData)),
+ body: BlocListener(
+ listener: (context, state) {
+ if (state is KycPersonalDataSuccess) {
+ // The API decides what comes next; re-reading it is what moves the flow on.
+ context.read().checkKyc();
+ }
+ if (state is KycPersonalDataFailure) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Text(S.of(context).kycPersonalDataFailed(state.message)),
+ backgroundColor: RealUnitColors.status.red600,
+ ),
+ );
+ }
+ },
+ child: SingleChildScrollView(
+ padding: const EdgeInsets.symmetric(horizontal: 20),
+ child: SafeArea(
+ child: GestureDetector(
+ onTap: () => FocusManager.instance.primaryFocus?.unfocus(),
+ behavior: HitTestBehavior.opaque,
+ child: Form(
+ key: _formKey,
+ child: Column(
+ spacing: 16,
+ children: [
+ Padding(
+ padding: const EdgeInsets.only(top: 8.0),
+ child: Text(S.of(context).kycPersonalDataDescription),
+ ),
+ Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ spacing: 10,
+ children: [
+ Expanded(
+ child: LabeledTextField(
+ label: S.of(context).firstName,
+ hintText: 'Max',
+ controller: firstNameCtrl,
+ textCapitalization: TextCapitalization.words,
+ validator: _required,
+ ),
+ ),
+ Expanded(
+ child: LabeledTextField(
+ label: S.of(context).lastName,
+ hintText: 'Mustermann',
+ controller: lastNameCtrl,
+ textCapitalization: TextCapitalization.words,
+ validator: _required,
+ ),
+ ),
+ ],
+ ),
+ PhoneNumberField(controller: phoneCtrl),
+ Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ spacing: 10,
+ children: [
+ Expanded(
+ flex: 2,
+ child: LabeledTextField(
+ hintText: S.of(context).streetHint,
+ controller: streetCtrl,
+ label: S.of(context).street,
+ keyboardType: TextInputType.streetAddress,
+ textCapitalization: TextCapitalization.words,
+ validator: _required,
+ ),
+ ),
+ Expanded(
+ child: LabeledTextField(
+ hintText: '13',
+ controller: houseNumberCtrl,
+ label: S.of(context).number,
+ keyboardType: TextInputType.streetAddress,
+ validator: _required,
+ ),
+ ),
+ ],
+ ),
+ Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ spacing: 10,
+ children: [
+ Expanded(
+ flex: 2,
+ child: LabeledTextField(
+ hintText: '8000',
+ controller: zipCtrl,
+ label: S.of(context).postcodeAbr,
+ // Alphanumeric in many residence countries (NL "1011 AB", UK "EC1A 1BB"),
+ // so not a number-only keyboard — mirrors the registration address step.
+ keyboardType: TextInputType.text,
+ validator: _required,
+ ),
+ ),
+ Expanded(
+ flex: 3,
+ child: LabeledTextField(
+ hintText: S.of(context).cityHint,
+ controller: cityCtrl,
+ label: S.of(context).city,
+ keyboardType: TextInputType.text,
+ textCapitalization: TextCapitalization.words,
+ validator: _required,
+ ),
+ ),
+ ],
+ ),
+ CountryField(
+ label: S.of(context).country,
+ purpose: CountryFieldPurpose.residence,
+ initialValue: _initialCountry,
+ onChanged: (country) => countryCtrl.value = country,
+ ),
+ Padding(
+ padding: const EdgeInsets.symmetric(vertical: 16.0),
+ child: BlocBuilder(
+ builder: (context, state) {
+ return AppFilledButton(
+ state: state is KycPersonalDataLoading ? .loading : .idle,
+ onPressed: _submit,
+ label: S.of(context).next,
+ );
+ },
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+
+ @override
+ void dispose() {
+ firstNameCtrl.dispose();
+ lastNameCtrl.dispose();
+ phoneCtrl.dispose();
+ streetCtrl.dispose();
+ houseNumberCtrl.dispose();
+ zipCtrl.dispose();
+ cityCtrl.dispose();
+ countryCtrl.dispose();
+ super.dispose();
+ }
+}
+
+/// Shown when the step is actionable but the registration row carries no signed payload to seed the
+/// form from. Transient, so it offers a refresh rather than dead-ending.
+class _PersonalDataMissingUserDataPage extends StatelessWidget {
+ const _PersonalDataMissingUserDataPage();
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ appBar: AppBar(title: Text(S.of(context).personalData)),
+ body: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 20.0),
+ child: SafeArea(
+ child: ScrollableActionsLayout(
+ centerBody: true,
+ body: Text(
+ S.of(context).kycFailure,
+ style: Theme.of(context).textTheme.bodyMedium,
+ textAlign: TextAlign.center,
+ ),
+ actions: [
+ AppFilledButton(
+ onPressed: () => context.read().checkKyc(),
+ label: S.of(context).refresh,
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/screens/kyc/subpages/kyc_unsupported_step_page.dart b/lib/screens/kyc/subpages/kyc_unsupported_step_page.dart
new file mode 100644
index 000000000..675d8f720
--- /dev/null
+++ b/lib/screens/kyc/subpages/kyc_unsupported_step_page.dart
@@ -0,0 +1,67 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:go_router/go_router.dart';
+import 'package:realunit_wallet/generated/i18n.dart';
+import 'package:realunit_wallet/screens/kyc/cubits/kyc/kyc_cubit.dart';
+import 'package:realunit_wallet/styles/colors.dart';
+import 'package:realunit_wallet/setup/routing/routes/support_routes.dart';
+import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart';
+import 'package:realunit_wallet/widgets/buttons/app_text_button.dart';
+import 'package:realunit_wallet/widgets/scrollable_actions_layout.dart';
+
+/// Shown when the API asks for a KYC step this app has no screen for.
+///
+/// The app renders a subset of the step names the API can return, so this is reachable whenever
+/// verification routes an account down a branch the app does not implement. It used to render the
+/// generic failure page — no actions, and the raw wire identifier of the step printed into the
+/// message — which left the user with nothing to do and nothing to tell support.
+///
+/// Deliberately does not name the step: the identifier is an internal enum value, not something a
+/// user can act on. Offering a retry matters because the state is not always terminal — the API can
+/// move the account on by itself (an internal review completing, a step expiring), and the retry
+/// re-reads it rather than stranding the user on a screen that will never change on its own.
+class KycUnsupportedStepPage extends StatelessWidget {
+ const KycUnsupportedStepPage({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ appBar: AppBar(title: Text(S.of(context).kyc)),
+ body: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 20.0),
+ child: SafeArea(
+ child: ScrollableActionsLayout(
+ centerBody: true,
+ body: Column(
+ spacing: 8.0,
+ children: [
+ Text(
+ S.of(context).kycUnsupportedStepTitle,
+ style: Theme.of(context).textTheme.headlineMedium,
+ textAlign: TextAlign.center,
+ ),
+ Text(
+ S.of(context).kycUnsupportedStepDescription,
+ textAlign: TextAlign.center,
+ style: Theme.of(context).textTheme.bodyMedium?.copyWith(
+ color: RealUnitColors.neutral500,
+ ),
+ ),
+ ],
+ ),
+ actions: [
+ AppFilledButton(
+ onPressed: () => context.read().checkKyc(),
+ label: S.of(context).refresh,
+ ),
+ AppTextButton(
+ onPressed: () => context.pushNamed(SupportRoutes.support),
+ label: S.of(context).contactSupport,
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/screens/settings/bloc/settings_bloc.dart b/lib/screens/settings/bloc/settings_bloc.dart
index 3c57a539c..50bc0fbdb 100644
--- a/lib/screens/settings/bloc/settings_bloc.dart
+++ b/lib/screens/settings/bloc/settings_bloc.dart
@@ -17,11 +17,14 @@ class SettingsBloc extends Bloc {
language: Language.fromCode(_settingsRepository.language),
currency: Currency.fromCode(_settingsRepository.currency),
networkMode: _settingsRepository.networkMode,
+ insiderFeaturesUnlocked:
+ _settingsRepository.insiderFeaturesUnlocked,
)) {
on(_onSetCurrencyEvent);
on(_onSetLanguageEvent);
on(_onSetNetworkModeEvent);
on(_onToggleHideAmountEvent);
+ on(_onUnlockInsiderFeaturesEvent);
}
final SettingsRepository _settingsRepository;
@@ -53,4 +56,12 @@ class SettingsBloc extends Bloc {
void _onToggleHideAmountEvent(ToggleHideAmountEvent event, Emitter emit) {
emit(state.copyWith(hideAmounts: !state.hideAmounts));
}
+
+ void _onUnlockInsiderFeaturesEvent(
+ UnlockInsiderFeaturesEvent event,
+ Emitter emit,
+ ) {
+ _settingsRepository.insiderFeaturesUnlocked = true;
+ emit(state.copyWith(insiderFeaturesUnlocked: true));
+ }
}
diff --git a/lib/screens/settings/bloc/settings_event.dart b/lib/screens/settings/bloc/settings_event.dart
index e65e52069..dd596c8fe 100644
--- a/lib/screens/settings/bloc/settings_event.dart
+++ b/lib/screens/settings/bloc/settings_event.dart
@@ -37,3 +37,7 @@ final class SetNetworkModeEvent extends SettingsEvent {
final class ToggleHideAmountEvent extends SettingsEvent {
const ToggleHideAmountEvent();
}
+
+final class UnlockInsiderFeaturesEvent extends SettingsEvent {
+ const UnlockInsiderFeaturesEvent();
+}
diff --git a/lib/screens/settings/bloc/settings_state.dart b/lib/screens/settings/bloc/settings_state.dart
index c10ac93f0..2288e2bb8 100644
--- a/lib/screens/settings/bloc/settings_state.dart
+++ b/lib/screens/settings/bloc/settings_state.dart
@@ -6,23 +6,27 @@ final class SettingsState {
this.currency = Currency.chf,
this.networkMode = NetworkMode.mainnet,
this.hideAmounts = false,
+ this.insiderFeaturesUnlocked = false,
});
final Language language;
final Currency currency;
final NetworkMode networkMode;
final bool hideAmounts;
+ final bool insiderFeaturesUnlocked;
SettingsState copyWith({
Language? language,
Currency? currency,
NetworkMode? networkMode,
bool? hideAmounts,
+ bool? insiderFeaturesUnlocked,
}) =>
SettingsState(
language: language ?? this.language,
currency: currency ?? this.currency,
networkMode: networkMode ?? this.networkMode,
hideAmounts: hideAmounts ?? this.hideAmounts,
+ insiderFeaturesUnlocked: insiderFeaturesUnlocked ?? this.insiderFeaturesUnlocked,
);
}
diff --git a/lib/screens/settings/settings_page.dart b/lib/screens/settings/settings_page.dart
index 7b6d5f351..d036b7587 100644
--- a/lib/screens/settings/settings_page.dart
+++ b/lib/screens/settings/settings_page.dart
@@ -11,6 +11,7 @@ import 'package:realunit_wallet/screens/pin/verify_pin_page.dart';
import 'package:realunit_wallet/screens/settings/bloc/settings_bloc.dart';
import 'package:realunit_wallet/screens/settings/widgets/settings_confirm_logout_wallet_sheet.dart';
import 'package:realunit_wallet/screens/settings/widgets/settings_section.dart';
+import 'package:realunit_wallet/screens/settings/widgets/settings_version_unlock.dart';
import 'package:realunit_wallet/setup/di.dart';
import 'package:realunit_wallet/setup/routing/routes/pin_routes.dart';
import 'package:realunit_wallet/setup/routing/routes/settings_routes.dart';
@@ -153,16 +154,7 @@ class SettingsPage extends StatelessWidget {
),
],
),
- Padding(
- padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 20),
- child: Text(
- S.of(context).settingsAppVersion(releaseTag),
- textAlign: TextAlign.center,
- style: Theme.of(context).textTheme.bodySmall?.copyWith(
- color: RealUnitColors.neutral500,
- ),
- ),
- ),
+ const SettingsVersionUnlock(releaseTag: releaseTag),
],
),
),
diff --git a/lib/screens/settings/widgets/settings_version_unlock.dart b/lib/screens/settings/widgets/settings_version_unlock.dart
new file mode 100644
index 000000000..d0717b6ec
--- /dev/null
+++ b/lib/screens/settings/widgets/settings_version_unlock.dart
@@ -0,0 +1,54 @@
+import 'package:flutter/material.dart';
+import 'package:realunit_wallet/generated/i18n.dart';
+import 'package:realunit_wallet/screens/settings/bloc/settings_bloc.dart';
+import 'package:realunit_wallet/setup/di.dart';
+import 'package:realunit_wallet/styles/colors.dart';
+
+class SettingsVersionUnlock extends StatefulWidget {
+ const SettingsVersionUnlock({required this.releaseTag, super.key});
+
+ final String releaseTag;
+
+ @override
+ State createState() =>
+ _SettingsVersionUnlockState();
+}
+
+class _SettingsVersionUnlockState extends State {
+ int _tapCount = 0;
+
+ void _onTap() {
+ final settingsBloc = getIt();
+ if (settingsBloc.state.insiderFeaturesUnlocked) {
+ return;
+ }
+
+ _tapCount++;
+ if (_tapCount == 7) {
+ settingsBloc.add(const UnlockInsiderFeaturesEvent());
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Text(S.of(context).settingsInsiderFeaturesUnlocked),
+ ),
+ );
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return GestureDetector(
+ behavior: HitTestBehavior.opaque,
+ onTap: _onTap,
+ child: Padding(
+ padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 20),
+ child: Text(
+ S.of(context).settingsAppVersion(widget.releaseTag),
+ textAlign: TextAlign.center,
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
+ color: RealUnitColors.neutral500,
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/setup/error_handling/crash_reporting.dart b/lib/setup/error_handling/crash_reporting.dart
new file mode 100644
index 000000000..b9aa5bac2
--- /dev/null
+++ b/lib/setup/error_handling/crash_reporting.dart
@@ -0,0 +1,75 @@
+import 'dart:developer' as developer;
+
+import 'package:flutter/foundation.dart';
+import 'package:sentry_flutter/sentry_flutter.dart';
+
+/// The crash-reporting DSN, injected at build time via
+/// `--dart-define=SENTRY_DSN=...`. Local and CI builds that do not inject a
+/// DSN get the empty default, which keeps crash reporting fully disabled —
+/// no SDK start, no network traffic. The DSN points at the company-operated
+/// crash-reporting service; see CONTRIBUTING § API Access for why this is the
+/// one sanctioned non-API endpoint.
+const crashReportingDsn = String.fromEnvironment('SENTRY_DSN');
+
+/// Reported as the Sentry environment. Only release builds carrying a DSN
+/// report at all, so the default names the production environment; testnet
+/// builds can override via `--dart-define=SENTRY_ENVIRONMENT=...`.
+const crashReportingEnvironment = String.fromEnvironment(
+ 'SENTRY_ENVIRONMENT',
+ defaultValue: 'production',
+);
+
+/// Matches the shape of [SentryFlutter.init] so tests can swap in a recording
+/// fake without touching the native SDK.
+typedef CrashReporterInit = Future Function(FlutterOptionsConfiguration configuration);
+
+/// Starts crash reporting when a DSN was injected at build time; a no-op
+/// otherwise. Best-effort by design: a malformed DSN or a failing native
+/// binding is logged and swallowed — reporting infrastructure must never keep
+/// the wallet from starting.
+///
+/// Must run AFTER [installErrorHandlers]: that installer overwrites
+/// `PlatformDispatcher.onError` without chaining, so in the reverse order the
+/// SDK's async-error hook would be silently dropped. The SDK itself chains
+/// both handlers it wraps — `FlutterError.onError` captures first and then
+/// delegates to the handler installed before it; `PlatformDispatcher.onError`
+/// delegates first and then captures.
+///
+/// @no-integration-test: the native SDK only starts in a build that injects a
+/// DSN, which no test build does; the DSN gate, the option pinning and the
+/// swallow-on-failure contract are covered by unit tests via the injectable
+/// [init].
+Future initCrashReporting({
+ String dsn = crashReportingDsn,
+ CrashReporterInit init = SentryFlutter.init,
+}) async {
+ if (dsn.isEmpty) return;
+ try {
+ await init((options) => configureCrashReporting(options, dsn: dsn));
+ } catch (error, stackTrace) {
+ developer.log(
+ 'crash reporting init failed: $error',
+ name: 'WalletApp',
+ error: error,
+ stackTrace: stackTrace,
+ );
+ }
+}
+
+/// Applies the pinned option set. The guarantee is exactly this list — an
+/// upstream default flip outside it is not caught here: no PII, no
+/// screenshots, no performance tracing, no session telemetry. Native crash
+/// handling and ANR detection stay on their SDK defaults deliberately; they
+/// produce precisely the error events this reporter exists for.
+/// (View-hierarchy attachment also stays off by SDK default; its option is
+/// experimental and deliberately not referenced here.)
+@visibleForTesting
+void configureCrashReporting(SentryFlutterOptions options, {required String dsn}) {
+ options
+ ..dsn = dsn
+ ..environment = crashReportingEnvironment
+ ..sendDefaultPii = false
+ ..attachScreenshot = false
+ ..enableAutoSessionTracking = false
+ ..tracesSampleRate = null;
+}
diff --git a/lib/setup/error_handling/error_handlers.dart b/lib/setup/error_handling/error_handlers.dart
index a38ae7448..87423a680 100644
--- a/lib/setup/error_handling/error_handlers.dart
+++ b/lib/setup/error_handling/error_handlers.dart
@@ -27,9 +27,9 @@ import 'package:realunit_wallet/setup/error_handling/realunit_error_view.dart';
/// engine's own reporting running instead of suppressing it (returning `true`
/// would claim the error as handled while our log is a no-op, i.e. total
/// silence). So this makes async errors *reachable where a developer is already
-/// attached*; it adds no release-mode visibility on its own. Getting evidence
-/// off a customer's device needs a crash reporter or a persisted log sink —
-/// this handler body is the hook such a sink plugs into.
+/// attached*; it adds no release-mode visibility on its own. Release-mode
+/// evidence comes from the crash reporter (`crash_reporting.dart`), which is
+/// initialized after this call and chains the handlers installed here.
///
/// @no-integration-test: the engine-side fallback reporting that runs after
/// [PlatformDispatcher.onError] returns false is embedder behaviour and is not
diff --git a/lib/widgets/action_button.dart b/lib/widgets/action_button.dart
index 5cd6a9a39..a882f408f 100644
--- a/lib/widgets/action_button.dart
+++ b/lib/widgets/action_button.dart
@@ -31,19 +31,32 @@ class ActionButton extends StatelessWidget {
? CupertinoActivityIndicator(
color: RealUnitColors.basic.white,
)
- : Column(
- mainAxisAlignment: .center,
- children: [
- icon,
- Text(
- label,
- textAlign: .center,
- style: Theme.of(context).textTheme.bodySmall?.copyWith(
- color: RealUnitColors.basic.white,
- fontWeight: .w600,
- ),
+ : Center(
+ // Expanded call-sites (dashboard_actions) squeeze this
+ // SizedBox's width down to ~80-84px, and large text scales
+ // blow past the fixed 50px height — scaleDown + a
+ // single-line label keeps the button overflow-free and
+ // fully tappable instead of wrapping/clipping (responsive
+ // matrix gate, issue class from PR #885).
+ child: FittedBox(
+ fit: .scaleDown,
+ child: Column(
+ mainAxisSize: .min,
+ children: [
+ icon,
+ Text(
+ label,
+ maxLines: 1,
+ softWrap: false,
+ textAlign: .center,
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
+ color: RealUnitColors.basic.white,
+ fontWeight: .w600,
+ ),
+ ),
+ ],
),
- ],
+ ),
),
),
),
diff --git a/lib/widgets/form/phone_number_field.dart b/lib/widgets/form/phone_number_field.dart
index 469cf364b..a2684db8d 100644
--- a/lib/widgets/form/phone_number_field.dart
+++ b/lib/widgets/form/phone_number_field.dart
@@ -21,17 +21,20 @@ class _PhoneNumberFieldState extends State {
void initState() {
super.initState();
final value = widget.controller.value;
- if (value != null) {
- for (var p in prefixes) {
- if (value.startsWith(p)) {
- prefix = p;
- number = value.substring(p.length);
- break;
- }
+ for (final p in prefixes) {
+ if (value != null && value.startsWith(p)) {
+ prefix = p;
+ number = value.substring(p.length);
+ break;
}
- } else {
- prefix = prefixes.first;
}
+
+ // 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,
+ // so the validator still blocks submit until it is re-entered.
+ prefix ??= prefixes.first;
}
void updatePhoneNumber() {
diff --git a/pubspec.lock b/pubspec.lock
index a6d39b085..a68f0b590 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -823,6 +823,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.5"
+ jni:
+ dependency: transitive
+ description:
+ name: jni
+ sha256: d2c361082d554d4593c3012e26f6b188f902acd291330f13d6427641a92b3da1
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.14.2"
js:
dependency: transitive
description:
@@ -1087,6 +1095,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
+ package_info_plus:
+ dependency: transitive
+ description:
+ name: package_info_plus
+ sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20"
+ url: "https://pub.dev"
+ source: hosted
+ version: "9.0.1"
+ package_info_plus_platform_interface:
+ dependency: transitive
+ description:
+ name: package_info_plus_platform_interface
+ sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086"
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.2.1"
path:
dependency: "direct main"
description:
@@ -1271,6 +1295,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.0"
+ sentry:
+ dependency: transitive
+ description:
+ name: sentry
+ sha256: a84bf3a83b3ce1c89fce28b9f97659e3826df14a73a256952465aac2c5efdf5a
+ url: "https://pub.dev"
+ source: hosted
+ version: "9.25.0"
sentry_dart_plugin:
dependency: "direct dev"
description:
@@ -1279,6 +1311,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.4.0"
+ sentry_flutter:
+ dependency: "direct main"
+ description:
+ name: sentry_flutter
+ sha256: c85575266d91f57364e9b4cb522835156ec3c5581c78339dc7fae491cd0c6f76
+ url: "https://pub.dev"
+ source: hosted
+ version: "9.25.0"
shared_preferences:
dependency: "direct main"
description:
diff --git a/pubspec.yaml b/pubspec.yaml
index 0a22ecbc0..775cd3438 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -72,6 +72,7 @@ dependencies:
path_provider: 2.1.5
pointycastle: ^3.9.1
qr_flutter: ^4.1.0
+ sentry_flutter: ^9.25.0
shared_preferences: ^2.5.2
sqlite3: ^3.3.0
url_launcher: ^6.3.1
diff --git a/scripts/assemble-handbook-screenshots.sh b/scripts/assemble-handbook-screenshots.sh
index 9a55f00ea..33e716baa 100755
--- a/scripts/assemble-handbook-screenshots.sh
+++ b/scripts/assemble-handbook-screenshots.sh
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
#
-# Assemble the 278 handbook screenshots from the visual-regression Golden
+# Assemble the 279 handbook screenshots from the visual-regression Golden
# baselines. The flat `NN-name.png` output layout matches what
# docs/handbook/de/index.html links to (` `
# — the relative path resolves to `docs/handbook/screenshots/NN-name.png`).
@@ -316,6 +316,7 @@ MAPPING=(
"266-debug-auth-page-clipboard-snackbar=screens/debug_auth/goldens/macos/debug_auth_page_clipboard_snackbar.png"
"267-debug-auth-page-authenticating=screens/debug_auth/goldens/macos/debug_auth_page_authenticating.png"
"268-phone-number-field-default=widgets/form/goldens/macos/phone_number_field_default.png"
+ "269-dashboard-insider-unlocked=screens/dashboard/goldens/macos/dashboard_insider_unlocked.png"
)
missing=()
diff --git a/test/goldens/screens/dashboard/dashboard_golden_test.dart b/test/goldens/screens/dashboard/dashboard_golden_test.dart
index 69bb26cbe..4014ecdf5 100644
--- a/test/goldens/screens/dashboard/dashboard_golden_test.dart
+++ b/test/goldens/screens/dashboard/dashboard_golden_test.dart
@@ -125,5 +125,24 @@ void main() {
return wrapForGolden(buildSubject());
},
);
+ goldenTest(
+ 'insider features unlocked',
+ fileName: 'dashboard_insider_unlocked',
+ constraints: const BoxConstraints.tightFor(width: 390, height: 844),
+ builder: () {
+ when(() => settingsBloc.state)
+ .thenReturn(const SettingsState(insiderFeaturesUnlocked: true));
+ when(() => balanceCubit.state).thenReturn(
+ Balance(
+ chainId: realUnitAsset.chainId,
+ contractAddress: realUnitAsset.address,
+ walletAddress: '0x0',
+ balance: BigInt.from(5000000000000000000),
+ asset: realUnitAsset,
+ ),
+ );
+ return wrapForGolden(buildSubject());
+ },
+ );
});
}
diff --git a/test/goldens/screens/dashboard/goldens/macos/dashboard_hidden_amounts.png b/test/goldens/screens/dashboard/goldens/macos/dashboard_hidden_amounts.png
index 6d13463d7..214c26bc5 100644
Binary files a/test/goldens/screens/dashboard/goldens/macos/dashboard_hidden_amounts.png and b/test/goldens/screens/dashboard/goldens/macos/dashboard_hidden_amounts.png differ
diff --git a/test/goldens/screens/dashboard/goldens/macos/dashboard_insider_unlocked.png b/test/goldens/screens/dashboard/goldens/macos/dashboard_insider_unlocked.png
new file mode 100644
index 000000000..2d41dd005
Binary files /dev/null and b/test/goldens/screens/dashboard/goldens/macos/dashboard_insider_unlocked.png differ
diff --git a/test/goldens/screens/dashboard/goldens/macos/dashboard_recent_transactions.png b/test/goldens/screens/dashboard/goldens/macos/dashboard_recent_transactions.png
index d90b7b99b..006cbc517 100644
Binary files a/test/goldens/screens/dashboard/goldens/macos/dashboard_recent_transactions.png and b/test/goldens/screens/dashboard/goldens/macos/dashboard_recent_transactions.png differ
diff --git a/test/goldens/screens/dashboard/goldens/macos/dashboard_with_balance.png b/test/goldens/screens/dashboard/goldens/macos/dashboard_with_balance.png
index bbb828a45..2f81230da 100644
Binary files a/test/goldens/screens/dashboard/goldens/macos/dashboard_with_balance.png and b/test/goldens/screens/dashboard/goldens/macos/dashboard_with_balance.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
new file mode 100644
index 000000000..8c72f9ff2
Binary files /dev/null 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_personal_data_page_missing_user_data.png b/test/goldens/screens/kyc/goldens/macos/kyc_personal_data_page_missing_user_data.png
new file mode 100644
index 000000000..eab60d884
Binary files /dev/null and b/test/goldens/screens/kyc/goldens/macos/kyc_personal_data_page_missing_user_data.png differ
diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_unsupported_step_page_default.png b/test/goldens/screens/kyc/goldens/macos/kyc_unsupported_step_page_default.png
new file mode 100644
index 000000000..87e0a9d5a
Binary files /dev/null and b/test/goldens/screens/kyc/goldens/macos/kyc_unsupported_step_page_default.png differ
diff --git a/test/goldens/screens/kyc/kyc_personal_data_golden_test.dart b/test/goldens/screens/kyc/kyc_personal_data_golden_test.dart
new file mode 100644
index 000000000..79d3328be
--- /dev/null
+++ b/test/goldens/screens/kyc/kyc_personal_data_golden_test.dart
@@ -0,0 +1,99 @@
+import 'package:bloc_test/bloc_test.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:get_it/get_it.dart';
+import 'package:mocktail/mocktail.dart';
+import 'package:realunit_wallet/packages/service/dfx/dfx_country_service.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/registration/kyc/kyc_personal_data.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/user/dto/real_unit_user_data_dto.dart';
+import 'package:realunit_wallet/screens/kyc/cubits/kyc/kyc_cubit.dart';
+import 'package:realunit_wallet/screens/kyc/steps/personal_data/cubit/kyc_personal_data/kyc_personal_data_cubit.dart';
+import 'package:realunit_wallet/screens/kyc/steps/personal_data/kyc_personal_data_page.dart';
+
+import '../../../helper/helper.dart';
+
+class _MockKycPersonalDataCubit extends MockCubit
+ implements KycPersonalDataCubit {}
+
+class _MockKycCubit extends MockCubit implements KycCubit {}
+
+RealUnitUserDataDto _userData() => const RealUnitUserDataDto(
+ email: 'erika@example.com',
+ name: 'Erika Mueller',
+ type: 'HUMAN',
+ phoneNumber: '+41790000000',
+ birthday: '1990-01-01',
+ nationality: 'CH',
+ addressStreet: 'Bahnhofstrasse 1',
+ addressPostalCode: '8001',
+ addressCity: 'Winterthur',
+ addressCountry: 'CH',
+ swissTaxResidence: true,
+ lang: 'EN',
+ kycData: KycPersonalData(
+ accountType: KycAccountType.personal,
+ firstName: 'Erika',
+ lastName: 'Mueller',
+ phone: '+41790000000',
+ address: KycAddress(
+ street: 'Bahnhofstrasse',
+ houseNumber: '1',
+ zip: '8001',
+ city: 'Winterthur',
+ country: 41,
+ ),
+ ),
+);
+
+void main() {
+ late _MockKycPersonalDataCubit personalDataCubit;
+ late _MockKycCubit kycCubit;
+
+ setUp(() {
+ personalDataCubit = _MockKycPersonalDataCubit();
+ kycCubit = _MockKycCubit();
+
+ when(() => personalDataCubit.state).thenReturn(const KycPersonalDataInitial());
+ when(() => kycCubit.state).thenReturn(const KycInitial());
+ });
+
+ setUpAll(() {
+ GetIt.instance.registerSingleton(fixtureCountryService());
+ });
+
+ tearDownAll(() async => GetIt.instance.reset());
+
+ group('$KycPersonalDataView', () {
+ // initialUserData == null → the page short-circuits to its defensive refresh surface. No cubit is
+ // created, so drive the page directly with only the parent KycCubit in scope for the handler.
+ goldenTest(
+ 'missing user data — defensive refresh page',
+ fileName: 'kyc_personal_data_page_missing_user_data',
+ constraints: phoneConstraints,
+ builder: () => wrapForGolden(
+ BlocProvider.value(
+ value: kycCubit,
+ child: const KycPersonalDataPage(url: 'https://example.com'),
+ ),
+ ),
+ );
+
+ goldenTest(
+ 'seeded from the registration payload',
+ fileName: 'kyc_personal_data_page_default',
+ constraints: phoneConstraints,
+ builder: () => wrapForGolden(
+ MultiBlocProvider(
+ providers: [
+ BlocProvider.value(value: personalDataCubit),
+ BlocProvider.value(value: kycCubit),
+ ],
+ child: KycPersonalDataView(
+ url: 'https://example.com',
+ initialUserData: _userData(),
+ ),
+ ),
+ ),
+ );
+ });
+}
diff --git a/test/goldens/screens/kyc/kyc_unsupported_step_golden_test.dart b/test/goldens/screens/kyc/kyc_unsupported_step_golden_test.dart
new file mode 100644
index 000000000..6c2ef9fbe
--- /dev/null
+++ b/test/goldens/screens/kyc/kyc_unsupported_step_golden_test.dart
@@ -0,0 +1,33 @@
+import 'package:bloc_test/bloc_test.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:mocktail/mocktail.dart';
+import 'package:realunit_wallet/screens/kyc/cubits/kyc/kyc_cubit.dart';
+import 'package:realunit_wallet/screens/kyc/subpages/kyc_unsupported_step_page.dart';
+
+import '../../../helper/helper.dart';
+
+class _MockKycCubit extends MockCubit implements KycCubit {}
+
+void main() {
+ late _MockKycCubit kycCubit;
+
+ setUp(() {
+ kycCubit = _MockKycCubit();
+ when(() => kycCubit.state).thenReturn(const KycInitial());
+ });
+
+ group('$KycUnsupportedStepPage', () {
+ goldenTest(
+ 'actionable handoff for a step the app cannot render',
+ fileName: 'kyc_unsupported_step_page_default',
+ constraints: phoneConstraints,
+ builder: () => wrapForGolden(
+ BlocProvider.value(
+ value: kycCubit,
+ child: const KycUnsupportedStepPage(),
+ ),
+ ),
+ );
+ });
+}
diff --git a/test/goldens/screens/settings_currencies/settings_currencies_golden_test.dart b/test/goldens/screens/settings_currencies/settings_currencies_golden_test.dart
index 6d22abeae..8d4475c21 100644
--- a/test/goldens/screens/settings_currencies/settings_currencies_golden_test.dart
+++ b/test/goldens/screens/settings_currencies/settings_currencies_golden_test.dart
@@ -26,6 +26,7 @@ void main() {
when(() => settingsRepo.language).thenReturn('en');
when(() => settingsRepo.currency).thenReturn('CHF');
when(() => settingsRepo.networkMode).thenReturn(NetworkMode.mainnet);
+ when(() => settingsRepo.insiderFeaturesUnlocked).thenReturn(false);
when(() => fiatRepo.getAll())
.thenAnswer((_) async => const [Currency.chf, Currency.eur]);
settingsBloc = SettingsBloc(settingsRepo, () async {});
diff --git a/test/goldens/screens/settings_languages/settings_languages_golden_test.dart b/test/goldens/screens/settings_languages/settings_languages_golden_test.dart
index ef2658bb5..25b8dc2d2 100644
--- a/test/goldens/screens/settings_languages/settings_languages_golden_test.dart
+++ b/test/goldens/screens/settings_languages/settings_languages_golden_test.dart
@@ -27,6 +27,7 @@ void main() {
when(() => settingsRepo.language).thenReturn('en');
when(() => settingsRepo.currency).thenReturn('CHF');
when(() => settingsRepo.networkMode).thenReturn(NetworkMode.mainnet);
+ when(() => settingsRepo.insiderFeaturesUnlocked).thenReturn(false);
when(() => langRepo.getEnabled())
.thenAnswer((_) async => const [Language.en, Language.de]);
settingsBloc = SettingsBloc(settingsRepo, () async {});
diff --git a/test/helper/layout_assertions.dart b/test/helper/layout_assertions.dart
index 4bd93030e..1e421cda8 100644
--- a/test/helper/layout_assertions.dart
+++ b/test/helper/layout_assertions.dart
@@ -62,7 +62,14 @@ Future expectFullyTappable(
);
final box = tester.renderObject(finder);
- final rect = box.localToGlobal(Offset.zero) & box.size;
+ // Map BOTH corners through the render transform: `localToGlobal(zero) &
+ // size` appends the untransformed layout size, which overstates the visual
+ // rect for scaled targets (e.g. inside a FittedBox) and fails containment
+ // on content that visibly fits. For untransformed targets both forms agree.
+ final rect = Rect.fromPoints(
+ box.localToGlobal(Offset.zero),
+ box.localToGlobal(box.size.bottomRight(Offset.zero)),
+ );
expect(
rect.width,
@@ -77,7 +84,10 @@ Future expectFullyTappable(
expect(within, findsOneWidget, reason: 'within parent not found');
final parentBox = tester.renderObject(within);
- final parentRect = parentBox.localToGlobal(Offset.zero) & parentBox.size;
+ final parentRect = Rect.fromPoints(
+ parentBox.localToGlobal(Offset.zero),
+ parentBox.localToGlobal(parentBox.size.bottomRight(Offset.zero)),
+ );
// Allow 1px float tolerance.
final inflated = parentRect.inflate(1);
expect(
diff --git a/test/helper/responsive_surface_catalog.dart b/test/helper/responsive_surface_catalog.dart
index f45b7dfd3..8c35aa01c 100644
--- a/test/helper/responsive_surface_catalog.dart
+++ b/test/helper/responsive_surface_catalog.dart
@@ -82,6 +82,20 @@ const kResponsiveSurfaceCatalog = [
'test/screens/kyc/subpages/kyc_status_pages_responsive_matrix_test.dart',
productionPath: 'lib/screens/kyc/subpages/kyc_pending_page.dart',
),
+ ResponsiveSurface(
+ id: 'kyc_unsupported_step_page',
+ description: 'KYC unsupported-step handoff page',
+ matrixTestPath:
+ 'test/screens/kyc/subpages/kyc_status_pages_responsive_matrix_test.dart',
+ productionPath: 'lib/screens/kyc/subpages/kyc_unsupported_step_page.dart',
+ ),
+ ResponsiveSurface(
+ id: 'kyc_personal_data_missing_user_data_page',
+ description: 'KYC personal-data missing-payload refresh page',
+ matrixTestPath:
+ 'test/screens/kyc/subpages/kyc_status_pages_responsive_matrix_test.dart',
+ productionPath: 'lib/screens/kyc/steps/personal_data/kyc_personal_data_page.dart',
+ ),
ResponsiveSurface(
id: 'kyc_account_merge_page',
description: 'KYC account merge page',
@@ -259,7 +273,7 @@ const kResponsiveSurfaceCatalog = [
matrixTestPath: 'test/screens/pin/pin_sheets_responsive_matrix_test.dart',
productionPath: 'lib/screens/pin/widgets/enable_biometric_bottom_sheet.dart',
),
- // Migration covers 34 surfaces total (bitbox_connect_sheet + 33 above). No
+ // Migration covers 36 surfaces total (bitbox_connect_sheet + 35 above). No
// further known candidates remain from the prior sweep. welcome_page was
// reviewed and found safe (scrolls end-to-end, no separate sticky CTA) — not
// a migration candidate. Not exhaustive — review responsibility for every
diff --git a/test/packages/repository/settings_repository_test.dart b/test/packages/repository/settings_repository_test.dart
index fc5907b60..216285525 100644
--- a/test/packages/repository/settings_repository_test.dart
+++ b/test/packages/repository/settings_repository_test.dart
@@ -122,6 +122,32 @@ void main() {
});
});
+ group('insiderFeaturesUnlocked', () {
+ test('defaults to false when not stored', () async {
+ SharedPreferences.setMockInitialValues({});
+ final repo = SettingsRepository(await SharedPreferences.getInstance());
+
+ expect(repo.insiderFeaturesUnlocked, isFalse);
+ });
+
+ test('returns the stored value when set', () async {
+ SharedPreferences.setMockInitialValues({'insiderFeaturesUnlocked': true});
+ final repo = SettingsRepository(await SharedPreferences.getInstance());
+
+ expect(repo.insiderFeaturesUnlocked, isTrue);
+ });
+
+ test('setter persists', () async {
+ SharedPreferences.setMockInitialValues({});
+ final repo = SettingsRepository(await SharedPreferences.getInstance());
+
+ repo.insiderFeaturesUnlocked = true;
+ await Future.delayed(Duration.zero);
+
+ expect(repo.insiderFeaturesUnlocked, isTrue);
+ });
+ });
+
group('networkMode', () {
test('defaults to mainnet when no value is stored', () async {
SharedPreferences.setMockInitialValues({});
diff --git a/test/screens/dashboard/dashboard_responsive_matrix_test.dart b/test/screens/dashboard/dashboard_responsive_matrix_test.dart
index 6fd7a62e9..f7bd104d6 100644
--- a/test/screens/dashboard/dashboard_responsive_matrix_test.dart
+++ b/test/screens/dashboard/dashboard_responsive_matrix_test.dart
@@ -6,6 +6,12 @@
// inside the height-bounded `Expanded > Stack` host (dashboard_page.dart)
// overflowed already at default text scale with one pending transaction,
// painting the CTA outside the parent's hit-testable region.
+//
+// A second matrix group below covers the same tappability guarantee for the
+// four DashboardActions buttons (Buy/Sell/Pay/Send) in a standalone host —
+// pre-existing overflow debt in the surrounding dashboard sections (e.g.
+// cash_holding_box.dart) is tracked separately as issue #887 and out of
+// scope for that check.
import 'package:bloc_test/bloc_test.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@@ -21,6 +27,7 @@ import 'package:realunit_wallet/screens/dashboard/bloc/balance_cubit.dart';
import 'package:realunit_wallet/screens/dashboard/bloc/dashboard_bloc.dart';
import 'package:realunit_wallet/screens/dashboard/bloc/pending_transactions_cubit.dart';
import 'package:realunit_wallet/screens/dashboard/dashboard_page.dart';
+import 'package:realunit_wallet/screens/dashboard/widgets/sections/dashboard_actions.dart';
import 'package:realunit_wallet/screens/settings/bloc/settings_bloc.dart';
import 'package:realunit_wallet/setup/routing/routes/app_routes.dart';
import 'package:realunit_wallet/styles/currency.dart';
@@ -92,13 +99,26 @@ void main() {
child: const DashboardView(),
);
- // A minimal two-route stack: '/' hosts the dashboard, '/buy' is a marker
- // page so a real `context.pushNamed(AppRoutes.buy)` resolves instead of
- // throwing (the CTA's actual, unmocked navigation call).
- GoRouter buildRouter() => GoRouter(
+ Widget buildActionsHost() => BlocProvider.value(
+ value: settingsBloc,
+ child: const Scaffold(
+ body: Padding(
+ padding: EdgeInsets.symmetric(horizontal: 20),
+ child: DashboardActions(),
+ ),
+ ),
+ );
+
+ // A minimal five-route stack: '/' hosts either the full dashboard or the
+ // standalone DashboardActions host (see [homeBuilder]); '/buy', '/sell',
+ // '/pay' and '/send' are marker pages so the four action buttons' real,
+ // unmocked `context.pushNamed(...)` calls resolve instead of throwing.
+ GoRouter buildRouter({
+ Widget Function(BuildContext, GoRouterState)? homeBuilder,
+ }) => GoRouter(
initialLocation: '/',
routes: [
- GoRoute(path: '/', builder: (_, _) => buildDashboard()),
+ GoRoute(path: '/', builder: homeBuilder ?? (_, _) => buildDashboard()),
GoRoute(
name: AppRoutes.buy,
path: '/buy',
@@ -106,6 +126,27 @@ void main() {
body: Center(child: Text('buy-page-marker')),
),
),
+ GoRoute(
+ name: AppRoutes.sell,
+ path: '/sell',
+ builder: (_, _) => const Scaffold(
+ body: Center(child: Text('sell-page-marker')),
+ ),
+ ),
+ GoRoute(
+ name: AppRoutes.pay,
+ path: '/pay',
+ builder: (_, _) => const Scaffold(
+ body: Center(child: Text('pay-page-marker')),
+ ),
+ ),
+ GoRoute(
+ name: AppRoutes.send,
+ path: '/send',
+ builder: (_, _) => const Scaffold(
+ body: Center(child: Text('send-page-marker')),
+ ),
+ ),
],
);
@@ -141,6 +182,34 @@ void main() {
await tester.pump(const Duration(milliseconds: 100));
}
+ Future pumpActions(WidgetTester tester, MatrixCell cell) async {
+ final router = buildRouter(homeBuilder: (_, _) => buildActionsHost());
+ addTearDown(router.dispose);
+
+ await tester.binding.setSurfaceSize(cell.device.size);
+ addTearDown(() => tester.binding.setSurfaceSize(null));
+
+ await tester.pumpWidget(
+ MediaQuery(
+ data: cell.mediaQuery,
+ child: MaterialApp.router(
+ routerConfig: router,
+ theme: realUnitTheme,
+ locale: const Locale('de'),
+ localizationsDelegates: const [
+ S.delegate,
+ GlobalMaterialLocalizations.delegate,
+ GlobalCupertinoLocalizations.delegate,
+ GlobalWidgetsLocalizations.delegate,
+ ],
+ supportedLocales: S.delegate.supportedLocales,
+ ),
+ ),
+ );
+ await tester.pump();
+ await tester.pump(const Duration(milliseconds: 100));
+ }
+
group('DashboardView responsive matrix - empty balance, buy CTA reachable '
'(full device x textScale)', () {
for (final cell in kFullResponsiveMatrix) {
@@ -172,6 +241,60 @@ void main() {
}
});
+ group(
+ 'DashboardActions responsive matrix - insider unlocked, all four actions '
+ 'tappable (full device x textScale)',
+ () {
+ for (final cell in kFullResponsiveMatrix) {
+ testWidgets(cell.id, (tester) async {
+ await withTargetPlatform(cell.device.platform, () async {
+ when(() => settingsBloc.state)
+ .thenReturn(const SettingsState(insiderFeaturesUnlocked: true));
+
+ await expectNoLayoutOverflow(
+ tester,
+ () async {
+ await pumpActions(tester, cell);
+ },
+ reason: 'overflow on insider unlocked / ${cell.label}',
+ );
+
+ await expectFullyTappable(
+ tester,
+ find.text(S.current.buy),
+ within: find.byType(DashboardActions),
+ reason: '${cell.label}: buy button not tappable',
+ );
+
+ await pumpActions(tester, cell);
+ await expectFullyTappable(
+ tester,
+ find.text(S.current.sell),
+ within: find.byType(DashboardActions),
+ reason: '${cell.label}: sell button not tappable',
+ );
+
+ await pumpActions(tester, cell);
+ await expectFullyTappable(
+ tester,
+ find.text(S.current.pay),
+ within: find.byType(DashboardActions),
+ reason: '${cell.label}: pay button not tappable',
+ );
+
+ await pumpActions(tester, cell);
+ await expectFullyTappable(
+ tester,
+ find.text(S.current.send),
+ within: find.byType(DashboardActions),
+ reason: '${cell.label}: send button not tappable',
+ );
+ });
+ });
+ }
+ },
+ );
+
// Focused regression: the exact reported failure mode (empty balance, one
// waitingForPayment pending tx, default text scale) must invoke the real
// navigation via a tap - not just have a non-null onPressed. The old code
diff --git a/test/screens/dashboard/widgets/sections/dashboard_actions_test.dart b/test/screens/dashboard/widgets/sections/dashboard_actions_test.dart
index 0192a2e36..2c0aa6f86 100644
--- a/test/screens/dashboard/widgets/sections/dashboard_actions_test.dart
+++ b/test/screens/dashboard/widgets/sections/dashboard_actions_test.dart
@@ -1,22 +1,29 @@
+import 'dart:async';
+
+import 'package:bloc_test/bloc_test.dart';
import 'package:flutter/material.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
+import 'package:mocktail/mocktail.dart';
import 'package:realunit_wallet/generated/i18n.dart';
import 'package:realunit_wallet/screens/dashboard/widgets/sections/dashboard_actions.dart';
+import 'package:realunit_wallet/screens/settings/bloc/settings_bloc.dart';
import 'package:realunit_wallet/setup/routing/routes/app_routes.dart';
import 'package:realunit_wallet/widgets/action_button.dart';
+import '../../../../helper/helper.dart';
+
void main() {
late List pushedRoutes;
+ late MockSettingsBloc settingsBloc;
setUp(() {
pushedRoutes = [];
+ settingsBloc = MockSettingsBloc();
});
- // Routes the four action buttons can push. Each target records the pushed
- // route name so the `onPressed` closures are both executed and asserted,
- // instead of only painted.
GoRouter buildRouter() {
GoRoute target(String name, String path) => GoRoute(
name: name,
@@ -32,7 +39,12 @@ void main() {
routes: [
GoRoute(
path: '/',
- builder: (_, _) => const Scaffold(body: DashboardActions()),
+ builder: (_, _) => Scaffold(
+ body: BlocProvider.value(
+ value: settingsBloc,
+ child: const DashboardActions(),
+ ),
+ ),
),
target(AppRoutes.buy, '/buy'),
target(AppRoutes.sell, '/sell'),
@@ -48,76 +60,130 @@ void main() {
await tester.pumpWidget(
MaterialApp.router(
routerConfig: router,
- localizationsDelegates: const [
- S.delegate,
- GlobalMaterialLocalizations.delegate,
- ],
+ localizationsDelegates: const [S.delegate, GlobalMaterialLocalizations.delegate],
supportedLocales: S.delegate.supportedLocales,
),
);
await tester.pumpAndSettle();
}
- Finder actionButtonByLabel(String label) => find.byWidgetPredicate(
- (w) => w is ActionButton && w.label == label,
- );
+ Finder actionButtonByLabel(String label) =>
+ find.byWidgetPredicate((w) => w is ActionButton && w.label == label);
group('$DashboardActions', () {
- testWidgets('renders the buy, sell, pay and send action buttons', (tester) async {
- await pumpActions(tester);
-
- expect(actionButtonByLabel(S.current.buy), findsOneWidget);
- expect(actionButtonByLabel(S.current.sell), findsOneWidget);
- expect(actionButtonByLabel(S.current.pay), findsOneWidget);
- expect(actionButtonByLabel(S.current.send), findsOneWidget);
- // Each button is laid out inside an Expanded so the row divides the
- // available width into four equal slots.
- expect(find.byType(Expanded), findsNWidgets(4));
+ group('locked (default)', () {
+ setUp(() {
+ when(() => settingsBloc.state).thenReturn(const SettingsState());
+ });
+
+ testWidgets('renders only the buy and sell action buttons', (tester) async {
+ await pumpActions(tester);
+
+ expect(actionButtonByLabel(S.current.buy), findsOneWidget);
+ expect(actionButtonByLabel(S.current.sell), findsOneWidget);
+ expect(actionButtonByLabel(S.current.pay), findsNothing);
+ expect(actionButtonByLabel(S.current.send), findsNothing);
+ expect(find.byType(Expanded), findsNWidgets(2));
+ });
+
+ testWidgets('buy button pushes the buy route', (tester) async {
+ await pumpActions(tester);
+ await tester.tap(actionButtonByLabel(S.current.buy));
+ await tester.pumpAndSettle();
+ expect(pushedRoutes, [AppRoutes.buy]);
+ });
+
+ testWidgets('sell button pushes the sell route', (tester) async {
+ await pumpActions(tester);
+ await tester.tap(actionButtonByLabel(S.current.sell));
+ await tester.pumpAndSettle();
+ expect(pushedRoutes, [AppRoutes.sell]);
+ });
});
- testWidgets('renders the expected icons for each action', (tester) async {
- await pumpActions(tester);
-
- expect(find.byIcon(Icons.add_circle_rounded), findsOneWidget);
- expect(find.byIcon(Icons.do_not_disturb_on_rounded), findsOneWidget);
- expect(find.byIcon(Icons.qr_code_scanner_rounded), findsOneWidget);
- expect(find.byIcon(Icons.send_rounded), findsOneWidget);
+ group('unlocked', () {
+ setUp(() {
+ when(() => settingsBloc.state)
+ .thenReturn(const SettingsState(insiderFeaturesUnlocked: true));
+ });
+
+ testWidgets('renders the buy, sell, pay and send action buttons', (tester) async {
+ await pumpActions(tester);
+
+ expect(actionButtonByLabel(S.current.buy), findsOneWidget);
+ expect(actionButtonByLabel(S.current.sell), findsOneWidget);
+ expect(actionButtonByLabel(S.current.pay), findsOneWidget);
+ expect(actionButtonByLabel(S.current.send), findsOneWidget);
+ expect(find.byType(Expanded), findsNWidgets(4));
+ });
+
+ testWidgets('renders the expected icons for each action', (tester) async {
+ await pumpActions(tester);
+
+ expect(find.byIcon(Icons.add_circle_rounded), findsOneWidget);
+ expect(find.byIcon(Icons.do_not_disturb_on_rounded), findsOneWidget);
+ expect(find.byIcon(Icons.qr_code_scanner_rounded), findsOneWidget);
+ expect(find.byIcon(Icons.send_rounded), findsOneWidget);
+ });
+
+ testWidgets('buy button pushes the buy route', (tester) async {
+ await pumpActions(tester);
+ await tester.tap(actionButtonByLabel(S.current.buy));
+ await tester.pumpAndSettle();
+ expect(pushedRoutes, [AppRoutes.buy]);
+ });
+
+ testWidgets('sell button pushes the sell route', (tester) async {
+ await pumpActions(tester);
+ await tester.tap(actionButtonByLabel(S.current.sell));
+ await tester.pumpAndSettle();
+ expect(pushedRoutes, [AppRoutes.sell]);
+ });
+
+ testWidgets('pay button pushes the pay route', (tester) async {
+ await pumpActions(tester);
+ await tester.tap(actionButtonByLabel(S.current.pay));
+ await tester.pumpAndSettle();
+ expect(pushedRoutes, [AppRoutes.pay]);
+ });
+
+ testWidgets('send button pushes the send route', (tester) async {
+ await pumpActions(tester);
+ await tester.tap(actionButtonByLabel(S.current.send));
+ await tester.pumpAndSettle();
+ expect(pushedRoutes, [AppRoutes.send]);
+ });
});
- testWidgets('buy button pushes the buy route', (tester) async {
- await pumpActions(tester);
-
- await tester.tap(actionButtonByLabel(S.current.buy));
- await tester.pumpAndSettle();
-
- expect(pushedRoutes, [AppRoutes.buy]);
- });
-
- testWidgets('sell button pushes the sell route', (tester) async {
- await pumpActions(tester);
-
- await tester.tap(actionButtonByLabel(S.current.sell));
- await tester.pumpAndSettle();
-
- expect(pushedRoutes, [AppRoutes.sell]);
- });
-
- testWidgets('pay button pushes the pay route', (tester) async {
- await pumpActions(tester);
-
- await tester.tap(actionButtonByLabel(S.current.pay));
- await tester.pumpAndSettle();
-
- expect(pushedRoutes, [AppRoutes.pay]);
- });
-
- testWidgets('send button pushes the send route', (tester) async {
- await pumpActions(tester);
-
- await tester.tap(actionButtonByLabel(S.current.send));
- await tester.pumpAndSettle();
-
- expect(pushedRoutes, [AppRoutes.send]);
+ group('transitions', () {
+ testWidgets(
+ 'rebuilds from locked to unlocked when the bloc emits without a remount '
+ '(pins context.watch, a regression to context.read would not react)',
+ (tester) async {
+ final controller = StreamController();
+ addTearDown(controller.close);
+ whenListen(
+ settingsBloc,
+ controller.stream,
+ initialState: const SettingsState(),
+ );
+
+ await pumpActions(tester);
+
+ expect(actionButtonByLabel(S.current.pay), findsNothing);
+ expect(actionButtonByLabel(S.current.send), findsNothing);
+
+ controller.add(const SettingsState(insiderFeaturesUnlocked: true));
+ // Two pumps: the first delivers the stream event (async broadcast
+ // delivery updates the mock's state and marks the element dirty),
+ // the second builds the frame that shows the unlocked buttons.
+ await tester.pump();
+ await tester.pump();
+
+ expect(actionButtonByLabel(S.current.pay), findsOneWidget);
+ expect(actionButtonByLabel(S.current.send), findsOneWidget);
+ },
+ );
});
});
}
diff --git a/test/screens/kyc/cubits/kyc/kyc_cubit_test.dart b/test/screens/kyc/cubits/kyc/kyc_cubit_test.dart
index 27fcb1d91..804e99e6f 100644
--- a/test/screens/kyc/cubits/kyc/kyc_cubit_test.dart
+++ b/test/screens/kyc/cubits/kyc/kyc_cubit_test.dart
@@ -809,9 +809,9 @@ void main() {
);
// PendingReview + a required step the app cannot render (e.g.
- // additionalDocuments, residencePermit, statutes, personalData — all
- // absent from `_mapStepName`). Must surface an explicit failure with
- // the step name, never `KycCompleted`.
+ // additionalDocuments, residencePermit, statutes — all absent from
+ // `_mapStepName`). Must surface an explicit failure with the step name,
+ // never `KycCompleted`.
blocTest(
'emits KycUnsupportedStepFailure(step) when PendingReview required step is unmapped',
setUp: () {
@@ -838,6 +838,35 @@ void main() {
],
);
+ // Registration normally satisfies PersonalData without the user seeing it. It re-opens when
+ // identification rejects the submitted data, and until the page existed that landed the user on
+ // the unsupported-step failure screen with no way to correct anything.
+ blocTest(
+ 'routes PersonalData to its own step instead of the unsupported-step failure',
+ setUp: () {
+ when(() => kycService.getKycStatus()).thenAnswer(
+ (_) async => _kycStatus(
+ level: KycLevel.level20,
+ processStatus: KycProcessStatus.pendingReview,
+ steps: [
+ _step(
+ KycStepName.personalData,
+ status: KycStepStatus.inProgress,
+ isRequired: true,
+ ),
+ ],
+ ),
+ );
+ when(() => kycService.getUser()).thenAnswer((_) async => _user());
+ },
+ build: buildCubit,
+ act: (cubit) => cubit.checkKyc(),
+ expect: () => [
+ const KycLoading(),
+ const KycPending(KycStep.personalData),
+ ],
+ );
+
blocTest(
'emits KycPending(dfxApproval) when dfxApproval is the only required step in PendingReview',
setUp: () {
@@ -903,7 +932,9 @@ void main() {
(_) async => _session(
level: KycLevel.level20,
steps: const [],
- currentStep: _currentStep(KycStepName.personalData),
+ // personalData used to stand in for "unmapped" here; it has its own page now, so this
+ // needs a name that is still absent from `_mapStepName`.
+ currentStep: _currentStep(KycStepName.statutes),
),
);
},
@@ -911,7 +942,7 @@ void main() {
act: (cubit) => cubit.checkKyc(),
expect: () => [
const KycLoading(),
- const KycUnsupportedStepFailure(KycStepName.personalData),
+ const KycUnsupportedStepFailure(KycStepName.statutes),
],
);
diff --git a/test/screens/kyc/kyc_page_manager_test.dart b/test/screens/kyc/kyc_page_manager_test.dart
index af5204ce2..cf7dc572c 100644
--- a/test/screens/kyc/kyc_page_manager_test.dart
+++ b/test/screens/kyc/kyc_page_manager_test.dart
@@ -4,6 +4,11 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:get_it/get_it.dart';
import 'package:mocktail/mocktail.dart';
+import 'package:realunit_wallet/screens/kyc/subpages/kyc_unsupported_step_page.dart';
+import 'package:realunit_wallet/packages/service/dfx/dfx_country_service.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/registration/kyc/kyc_personal_data.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/user/dto/real_unit_user_data_dto.dart';
+import 'package:realunit_wallet/screens/kyc/steps/personal_data/kyc_personal_data_page.dart';
import 'package:realunit_wallet/packages/service/app_store.dart';
import 'package:realunit_wallet/packages/service/dfx/dfx_kyc_service.dart';
import 'package:realunit_wallet/packages/service/dfx/models/kyc/dto/kyc_level_dto.dart';
@@ -77,6 +82,34 @@ KycStepSessionDto _currentStep(
void main() {
late _MockDfxKycService kycService;
late _MockRealUnitRegistrationService registrationService;
+
+ RealUnitUserDataDto personalUserData() => const RealUnitUserDataDto(
+ email: 'erika@example.com',
+ name: 'Erika Mueller',
+ type: 'HUMAN',
+ phoneNumber: '+41790000000',
+ birthday: '1990-01-01',
+ nationality: 'CH',
+ addressStreet: 'Bahnhofstrasse 1',
+ addressPostalCode: '8001',
+ addressCity: 'Winterthur',
+ addressCountry: 'CH',
+ swissTaxResidence: true,
+ lang: 'EN',
+ kycData: KycPersonalData(
+ accountType: KycAccountType.personal,
+ firstName: 'Erika',
+ lastName: 'Mueller',
+ phone: '+41790000000',
+ address: KycAddress(
+ street: 'Bahnhofstrasse',
+ houseNumber: '1',
+ zip: '8001',
+ city: 'Winterthur',
+ country: 41,
+ ),
+ ),
+ );
late _MockRealUnitLegalService legalService;
late _MockAppStore appStore;
late _MockAWallet wallet;
@@ -101,6 +134,59 @@ void main() {
);
});
+ // The personal-data step only renders its correction form when the registration payload reaches it.
+ // Both hops are easy to drop silently — `_continueKyc` forwarding it into `KycSuccess`, and the
+ // manager passing it to the page — and either one makes the gate refuse for EVERY account, which is
+ // exactly the dead end this step exists to remove.
+ testWidgets(
+ 'KycSuccess(personalData) renders the form, not the refusal screen',
+ (tester) async {
+ when(() => registrationService.getRegistrationInfo()).thenAnswer(
+ (_) async => RealUnitRegistrationInfoDto(
+ state: RealUnitRegistrationState.alreadyRegistered,
+ realUnitUserDataDto: personalUserData(),
+ ),
+ );
+ when(() => kycService.getKycStatus()).thenAnswer(
+ (_) async => _kycStatus(
+ level: KycLevel.level20,
+ processStatus: KycProcessStatus.inProgress,
+ ),
+ );
+ when(() => kycService.getUser()).thenAnswer((_) async => _user());
+ when(() => kycService.continueKyc()).thenAnswer(
+ (_) async => _session(
+ level: KycLevel.level20,
+ steps: const [],
+ currentStep: _currentStep(KycStepName.personalData),
+ ),
+ );
+
+ // the page resolves both from getIt
+ final getIt = GetIt.instance;
+ getIt.registerSingleton(kycService);
+ getIt.registerSingleton(fixtureCountryService());
+ addTearDown(() async => getIt.reset());
+
+ final cubit = KycCubit(kycService, registrationService, legalService, appStore);
+ await tester.pumpApp(
+ BlocProvider.value(value: cubit, child: const KycViewManager()),
+ );
+
+ await cubit.checkKyc();
+ await tester.pumpAndSettle();
+
+ expect(cubit.state, isA());
+ expect((cubit.state as KycSuccess).currentStep, KycStep.personalData);
+ // the payload survived both hops
+ expect((cubit.state as KycSuccess).realUnitUserData, isNotNull);
+ expect(find.byType(KycPersonalDataView), findsOneWidget);
+ expect(find.byType(KycFailurePage), findsNothing);
+
+ await cubit.close();
+ },
+ );
+
// An in-progress `dfxApproval` step used to land on a blank white Scaffold
// (the `(_) => const Scaffold()` fallback in KycViewManager). It must render
// the existing pending page instead.
@@ -185,19 +271,22 @@ void main() {
},
);
- // The KycUnsupportedStepFailure arm renders a KycFailurePage with the
- // unsupported step name — a state no page test drives directly.
+ // The KycUnsupportedStepFailure arm renders the actionable handoff, never the generic failure page
+ // (which carries no actions) and never the raw wire identifier of the step.
testWidgets(
- 'KycViewManager renders KycFailurePage for KycUnsupportedStepFailure',
+ 'KycViewManager renders KycUnsupportedStepPage for KycUnsupportedStepFailure',
(tester) async {
final cubit = _MockKycCubit();
when(() => cubit.state).thenReturn(
- const KycUnsupportedStepFailure(KycStepName.personalData),
+ const KycUnsupportedStepFailure(KycStepName.statutes),
);
await tester.pumpApp(viewWithState(cubit));
- expect(find.byType(KycFailurePage), findsOneWidget);
+ expect(find.byType(KycUnsupportedStepPage), findsOneWidget);
+ expect(find.byType(KycFailurePage), findsNothing);
+ // the internal step name must not leak into the UI
+ expect(find.textContaining(KycStepName.statutes.value), findsNothing);
},
);
diff --git a/test/screens/kyc/steps/kyc_personal_data_page_test.dart b/test/screens/kyc/steps/kyc_personal_data_page_test.dart
new file mode 100644
index 000000000..720475d93
--- /dev/null
+++ b/test/screens/kyc/steps/kyc_personal_data_page_test.dart
@@ -0,0 +1,341 @@
+import 'dart:async';
+
+import 'package:bloc_test/bloc_test.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:get_it/get_it.dart';
+import 'package:http/testing.dart';
+import 'package:mocktail/mocktail.dart';
+import 'package:realunit_wallet/packages/service/dfx/dfx_country_service.dart';
+import 'package:realunit_wallet/packages/service/dfx/dfx_kyc_service.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/packages/service/dfx/models/user/dto/real_unit_user_data_dto.dart';
+import 'package:realunit_wallet/screens/kyc/subpages/kyc_unsupported_step_page.dart';
+import 'package:realunit_wallet/screens/kyc/cubits/kyc/kyc_cubit.dart';
+import 'package:realunit_wallet/screens/kyc/steps/personal_data/cubit/kyc_personal_data/kyc_personal_data_cubit.dart';
+import 'package:realunit_wallet/screens/kyc/steps/personal_data/kyc_personal_data_page.dart';
+import 'package:realunit_wallet/widgets/form/country_field.dart';
+import 'package:realunit_wallet/widgets/form/labeled_text_field.dart';
+import 'package:realunit_wallet/widgets/form/phone_number_field.dart';
+
+import '../../../helper/country_fixture.dart';
+import '../../../helper/pump_app.dart';
+
+class MockKycPersonalDataCubit extends MockCubit
+ implements KycPersonalDataCubit {}
+
+class MockKycCubit extends MockCubit implements KycCubit {}
+
+class MockDfxKycService extends Mock implements DfxKycService {}
+
+void main() {
+ late KycPersonalDataCubit personalDataCubit;
+ late KycCubit kycCubit;
+ const url = 'https://example.com/kyc/data/personal/1';
+
+ RealUnitUserDataDto userDataDto({
+ KycAccountType accountType = KycAccountType.personal,
+ String phone = '+41790000000',
+ }) => RealUnitUserDataDto(
+ email: 'erika@example.com',
+ name: 'Erika Mueller',
+ type: 'HUMAN',
+ phoneNumber: phone,
+ birthday: '1990-01-01',
+ nationality: 'CH',
+ addressStreet: 'Bahnhofstrasse 1',
+ addressPostalCode: '8001',
+ addressCity: 'Winterthur',
+ addressCountry: 'CH',
+ swissTaxResidence: true,
+ lang: 'EN',
+ kycData: KycPersonalData(
+ accountType: accountType,
+ firstName: 'Erika',
+ lastName: 'Mueller',
+ phone: phone,
+ address: const KycAddress(
+ street: 'Bahnhofstrasse',
+ houseNumber: '1',
+ zip: '8001',
+ city: 'Winterthur',
+ country: 41,
+ ),
+ ),
+ );
+
+ setUp(() {
+ personalDataCubit = MockKycPersonalDataCubit();
+ kycCubit = MockKycCubit();
+
+ when(() => personalDataCubit.state).thenReturn(const KycPersonalDataInitial());
+ when(() => personalDataCubit.stream).thenAnswer((_) => const Stream.empty());
+ when(() => kycCubit.state).thenReturn(const KycInitial());
+ when(() => kycCubit.checkKyc()).thenAnswer((_) => Future.value());
+ });
+
+ void setupDependencyInjection() {
+ final getIt = GetIt.instance;
+ getIt.registerSingleton(MockDfxKycService());
+ getIt.registerSingleton(fixtureCountryService());
+ }
+
+ setUpAll(() {
+ setupDependencyInjection();
+ registerFallbackValue(
+ const Country(id: 41, symbol: 'CH', name: 'Switzerland', kycAllowed: true),
+ );
+ registerFallbackValue(KycAccountType.personal);
+ });
+
+ tearDownAll(() async => await GetIt.instance.reset());
+
+ Widget buildSubject(Widget child) {
+ return MultiBlocProvider(
+ providers: [
+ BlocProvider.value(value: personalDataCubit),
+ BlocProvider.value(value: kycCubit),
+ ],
+ child: child,
+ );
+ }
+
+ group('$KycPersonalDataPage', () {
+ testWidgets('renders $KycPersonalDataView', (tester) async {
+ await tester.pumpApp(KycPersonalDataPage(url: url, initialUserData: userDataDto()));
+
+ expect(find.byType(KycPersonalDataView), findsOne);
+ });
+ });
+
+ group('$KycPersonalDataPage account-type guard', () {
+ // Submitting this form sets `accountType` on the account, and the API nulls every organization
+ // field whenever that value is Personal — so offering the form to a non-personal account would
+ // destroy its organization data and drop the org-only steps from its required set.
+ for (final type in [KycAccountType.organization, KycAccountType.soleProprietorship]) {
+ testWidgets('refuses the form for a $type account', (tester) async {
+ await tester.pumpApp(
+ KycPersonalDataPage(url: url, initialUserData: userDataDto(accountType: type)),
+ );
+
+ expect(find.byType(KycPersonalDataView), findsNothing);
+ expect(find.byType(KycUnsupportedStepPage), findsOne);
+ });
+ }
+
+ // A missing payload is transient (the registration row has no signed payload yet), so it gets
+ // its own refresh surface rather than the shared handoff an unsupported account type gets.
+ testWidgets('offers a retry, not a dead end, when the payload is missing', (tester) async {
+ await tester.pumpApp(
+ BlocProvider.value(
+ value: kycCubit,
+ child: const KycPersonalDataPage(url: url),
+ ),
+ );
+
+ expect(find.byType(KycPersonalDataView), findsNothing);
+ expect(find.byType(KycUnsupportedStepPage), findsNothing);
+
+ await tester.tap(find.byType(FilledButton));
+ await tester.pump();
+
+ verify(() => kycCubit.checkKyc()).called(1);
+ });
+ });
+
+ group('$KycPersonalDataView', () {
+ 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));
+ expect(find.byType(PhoneNumberField), findsOne);
+ expect(find.byType(CountryField), findsOne);
+ expect(find.byType(FilledButton), findsOne);
+ });
+
+ // The copy asks the user to check their details and every submit rewrites all of them, so an
+ // empty form would force a from-memory re-entry and let a typo overwrite correct data.
+ testWidgets('seeds the form from the registration payload', (tester) async {
+ await tester.pumpApp(
+ buildSubject(KycPersonalDataView(url: url, initialUserData: userDataDto())),
+ );
+ await tester.pumpAndSettle();
+
+ expect(find.text('Erika'), findsOne);
+ expect(find.text('Mueller'), findsOne);
+ expect(find.text('Bahnhofstrasse'), findsOne);
+ expect(find.text('8001'), findsOne);
+ expect(find.text('Winterthur'), findsOne);
+ });
+
+ // A stored number with a dial code the field does not offer must still leave an editable field:
+ // PhoneNumberField falls back to its first prefix rather than leaving `prefix` null, which would
+ // render a blank dropdown and make it silently drop every edit the user makes.
+ testWidgets('stays editable when the stored phone has an unsupported prefix', (tester) async {
+ final dto = userDataDto(phone: '+33612345678');
+ await tester.pumpApp(buildSubject(KycPersonalDataView(url: url, initialUserData: dto)));
+ await tester.pumpAndSettle();
+
+ expect(find.text('+41'), findsOne);
+ });
+
+ // The country lookup is fire-and-forget; without a catch a failing GET escapes as an uncaught
+ // async error instead of degrading to an empty picker.
+ testWidgets('survives a failing country lookup', (tester) async {
+ final getIt = GetIt.instance;
+ await getIt.reset();
+ getIt.registerSingleton(MockDfxKycService());
+ getIt.registerSingleton(failingCountryService());
+ addTearDown(() async {
+ await getIt.reset();
+ setupDependencyInjection();
+ });
+
+ await tester.pumpApp(
+ buildSubject(KycPersonalDataView(url: url, initialUserData: userDataDto())),
+ );
+ await tester.pumpAndSettle();
+
+ // the rest of the form still rendered
+ expect(find.text('Erika'), findsOne);
+ });
+
+ // Two independent country lookups race — this page's and CountryField's own. If this one is the
+ // slower, it must not overwrite a country the user has already chosen in the meantime.
+ testWidgets('does not overwrite a country the user already picked', (tester) async {
+ final gate = Completer();
+ var served = 0;
+ final getIt = GetIt.instance;
+ await getIt.reset();
+ getIt.registerSingleton(MockDfxKycService());
+ getIt.registerSingleton(
+ countryServiceWithClient(
+ MockClient((_) async {
+ // hold only the first caller (this page); let CountryField's own load through
+ if (served++ == 0) await gate.future;
+ return countriesFixtureResponse();
+ }),
+ ),
+ );
+ addTearDown(() async {
+ await getIt.reset();
+ setupDependencyInjection();
+ });
+
+ await tester.pumpApp(
+ buildSubject(KycPersonalDataView(url: url, initialUserData: userDataDto())),
+ );
+ await tester.pumpAndSettle();
+
+ await tester.tap(find.byType(CountryField));
+ await tester.pumpAndSettle();
+ await tester.tap(find.text('Germany').last);
+ await tester.pumpAndSettle();
+
+ gate.complete();
+ await tester.pumpAndSettle();
+
+ expect(find.text('Germany'), findsOne);
+ expect(find.text('Switzerland'), findsNothing);
+ });
+
+ // Pins the url plumbing: the step's session url is what the submit PUTs to.
+ testWidgets('submits the seeded values to the step url', (tester) async {
+ when(() => personalDataCubit.submit(
+ url: any(named: 'url'),
+ accountType: any(named: 'accountType'),
+ firstName: any(named: 'firstName'),
+ lastName: any(named: 'lastName'),
+ phone: any(named: 'phone'),
+ street: any(named: 'street'),
+ houseNumber: any(named: 'houseNumber'),
+ zip: any(named: 'zip'),
+ city: any(named: 'city'),
+ country: any(named: 'country'),
+ )).thenAnswer((_) async {});
+
+ await tester.pumpApp(
+ buildSubject(KycPersonalDataView(url: url, initialUserData: userDataDto())),
+ );
+ await tester.pumpAndSettle();
+
+ await tester.ensureVisible(find.byType(FilledButton));
+ await tester.pumpAndSettle();
+ await tester.tap(find.byType(FilledButton));
+ await tester.pump();
+
+ verify(() => personalDataCubit.submit(
+ url: url,
+ accountType: KycAccountType.personal,
+ firstName: 'Erika',
+ lastName: 'Mueller',
+ phone: '+41790000000',
+ street: 'Bahnhofstrasse',
+ houseNumber: '1',
+ zip: '8001',
+ city: 'Winterthur',
+ country: any(named: 'country'),
+ )).called(1);
+ });
+
+ // The step exists so a rejected account can correct its data; submitting an empty form must
+ // surface the validation errors rather than PUT an incomplete body.
+ testWidgets('does not submit while a required field is empty', (tester) async {
+ await tester.pumpApp(
+ buildSubject(KycPersonalDataView(url: url, initialUserData: userDataDto())),
+ );
+ await tester.pumpAndSettle();
+
+ await tester.enterText(find.widgetWithText(LabeledTextField, 'Erika'), '');
+ await tester.ensureVisible(find.byType(FilledButton));
+ await tester.pumpAndSettle();
+ await tester.tap(find.byType(FilledButton));
+ await tester.pump();
+
+ verifyNever(() => personalDataCubit.submit(
+ url: any(named: 'url'),
+ accountType: any(named: 'accountType'),
+ firstName: any(named: 'firstName'),
+ lastName: any(named: 'lastName'),
+ phone: any(named: 'phone'),
+ street: any(named: 'street'),
+ houseNumber: any(named: 'houseNumber'),
+ zip: any(named: 'zip'),
+ city: any(named: 'city'),
+ country: any(named: 'country'),
+ ));
+ });
+ });
+
+ group('$BlocListener', () {
+ testWidgets('triggers checkKyc when the submit succeeds', (tester) async {
+ whenListen(
+ personalDataCubit,
+ Stream.fromIterable([const KycPersonalDataSuccess()]),
+ initialState: const KycPersonalDataInitial(),
+ );
+
+ await tester.pumpApp(buildSubject(KycPersonalDataView(url: url, initialUserData: userDataDto())));
+ await tester.pump();
+
+ verify(() => kycCubit.checkKyc()).called(1);
+ });
+
+ testWidgets('shows a SnackBar when the submit fails', (tester) async {
+ whenListen(
+ personalDataCubit,
+ Stream.fromIterable([const KycPersonalDataFailure('fail')]),
+ initialState: const KycPersonalDataInitial(),
+ );
+
+ await tester.pumpApp(buildSubject(KycPersonalDataView(url: url, initialUserData: userDataDto())));
+ await tester.pump();
+
+ expect(find.byType(SnackBar), findsOne);
+ });
+ });
+}
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
new file mode 100644
index 000000000..f5b57bd01
--- /dev/null
+++ b/test/screens/kyc/steps/personal_data/kyc_personal_data_cubit_test.dart
@@ -0,0 +1,98 @@
+import 'package:bloc_test/bloc_test.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:mocktail/mocktail.dart';
+import 'package:realunit_wallet/packages/service/dfx/dfx_kyc_service.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/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';
+
+class _MockKycService extends Mock implements DfxKycService {}
+
+const _switzerland = Country(
+ id: 41,
+ symbol: 'CH',
+ name: 'Switzerland',
+ kycAllowed: true,
+);
+
+void main() {
+ late _MockKycService service;
+
+ setUpAll(() {
+ registerFallbackValue({});
+ });
+
+ setUp(() {
+ service = _MockKycService();
+ });
+
+ KycPersonalDataCubit build() => KycPersonalDataCubit(service);
+
+ Future submit(KycPersonalDataCubit c) => c.submit(
+ url: 'https://kyc/data/personal/1',
+ accountType: KycAccountType.personal,
+ firstName: 'Erika',
+ lastName: 'Mueller',
+ phone: '+41790000000',
+ street: 'Bahnhofstrasse',
+ houseNumber: '13',
+ zip: '8001',
+ city: 'Zurich',
+ country: _switzerland,
+ );
+
+ group('initial state', () {
+ test('emits $KycPersonalDataInitial', () {
+ expect(build().state, isA());
+ });
+ });
+
+ group('submit', () {
+ blocTest(
+ 'success: forwards the KycPersonalData body to setData; Loading → Success',
+ setUp: () => when(() => service.setData(any(), any())).thenAnswer((_) async {}),
+ build: build,
+ act: submit,
+ expect: () => const [KycPersonalDataLoading(), KycPersonalDataSuccess()],
+ verify: (_) => verify(
+ () => service.setData('https://kyc/data/personal/1', {
+ 'accountType': 'Personal',
+ 'firstName': 'Erika',
+ 'lastName': 'Mueller',
+ 'phone': '+41790000000',
+ 'address': {
+ 'street': 'Bahnhofstrasse',
+ 'houseNumber': '13',
+ 'zip': '8001',
+ 'city': 'Zurich',
+ 'country': {'id': 41},
+ },
+ }),
+ ).called(1),
+ );
+
+ blocTest(
+ 'failure: setData throws → Loading → Failure(e.toString())',
+ setUp: () => when(
+ () => service.setData(any(), any()),
+ ).thenAnswer((_) async => throw Exception('boom')),
+ build: build,
+ act: submit,
+ expect: () => [
+ const KycPersonalDataLoading(),
+ isA().having((s) => s.message, 'message', contains('boom')),
+ ],
+ );
+ });
+
+ group('$KycPersonalDataFailure', () {
+ test('Equatable props cover message', () {
+ const a = KycPersonalDataFailure('x');
+ const b = KycPersonalDataFailure('x');
+ const c = KycPersonalDataFailure('y');
+
+ expect(a, b);
+ expect(a, isNot(c));
+ });
+ });
+}
diff --git a/test/screens/kyc/subpages/kyc_status_pages_responsive_matrix_test.dart b/test/screens/kyc/subpages/kyc_status_pages_responsive_matrix_test.dart
index 8f42e609b..f016054bc 100644
--- a/test/screens/kyc/subpages/kyc_status_pages_responsive_matrix_test.dart
+++ b/test/screens/kyc/subpages/kyc_status_pages_responsive_matrix_test.dart
@@ -12,6 +12,10 @@ import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:mocktail/mocktail.dart';
+import 'package:realunit_wallet/setup/routing/routes/support_routes.dart';
+import 'package:realunit_wallet/widgets/buttons/app_text_button.dart';
+import 'package:realunit_wallet/screens/kyc/steps/personal_data/kyc_personal_data_page.dart';
+import 'package:realunit_wallet/screens/kyc/subpages/kyc_unsupported_step_page.dart';
import 'package:realunit_wallet/generated/i18n.dart';
import 'package:realunit_wallet/screens/kyc/cubits/kyc/kyc_cubit.dart';
import 'package:realunit_wallet/screens/kyc/subpages/kyc_completed_page.dart';
@@ -74,6 +78,54 @@ void main() {
/// Hosts [KycCompletedPage] under a two-route GoRouter so `context.pop` has
/// somewhere to go back to (mirrors support_email_capture_page_test).
+ /// Hosts the unsupported-step page under a router so its support CTA — which navigates — can be
+ /// really tapped. `pumpPage` cannot: it mounts pages under `MaterialApp(home:)`, and a tap would
+ /// throw "No GoRouter found in context".
+ Future pumpUnsupportedStepPage(
+ WidgetTester tester,
+ MatrixCell cell,
+ ) async {
+ await tester.binding.setSurfaceSize(cell.device.size);
+ addTearDown(() async {
+ await tester.binding.setSurfaceSize(null);
+ });
+
+ final router = GoRouter(
+ initialLocation: '/',
+ routes: [
+ GoRoute(
+ path: '/',
+ builder: (_, _) => BlocProvider.value(
+ value: cubit,
+ child: const KycUnsupportedStepPage(),
+ ),
+ ),
+ GoRoute(
+ name: SupportRoutes.support,
+ path: '/support',
+ builder: (_, _) => const Scaffold(body: Text('SUPPORT')),
+ ),
+ ],
+ );
+ addTearDown(router.dispose);
+
+ await tester.pumpWidget(
+ MediaQuery(
+ data: cell.mediaQuery,
+ child: MaterialApp.router(
+ theme: realUnitTheme,
+ locale: const Locale('de'),
+ localizationsDelegates: _localizationsDelegates,
+ supportedLocales: S.delegate.supportedLocales,
+ routerConfig: router,
+ ),
+ ),
+ );
+ await tester.pumpAndSettle();
+
+ return router;
+ }
+
Future pumpCompletedPage(
WidgetTester tester,
MatrixCell cell,
@@ -173,6 +225,72 @@ void main() {
}
});
+ // Two live tap targets in one sticky block, so both are asserted rather than just the primary CTA.
+ // The DE copy is long enough that the 3.0 text-scale cells are the point of this group.
+ group('KycUnsupportedStepPage responsive matrix (full device × textScale)', () {
+ for (final cell in kFullResponsiveMatrix) {
+ testWidgets(cell.id, (tester) async {
+ await withTargetPlatform(cell.device.platform, () async {
+ await expectNoLayoutOverflow(
+ tester,
+ () async {
+ await pumpUnsupportedStepPage(tester, cell);
+ },
+ reason: 'overflow on KycUnsupportedStepPage / ${cell.label}',
+ );
+
+ await expectFullyTappable(
+ tester,
+ find.byType(AppFilledButton),
+ within: find.byType(KycUnsupportedStepPage),
+ reason: 'KycUnsupportedStepPage / ${cell.label}: retry not tappable',
+ );
+
+ // Asserted for real, not inferred from the overflow check: ScrollableActionsLayout scrolls
+ // an over-tall action block instead of overflowing, so a CTA pushed out of the viewport
+ // raises nothing. Only a hit test catches it.
+ await expectFullyTappable(
+ tester,
+ find.byType(AppTextButton),
+ within: find.byType(KycUnsupportedStepPage),
+ reason: 'KycUnsupportedStepPage / ${cell.label}: support CTA not tappable',
+ );
+ });
+ });
+ }
+ });
+
+ group('KycPersonalDataPage missing-payload responsive matrix (full device × textScale)', () {
+ for (final cell in kFullResponsiveMatrix) {
+ testWidgets(cell.id, (tester) async {
+ await withTargetPlatform(cell.device.platform, () async {
+ await expectNoLayoutOverflow(
+ tester,
+ () async {
+ await pumpPage(
+ tester,
+ cell,
+ BlocProvider.value(
+ value: cubit,
+ // null payload → the defensive refresh surface
+ child: const KycPersonalDataPage(url: 'https://example.com'),
+ ),
+ );
+ },
+ reason: 'overflow on KycPersonalDataPage(missing payload) / ${cell.label}',
+ );
+
+ await expectFullyTappable(
+ tester,
+ find.byType(AppFilledButton),
+ within: find.byType(KycPersonalDataPage),
+ reason: 'KycPersonalDataPage(missing payload) / ${cell.label}: CTA not tappable',
+ );
+ });
+ });
+ }
+ });
+
group('KycPendingPage responsive matrix (full device × textScale)', () {
for (final cell in kFullResponsiveMatrix) {
testWidgets(cell.id, (tester) async {
diff --git a/test/screens/kyc/subpages/kyc_unsupported_step_page_test.dart b/test/screens/kyc/subpages/kyc_unsupported_step_page_test.dart
new file mode 100644
index 000000000..aa29aaecf
--- /dev/null
+++ b/test/screens/kyc/subpages/kyc_unsupported_step_page_test.dart
@@ -0,0 +1,104 @@
+import 'package:bloc_test/bloc_test.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:mocktail/mocktail.dart';
+import 'package:go_router/go_router.dart';
+import 'package:flutter_localizations/flutter_localizations.dart';
+import 'package:realunit_wallet/generated/i18n.dart';
+import 'package:realunit_wallet/setup/routing/routes/support_routes.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/kyc/kyc_level.dart';
+import 'package:realunit_wallet/screens/kyc/cubits/kyc/kyc_cubit.dart';
+import 'package:realunit_wallet/screens/kyc/subpages/kyc_unsupported_step_page.dart';
+import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart';
+import 'package:realunit_wallet/widgets/buttons/app_text_button.dart';
+
+import '../../../helper/pump_app.dart';
+
+class _MockKycCubit extends MockCubit implements KycCubit {}
+
+void main() {
+ late _MockKycCubit kycCubit;
+
+ // Hosting a router is what makes the support handoff assertable: without one the CTA's
+ // `pushNamed` throws, so a test that only checks the button exists proves nothing about where it
+ // goes. Mirrors settings_contact_page_test.
+ late List pushedRoutes;
+
+ setUp(() {
+ kycCubit = _MockKycCubit();
+ when(() => kycCubit.state).thenReturn(const KycInitial());
+ when(() => kycCubit.checkKyc()).thenAnswer((_) => Future.value());
+ pushedRoutes = [];
+ });
+
+ Widget subject() => BlocProvider.value(
+ value: kycCubit,
+ child: const KycUnsupportedStepPage(),
+ );
+
+ GoRouter buildRouter() => GoRouter(
+ initialLocation: '/',
+ routes: [
+ GoRoute(path: '/', builder: (_, _) => subject()),
+ GoRoute(
+ name: SupportRoutes.support,
+ path: '/support',
+ builder: (_, _) {
+ pushedRoutes.add(SupportRoutes.support);
+ return const Scaffold(body: Text('SUPPORT'));
+ },
+ ),
+ ],
+ );
+
+ group('$KycUnsupportedStepPage', () {
+ testWidgets('offers both a retry and a route to support', (tester) async {
+ await tester.pumpApp(subject());
+
+ expect(find.byType(AppFilledButton), findsOne);
+ expect(find.byType(AppTextButton), findsOne);
+ });
+
+ testWidgets('the support action navigates to the support screen', (tester) async {
+ await tester.pumpWidget(
+ MaterialApp.router(
+ // pumpApp cannot host a router, so the delegates it normally supplies are repeated here
+ localizationsDelegates: [S.delegate, GlobalMaterialLocalizations.delegate],
+ supportedLocales: S.delegate.supportedLocales,
+ routerConfig: buildRouter(),
+ ),
+ );
+ await tester.pumpAndSettle();
+
+ await tester.tap(find.byType(AppTextButton));
+ await tester.pumpAndSettle();
+
+ expect(pushedRoutes, [SupportRoutes.support]);
+ });
+
+ // The whole point of the page: the previous screen was a dead end with no actions at all.
+ testWidgets('the retry re-reads the KYC state', (tester) async {
+ await tester.pumpApp(subject());
+
+ await tester.tap(find.byType(AppFilledButton));
+ await tester.pump();
+
+ verify(() => kycCubit.checkKyc()).called(1);
+ });
+
+ // The step identifier is an internal enum value; surfacing it told the user nothing and was the
+ // defect this page replaces.
+ testWidgets('never renders an internal step identifier', (tester) async {
+ await tester.pumpApp(subject());
+
+ for (final name in KycStepName.values) {
+ expect(
+ find.textContaining(name.value),
+ findsNothing,
+ reason: 'the wire identifier ${name.value} must not reach the UI',
+ );
+ }
+ });
+ });
+}
diff --git a/test/screens/kyc_bitbox_create_wallet_states_test.dart b/test/screens/kyc_bitbox_create_wallet_states_test.dart
index 2fb1438da..4e2b9d442 100644
--- a/test/screens/kyc_bitbox_create_wallet_states_test.dart
+++ b/test/screens/kyc_bitbox_create_wallet_states_test.dart
@@ -9,13 +9,14 @@ const _testSeed =
void main() {
group('$KycState', () {
- test('KycStep enum has all 10 documented variants', () {
+ test('KycStep enum has all 11 documented variants', () {
// The KycCubit advances through these steps in order; pin the set so a
// refactor that drops one surfaces here.
expect(KycStep.values.toSet(), {
KycStep.email,
KycStep.confirmEmail,
KycStep.registration,
+ KycStep.personalData,
KycStep.linkWallet,
KycStep.legalDisclaimer,
KycStep.nationality,
diff --git a/test/screens/settings/settings_bloc_test.dart b/test/screens/settings/settings_bloc_test.dart
index c84bd2859..525cff27e 100644
--- a/test/screens/settings/settings_bloc_test.dart
+++ b/test/screens/settings/settings_bloc_test.dart
@@ -20,6 +20,7 @@ void main() {
when(() => repo.language).thenReturn('en');
when(() => repo.currency).thenReturn('CHF');
when(() => repo.networkMode).thenReturn(NetworkMode.mainnet);
+ when(() => repo.insiderFeaturesUnlocked).thenReturn(false);
});
SettingsBloc build() => SettingsBloc(
@@ -34,6 +35,7 @@ void main() {
when(() => repo.language).thenReturn('de');
when(() => repo.currency).thenReturn('EUR');
when(() => repo.networkMode).thenReturn(NetworkMode.testnet);
+ when(() => repo.insiderFeaturesUnlocked).thenReturn(true);
final bloc = build();
@@ -41,6 +43,7 @@ void main() {
expect(bloc.state.currency, Currency.eur);
expect(bloc.state.networkMode, NetworkMode.testnet);
expect(bloc.state.hideAmounts, isFalse);
+ expect(bloc.state.insiderFeaturesUnlocked, isTrue);
});
blocTest(
@@ -123,5 +126,15 @@ void main() {
expect(bloc.state.hideAmounts, isTrue);
verifyNever(() => repo.language = any()); // proxy: no repo call at all
});
+
+ blocTest(
+ 'UnlockInsiderFeaturesEvent persists to the repo and emits insiderFeaturesUnlocked=true',
+ build: build,
+ act: (bloc) => bloc.add(const UnlockInsiderFeaturesEvent()),
+ verify: (bloc) {
+ expect(bloc.state.insiderFeaturesUnlocked, isTrue);
+ verify(() => repo.insiderFeaturesUnlocked = true).called(1);
+ },
+ );
});
}
diff --git a/test/screens/settings/settings_event_test.dart b/test/screens/settings/settings_event_test.dart
index def20d394..72015be82 100644
--- a/test/screens/settings/settings_event_test.dart
+++ b/test/screens/settings/settings_event_test.dart
@@ -82,6 +82,18 @@ void main() {
});
});
+ group('UnlockInsiderFeaturesEvent', () {
+ test('all instances are equal (singleton-style event, no payload)', () {
+ const a = UnlockInsiderFeaturesEvent();
+ const b = UnlockInsiderFeaturesEvent();
+
+ expect(a, equals(b));
+ expect(a.hashCode, b.hashCode);
+ // Inherits the empty `props` list from the sealed base class.
+ expect(a.props, isEmpty);
+ });
+ });
+
group('SettingsEvent (cross-subclass identity)', () {
test('different subclasses are not equal even when props happen to match', () {
// Two payload-less events from different subclasses must still compare
@@ -105,16 +117,19 @@ void main() {
final cur = SetCurrencyEvent(Currency.eur);
final net = SetNetworkModeEvent(NetworkMode.mainnet);
final toggle = ToggleHideAmountEvent();
+ final unlock = UnlockInsiderFeaturesEvent();
expect(lang, equals(const SetLanguageEvent(Language.de)));
expect(cur, equals(const SetCurrencyEvent(Currency.eur)));
expect(net, equals(const SetNetworkModeEvent(NetworkMode.mainnet)));
expect(toggle, equals(const ToggleHideAmountEvent()));
+ expect(unlock, equals(const UnlockInsiderFeaturesEvent()));
expect(lang.props, [Language.de]);
expect(cur.props, [Currency.eur]);
expect(net.props, [NetworkMode.mainnet]);
expect(toggle.props, isEmpty);
+ expect(unlock.props, isEmpty);
});
});
}
diff --git a/test/screens/settings/widgets/settings_version_unlock_test.dart b/test/screens/settings/widgets/settings_version_unlock_test.dart
new file mode 100644
index 000000000..a29d4a3b5
--- /dev/null
+++ b/test/screens/settings/widgets/settings_version_unlock_test.dart
@@ -0,0 +1,115 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:get_it/get_it.dart';
+import 'package:mocktail/mocktail.dart';
+import 'package:realunit_wallet/generated/i18n.dart';
+import 'package:realunit_wallet/screens/settings/bloc/settings_bloc.dart';
+import 'package:realunit_wallet/screens/settings/widgets/settings_version_unlock.dart';
+
+import '../../../helper/helper.dart';
+
+void main() {
+ late MockSettingsBloc settingsBloc;
+
+ setUpAll(() {
+ GetIt.instance.registerSingleton(MockSettingsBloc());
+ });
+
+ tearDownAll(() async {
+ await GetIt.instance.reset();
+ });
+
+ setUp(() {
+ settingsBloc = MockSettingsBloc();
+ when(() => settingsBloc.state).thenReturn(const SettingsState());
+ });
+
+ Widget host() {
+ // Re-register the per-test mock into GetIt so getIt() inside the
+ // widget picks up the state defined in setUp(). setUpAll() only registers once.
+ if (GetIt.instance.isRegistered()) {
+ GetIt.instance.unregister();
+ }
+ GetIt.instance.registerSingleton(settingsBloc);
+
+ return const Scaffold(
+ body: SettingsVersionUnlock(releaseTag: '1.2.3'),
+ );
+ }
+
+ group('$SettingsVersionUnlock', () {
+ testWidgets('displays the version text', (tester) async {
+ await tester.pumpApp(host());
+
+ expect(find.textContaining('1.2.3'), findsOneWidget);
+ });
+
+ testWidgets('6 taps: no event dispatched, no SnackBar shown', (tester) async {
+ await tester.pumpApp(host());
+
+ for (var i = 0; i < 6; i++) {
+ await tester.tap(find.byType(SettingsVersionUnlock));
+ await tester.pump();
+ }
+
+ verifyNever(() => settingsBloc.add(const UnlockInsiderFeaturesEvent()));
+ expect(find.byType(SnackBar), findsNothing);
+ });
+
+ testWidgets(
+ '7th tap dispatches UnlockInsiderFeaturesEvent exactly once and shows the SnackBar',
+ (tester) async {
+ await tester.pumpApp(host());
+
+ for (var i = 0; i < 7; i++) {
+ await tester.tap(find.byType(SettingsVersionUnlock));
+ await tester.pump();
+ }
+
+ verify(() => settingsBloc.add(const UnlockInsiderFeaturesEvent())).called(1);
+ expect(find.byType(SnackBar), findsOneWidget);
+ expect(find.text(S.current.settingsInsiderFeaturesUnlocked), findsOneWidget);
+ },
+ );
+
+ testWidgets('already unlocked: taps trigger neither event nor SnackBar', (tester) async {
+ when(() => settingsBloc.state)
+ .thenReturn(const SettingsState(insiderFeaturesUnlocked: true));
+
+ await tester.pumpApp(host());
+
+ for (var i = 0; i < 10; i++) {
+ await tester.tap(find.byType(SettingsVersionUnlock));
+ await tester.pump();
+ }
+
+ verifyNever(() => settingsBloc.add(const UnlockInsiderFeaturesEvent()));
+ expect(find.byType(SnackBar), findsNothing);
+ });
+
+ testWidgets('already unlocked: version text is still displayed', (tester) async {
+ when(() => settingsBloc.state)
+ .thenReturn(const SettingsState(insiderFeaturesUnlocked: true));
+
+ await tester.pumpApp(host());
+
+ expect(find.textContaining('1.2.3'), findsOneWidget);
+ });
+
+ testWidgets(
+ 'nine taps still dispatch UnlockInsiderFeaturesEvent exactly once '
+ '(pins the == 7 comparison, a regression to >= 7 would fire on every tap after)',
+ (tester) async {
+ await tester.pumpApp(host());
+
+ for (var i = 0; i < 9; i++) {
+ await tester.tap(find.byType(SettingsVersionUnlock));
+ await tester.pump();
+ }
+
+ verify(() => settingsBloc.add(const UnlockInsiderFeaturesEvent())).called(1);
+ expect(find.byType(SnackBar), findsOneWidget);
+ },
+ );
+ });
+}
diff --git a/test/screens/settings_currencies/settings_currencies_page_test.dart b/test/screens/settings_currencies/settings_currencies_page_test.dart
index c46d2b01c..e7537f0ae 100644
--- a/test/screens/settings_currencies/settings_currencies_page_test.dart
+++ b/test/screens/settings_currencies/settings_currencies_page_test.dart
@@ -26,6 +26,7 @@ void main() {
when(() => settingsRepo.language).thenReturn('en');
when(() => settingsRepo.currency).thenReturn('CHF');
when(() => settingsRepo.networkMode).thenReturn(NetworkMode.mainnet);
+ when(() => settingsRepo.insiderFeaturesUnlocked).thenReturn(false);
settingsBloc = SettingsBloc(settingsRepo, () async {});
final getIt = GetIt.instance;
diff --git a/test/screens/settings_languages/settings_languages_page_test.dart b/test/screens/settings_languages/settings_languages_page_test.dart
index 9a4e6e15e..3d3b58f58 100644
--- a/test/screens/settings_languages/settings_languages_page_test.dart
+++ b/test/screens/settings_languages/settings_languages_page_test.dart
@@ -27,6 +27,7 @@ void main() {
when(() => settingsRepo.language).thenReturn('en');
when(() => settingsRepo.currency).thenReturn('CHF');
when(() => settingsRepo.networkMode).thenReturn(NetworkMode.mainnet);
+ when(() => settingsRepo.insiderFeaturesUnlocked).thenReturn(false);
settingsBloc = SettingsBloc(settingsRepo, () async {});
final getIt = GetIt.instance;
diff --git a/test/setup/error_handling/crash_reporting_test.dart b/test/setup/error_handling/crash_reporting_test.dart
new file mode 100644
index 000000000..b1aa3a1b6
--- /dev/null
+++ b/test/setup/error_handling/crash_reporting_test.dart
@@ -0,0 +1,57 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:sentry_flutter/sentry_flutter.dart';
+import 'package:realunit_wallet/setup/error_handling/crash_reporting.dart';
+
+void main() {
+ group('initCrashReporting', () {
+ test('stays a no-op when no DSN is injected', () async {
+ var initCalls = 0;
+
+ await initCrashReporting(
+ dsn: '',
+ init: (_) async => initCalls++,
+ );
+
+ expect(initCalls, 0);
+ });
+
+ test('starts the reporter exactly once when a DSN is injected', () async {
+ var initCalls = 0;
+
+ await initCrashReporting(
+ dsn: 'https://key@reporting.invalid/1',
+ init: (_) async => initCalls++,
+ );
+
+ expect(initCalls, 1);
+ });
+
+ test('applies the pinned option set', () async {
+ final options = SentryFlutterOptions();
+ late FlutterOptionsConfiguration configuration;
+
+ await initCrashReporting(
+ dsn: 'https://key@reporting.invalid/1',
+ init: (config) async => configuration = config,
+ );
+ await configuration(options);
+
+ expect(options.dsn, 'https://key@reporting.invalid/1');
+ expect(options.environment, crashReportingEnvironment);
+ expect(options.sendDefaultPii, isFalse);
+ expect(options.attachScreenshot, isFalse);
+ expect(options.enableAutoSessionTracking, isFalse);
+ expect(options.tracesSampleRate, isNull);
+ });
+
+ test('swallows a failing reporter init instead of blocking startup', () async {
+ await expectLater(
+ initCrashReporting(
+ dsn: 'https://key@reporting.invalid/1',
+ init: (_) async => throw StateError('native binding unavailable'),
+ ),
+ completes,
+ );
+ });
+ });
+}