From 2e81c43091094d010cb4364b9f9290ede6c70798 Mon Sep 17 00:00:00 2001 From: Stephan D Date: Sat, 12 Sep 2026 10:05:20 +0200 Subject: [PATCH 1/5] Display used and reserved limits capacity --- docs/implementation.md | 15 ++++ docs/ux/kyc.md | 15 ++++ .../lib/generated/i18n/app_localizations.dart | 30 +++++++ .../generated/i18n/app_localizations_en.dart | 16 ++++ .../generated/i18n/app_localizations_ru.dart | 16 ++++ frontend/app/lib/kyc/limits.dart | 25 ++++++ frontend/app/lib/l10n/en.arb | 7 +- frontend/app/lib/l10n/ru.arb | 7 +- frontend/app/test/limits_test.dart | 86 +++++++++++++++++++ frontend/packages/sxchnge_kyc/README.md | 5 ++ .../sxchnge_kyc/lib/src/models/limits.dart | 9 ++ .../sxchnge_kyc/lib/src/services/dummy.dart | 8 +- .../lib/src/transport/contracts.dart | 6 +- .../sxchnge_kyc/test/limits_test.dart | 47 ++++++++++ 14 files changed, 287 insertions(+), 5 deletions(-) create mode 100644 frontend/app/test/limits_test.dart create mode 100644 frontend/packages/sxchnge_kyc/test/limits_test.dart diff --git a/docs/implementation.md b/docs/implementation.md index 4743504..12e29a3 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -168,3 +168,18 @@ Documentation only; no runtime tests were run. server-side review analytics remain integration work. Native mobile and VoiceOver/TalkBack checks remain release QA. Security scans were not requested. - Delivery: [PR #21](https://git.sendico.io/tech/sxchnge/pulls/21), pushed on `kyc-20`; not merged. Final browser review also preserved heading semantics when moving keyboard focus. + +## Limits usage display — task #22 + +- Branch `limits-22`, stacked on `kyc-20` / PR #21 (merge #21 first). +- Added explicit used/committed and reserved fields through DTO, mapper, service + and model. Remaining is independently supplied by the backend stand-in. +- Daily/monthly cards show used, reserved, available, cap and upgrade amounts, + with a labeled theme-based occupied-capacity indicator and exhausted text. + Dummy fixtures contain nonzero activity; usage survives tier increases. +- UX review: EN/RU labels explain pending holds, no color-only information, + 320px/1.6-scale widget checks, actual browser visual verification, zero and + over-cap ratio handling. Existing KYC telemetry is sufficient; no usage amounts + are sent to analytics. +- Validation passed: all 91 frontend tests, analyze app and sxchnge_kyc, + format check of all 9 affected Dart files, web build and diff checks. diff --git a/docs/ux/kyc.md b/docs/ux/kyc.md index b5518d4..554e098 100644 --- a/docs/ux/kyc.md +++ b/docs/ux/kyc.md @@ -147,3 +147,18 @@ packages, `make test-frontend` (86 tests) and release web build passed. The UX checklist was reviewed against the implemented demo scope; production/backend and native assistive-technology gaps are recorded above rather than represented as complete. + +## Limits usage correction — task #22 + +Visible limits include backend-provided committed (`usedMinor`) and reserved +(`reservedMinor`) usage, matching Sendico's `VisibleLimitSlot`. Show both amounts, +the current cap and authoritative remaining capacity for each period. A labeled +theme-based indicator visualizes used plus reserved against the cap; clamp only +the visual ratio, never rewrite the returned monetary values. No capacity is +communicated in text when remaining is zero. Daily/monthly demo fixtures now have +nonzero activity, retained when caps increase after approval. + +This is a display correction within the existing KYC journey, so no additional +analytics events or financial data collection are introduced. Tests cover mapping +without inferring remaining, unused/exhausted/over-cap/zero-cap ratios and EN/RU +usage display at 320px with 1.6 text scaling. diff --git a/frontend/app/lib/generated/i18n/app_localizations.dart b/frontend/app/lib/generated/i18n/app_localizations.dart index 7188c75..32dec96 100644 --- a/frontend/app/lib/generated/i18n/app_localizations.dart +++ b/frontend/app/lib/generated/i18n/app_localizations.dart @@ -991,6 +991,36 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Status could not be refreshed. The status shown is the last known result.'** String get kycStatusStale; + + /// No description provided for @kycUsed. + /// + /// In en, this message translates to: + /// **'Used'** + String get kycUsed; + + /// No description provided for @kycReserved. + /// + /// In en, this message translates to: + /// **'Reserved'** + String get kycReserved; + + /// No description provided for @kycUsageHelp. + /// + /// In en, this message translates to: + /// **'Used is completed activity. Reserved is held for pending transactions. Both count toward your limit.'** + String get kycUsageHelp; + + /// No description provided for @kycOccupied. + /// + /// In en, this message translates to: + /// **'Used + reserved'** + String get kycOccupied; + + /// No description provided for @kycNoCapacity. + /// + /// In en, this message translates to: + /// **'No capacity available'** + String get kycNoCapacity; } class _AppLocalizationsDelegate diff --git a/frontend/app/lib/generated/i18n/app_localizations_en.dart b/frontend/app/lib/generated/i18n/app_localizations_en.dart index 626a86b..be62464 100644 --- a/frontend/app/lib/generated/i18n/app_localizations_en.dart +++ b/frontend/app/lib/generated/i18n/app_localizations_en.dart @@ -502,4 +502,20 @@ class AppLocalizationsEn extends AppLocalizations { @override String get kycStatusStale => 'Status could not be refreshed. The status shown is the last known result.'; + + @override + String get kycUsed => 'Used'; + + @override + String get kycReserved => 'Reserved'; + + @override + String get kycUsageHelp => + 'Used is completed activity. Reserved is held for pending transactions. Both count toward your limit.'; + + @override + String get kycOccupied => 'Used + reserved'; + + @override + String get kycNoCapacity => 'No capacity available'; } diff --git a/frontend/app/lib/generated/i18n/app_localizations_ru.dart b/frontend/app/lib/generated/i18n/app_localizations_ru.dart index 9e856f0..b85054f 100644 --- a/frontend/app/lib/generated/i18n/app_localizations_ru.dart +++ b/frontend/app/lib/generated/i18n/app_localizations_ru.dart @@ -503,4 +503,20 @@ class AppLocalizationsRu extends AppLocalizations { @override String get kycStatusStale => 'Не удалось обновить статус. Показан последний известный результат.'; + + @override + String get kycUsed => 'Использовано'; + + @override + String get kycReserved => 'Зарезервировано'; + + @override + String get kycUsageHelp => + 'Использовано — завершённые операции. Зарезервировано — суммы, удержанные для ожидающих операций. Обе суммы учитываются в лимите.'; + + @override + String get kycOccupied => 'Использовано + зарезервировано'; + + @override + String get kycNoCapacity => 'Доступный объём исчерпан'; } diff --git a/frontend/app/lib/kyc/limits.dart b/frontend/app/lib/kyc/limits.dart index 724ba85..2e39513 100644 --- a/frontend/app/lib/kyc/limits.dart +++ b/frontend/app/lib/kyc/limits.dart @@ -49,6 +49,8 @@ class LimitsPanel extends StatelessWidget { ), if (data.simulated) Text(l.kycLimitsDemo, style: t.textTheme.bodySmall), + const SizedBox(height: 12), + Text(l.kycUsageHelp, style: t.textTheme.bodySmall), if (data.rows.isEmpty) Text(l.kycLimitsEmpty), for (final row in data.rows) ...[ const Divider(height: 32), @@ -67,6 +69,13 @@ class LimitsPanel extends StatelessWidget { row.currentMinor, row.currency, ), + _amount(context, l.kycUsed, row.usedMinor, row.currency), + _amount( + context, + l.kycReserved, + row.reservedMinor, + row.currency, + ), _amount( context, l.kycRemaining, @@ -82,6 +91,22 @@ class LimitsPanel extends StatelessWidget { ), ], ), + const SizedBox(height: 16), + LinearProgressIndicator( + key: ValueKey('usage-${row.period.name}'), + value: row.occupiedFraction, + minHeight: 8, + borderRadius: BorderRadius.circular(4), + semanticsLabel: + '${row.period == LimitPeriod.day ? l.kycDay : l.kycMonth}: ${l.kycOccupied}', + ), + if (row.remainingMinor <= 0) ...[ + const SizedBox(height: 8), + Text( + l.kycNoCapacity, + style: TextStyle(color: t.colorScheme.error), + ), + ], ], ], ], diff --git a/frontend/app/lib/l10n/en.arb b/frontend/app/lib/l10n/en.arb index af54b29..3cf71c8 100644 --- a/frontend/app/lib/l10n/en.arb +++ b/frontend/app/lib/l10n/en.arb @@ -148,5 +148,10 @@ "kycDemoDeny": "Decline", "kycPreview": "Explore verification & limits", "kycChooseFirst": "Choose a photo to continue.", - "kycStatusStale": "Status could not be refreshed. The status shown is the last known result." + "kycStatusStale": "Status could not be refreshed. The status shown is the last known result.", + "kycUsed": "Used", + "kycReserved": "Reserved", + "kycUsageHelp": "Used is completed activity. Reserved is held for pending transactions. Both count toward your limit.", + "kycOccupied": "Used + reserved", + "kycNoCapacity": "No capacity available" } diff --git a/frontend/app/lib/l10n/ru.arb b/frontend/app/lib/l10n/ru.arb index 21eb357..b967206 100644 --- a/frontend/app/lib/l10n/ru.arb +++ b/frontend/app/lib/l10n/ru.arb @@ -148,5 +148,10 @@ "kycDemoDeny": "Отклонить", "kycPreview": "Посмотреть верификацию и лимиты", "kycChooseFirst": "Выберите фото для продолжения.", - "kycStatusStale": "Не удалось обновить статус. Показан последний известный результат." + "kycStatusStale": "Не удалось обновить статус. Показан последний известный результат.", + "kycUsed": "Использовано", + "kycReserved": "Зарезервировано", + "kycUsageHelp": "Использовано — завершённые операции. Зарезервировано — суммы, удержанные для ожидающих операций. Обе суммы учитываются в лимите.", + "kycOccupied": "Использовано + зарезервировано", + "kycNoCapacity": "Доступный объём исчерпан" } diff --git a/frontend/app/test/limits_test.dart b/frontend/app/test/limits_test.dart new file mode 100644 index 0000000..553bebd --- /dev/null +++ b/frontend/app/test/limits_test.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:provider/provider.dart'; + +import 'package:sxchnge_kyc/sxchnge_kyc.dart'; + +import 'package:sxchnge_theme/sxchnge_theme.dart'; + +import 'package:sxchnge_app/kyc/limits.dart'; + +import 'package:sxchnge_app/generated/i18n/app_localizations.dart'; + +class Service implements LimitsService { + Service(this.row); + final VisibleLimit row; + @override + Future fetch() async => + Limits(tier: AccountTier.basic, rows: [row], simulated: true); +} + +void main() { + for (final locale in ['en', 'ru']) { + testWidgets( + 'usage and authoritative available capacity fit narrow $locale layout', + (tester) async { + tester.view.physicalSize = const Size(320, 1800); + tester.view.devicePixelRatio = 1; + tester.platformDispatcher.textScaleFactorTestValue = 1.6; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + final provider = LimitsProvider( + Service( + const VisibleLimit( + period: LimitPeriod.day, + currency: 'EUR', + basicMinor: 10000, + verifiedMinor: 250000, + currentMinor: 10000, + remainingMinor: 5500, + usedMinor: 3000, + reservedMinor: 1000, + ), + ), + ); + addTearDown(provider.dispose); + await provider.refresh(); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: provider, + child: MaterialApp( + theme: SxchngeTheme.light(), + locale: Locale(locale), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const Scaffold( + body: SingleChildScrollView(child: LimitsPanel()), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect( + find.text(locale == 'en' ? 'Used' : 'Использовано'), + findsOneWidget, + ); + expect( + find.text(locale == 'en' ? 'Reserved' : 'Зарезервировано'), + findsOneWidget, + ); + expect( + tester + .widget( + find.byKey(const ValueKey('usage-day')), + ) + .value, + 0.4, + ); + expect(find.textContaining('55'), findsOneWidget); + expect(tester.takeException(), null); + }, + ); + } +} diff --git a/frontend/packages/sxchnge_kyc/README.md b/frontend/packages/sxchnge_kyc/README.md index 18181be..afe46bf 100644 --- a/frontend/packages/sxchnge_kyc/README.md +++ b/frontend/packages/sxchnge_kyc/README.md @@ -36,3 +36,8 @@ Photo selection uses Flutter's [file_selector](https://pub.dev/packages/file_sel and [image metadata decoding](https://pub.dev/documentation/image/latest/image/Decoder-class.html). For native macOS consumers enable the user-selected read-only file entitlement; iOS/Android camera permissions are not required for this file-picker-only flow. + +Visible limit rows also expose `usedMinor` (committed activity) and `reservedMinor` +(pending holds). `remainingMinor` is independently provided by the backend and +must not be inferred as cap minus those fields. The display-only occupied ratio +includes used plus reserved and is bounded for progress rendering. diff --git a/frontend/packages/sxchnge_kyc/lib/src/models/limits.dart b/frontend/packages/sxchnge_kyc/lib/src/models/limits.dart index 6e58621..476e028 100644 --- a/frontend/packages/sxchnge_kyc/lib/src/models/limits.dart +++ b/frontend/packages/sxchnge_kyc/lib/src/models/limits.dart @@ -10,6 +10,8 @@ class VisibleLimit { required this.verifiedMinor, required this.currentMinor, required this.remainingMinor, + required this.usedMinor, + required this.reservedMinor, }); final LimitPeriod period; final String currency; @@ -17,6 +19,13 @@ class VisibleLimit { final int verifiedMinor; final int currentMinor; final int remainingMinor; + final int usedMinor; + final int reservedMinor; + + /// Presentation only: remaining capacity and payment authorization stay server-owned. + double get occupiedFraction => currentMinor <= 0 + ? 0 + : ((usedMinor + reservedMinor) / currentMinor).clamp(0.0, 1.0); } class Limits { diff --git a/frontend/packages/sxchnge_kyc/lib/src/services/dummy.dart b/frontend/packages/sxchnge_kyc/lib/src/services/dummy.dart index 9dfef29..6b9a3ba 100644 --- a/frontend/packages/sxchnge_kyc/lib/src/services/dummy.dart +++ b/frontend/packages/sxchnge_kyc/lib/src/services/dummy.dart @@ -76,7 +76,9 @@ class DummyLimitsService implements LimitsService { 10000, 250000, approved ? 250000 : 10000, - approved ? 250000 : 10000, + (approved ? 250000 : 10000) - 4000, + 3000, + 1000, ), LimitDTO( 'month', @@ -84,7 +86,9 @@ class DummyLimitsService implements LimitsService { 50000, 1000000, approved ? 1000000 : 50000, - approved ? 1000000 : 50000, + (approved ? 1000000 : 50000) - 19000, + 18000, + 1000, ), ], ), diff --git a/frontend/packages/sxchnge_kyc/lib/src/transport/contracts.dart b/frontend/packages/sxchnge_kyc/lib/src/transport/contracts.dart index b15337b..04bde34 100644 --- a/frontend/packages/sxchnge_kyc/lib/src/transport/contracts.dart +++ b/frontend/packages/sxchnge_kyc/lib/src/transport/contracts.dart @@ -38,10 +38,12 @@ class LimitDTO { this.verified, this.current, this.remaining, + this.used, + this.reserved, ); final String period; final String currency; - final int basic, verified, current, remaining; + final int basic, verified, current, remaining, used, reserved; } class LimitsMapper { @@ -57,6 +59,8 @@ class LimitsMapper { verifiedMinor: r.verified, currentMinor: r.current, remainingMinor: r.remaining, + usedMinor: r.used, + reservedMinor: r.reserved, ), ) .toList(), diff --git a/frontend/packages/sxchnge_kyc/test/limits_test.dart b/frontend/packages/sxchnge_kyc/test/limits_test.dart new file mode 100644 index 0000000..4ec6978 --- /dev/null +++ b/frontend/packages/sxchnge_kyc/test/limits_test.dart @@ -0,0 +1,47 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:sxchnge_kyc/sxchnge_kyc.dart'; +import 'package:sxchnge_kyc/src/transport/contracts.dart'; + +void main() { + test('mapping preserves backend usage and independent remaining value', () { + final data = LimitsMapper.fromDTO( + const LimitsDTO( + tier: 'basic', + simulated: false, + rows: [LimitDTO('day', 'EUR', 10000, 250000, 10000, 5500, 3000, 1000)], + ), + ); + final row = data.rows.single; + expect(row.usedMinor, 3000); + expect(row.reservedMinor, 1000); + expect(row.remainingMinor, 5500); + expect(row.occupiedFraction, 0.4); + }); + test('capacity visualization handles unused, exhausted and zero caps', () { + VisibleLimit row(int cap, int used, int reserved) => VisibleLimit( + period: LimitPeriod.day, + currency: 'EUR', + basicMinor: cap, + verifiedMinor: cap, + currentMinor: cap, + remainingMinor: 0, + usedMinor: used, + reservedMinor: reserved, + ); + expect(row(10000, 0, 0).occupiedFraction, 0); + expect(row(10000, 8000, 2000).occupiedFraction, 1); + expect(row(10000, 12000, 2000).occupiedFraction, 1); + expect(row(0, 0, 0).occupiedFraction, 0); + }); + test('stub provides nonzero daily and monthly usage', () async { + final identity = DummyIdentityService(delay: Duration.zero); + final service = DummyLimitsService(identity); + final data = await service.fetch(); + expect(data.rows.first.usedMinor, 3000); + expect(data.rows.first.reservedMinor, 1000); + expect(data.rows.first.remainingMinor, 6000); + expect(data.rows.last.usedMinor, 18000); + expect(data.rows.last.remainingMinor, 31000); + }); +} -- 2.54.0 From 59b6c075124ed74882b44c9cf1f758735f3b59e5 Mon Sep 17 00:00:00 2001 From: Stephan D Date: Sat, 12 Sep 2026 10:05:51 +0200 Subject: [PATCH 2/5] Record limits usage delivery and PR dependency --- docs/implementation.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/implementation.md b/docs/implementation.md index 12e29a3..9943303 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -183,3 +183,4 @@ Documentation only; no runtime tests were run. are sent to analytics. - Validation passed: all 91 frontend tests, analyze app and sxchnge_kyc, format check of all 9 affected Dart files, web build and diff checks. +- Delivery: [PR #23](https://git.sendico.io/tech/sxchnge/pulls/23), unmerged. Merge #21 first and retarget #23 to main. -- 2.54.0 From c496da8a558de57885e4538e4074517a23716592 Mon Sep 17 00:00:00 2001 From: Stephan D Date: Sat, 12 Sep 2026 12:01:36 +0200 Subject: [PATCH 3/5] Build exchange workspace and shared transaction services (#28) --- Makefile | 1 + README.md | 20 +- docs/implementation.md | 39 ++ docs/ux/exchange-workspace.md | 409 ++++++++++++++ frontend/app/lib/auth/form.dart | 4 +- frontend/app/lib/auth/page.dart | 13 +- .../lib/generated/i18n/app_localizations.dart | 522 ++++++++++++++++++ .../generated/i18n/app_localizations_en.dart | 288 ++++++++++ .../generated/i18n/app_localizations_ru.dart | 285 ++++++++++ frontend/app/lib/kyc/limits.dart | 8 +- frontend/app/lib/kyc/page.dart | 19 +- frontend/app/lib/l10n/en.arb | 89 ++- frontend/app/lib/l10n/ru.arb | 89 ++- frontend/app/lib/main.dart | 16 +- frontend/app/lib/providers/locale.dart | 25 + frontend/app/lib/routing/router.dart | 90 ++- frontend/app/lib/verification/page.dart | 9 +- frontend/app/lib/widgets/locale.dart | 54 -- frontend/app/lib/workspace/accounts.dart | 245 ++++++++ frontend/app/lib/workspace/activity.dart | 196 +++++++ frontend/app/lib/workspace/copy.dart | 52 ++ frontend/app/lib/workspace/limits.dart | 80 +++ frontend/app/lib/workspace/operation.dart | 152 +++++ frontend/app/lib/workspace/settings.dart | 132 +++++ frontend/app/lib/workspace/shell.dart | 134 +++++ frontend/app/lib/workspace/summary.dart | 84 +++ frontend/app/lib/workspace/transfer.dart | 310 +++++++++++ frontend/app/pubspec.lock | 7 + frontend/app/pubspec.yaml | 2 + frontend/app/test/kyc_test.dart | 2 +- frontend/app/test/limits_test.dart | 6 +- frontend/app/test/localization_test.dart | 10 +- frontend/app/test/verification_test.dart | 15 +- frontend/app/test/workspace_test.dart | 220 ++++++++ .../lib/sxchnge_analytics.dart | 23 +- .../sxchnge_kyc/lib/limits_transport.dart | 2 + frontend/packages/sxchnge_wallet/.gitignore | 3 + frontend/packages/sxchnge_wallet/README.md | 48 ++ .../sxchnge_wallet/lib/src/models/wallet.dart | 113 ++++ .../lib/src/providers/limits.dart | 25 + .../lib/src/providers/transfer.dart | 213 +++++++ .../lib/src/providers/wallet.dart | 150 +++++ .../lib/src/services/contracts.dart | 13 + .../lib/src/services/dummy.dart | 259 +++++++++ .../lib/src/transport/wallet.dart | 89 +++ .../sxchnge_wallet/lib/sxchnge_wallet.dart | 6 + frontend/packages/sxchnge_wallet/pubspec.yaml | 17 + .../sxchnge_wallet/test/wallet_test.dart | 289 ++++++++++ 48 files changed, 4757 insertions(+), 120 deletions(-) create mode 100644 docs/ux/exchange-workspace.md delete mode 100644 frontend/app/lib/widgets/locale.dart create mode 100644 frontend/app/lib/workspace/accounts.dart create mode 100644 frontend/app/lib/workspace/activity.dart create mode 100644 frontend/app/lib/workspace/copy.dart create mode 100644 frontend/app/lib/workspace/limits.dart create mode 100644 frontend/app/lib/workspace/operation.dart create mode 100644 frontend/app/lib/workspace/settings.dart create mode 100644 frontend/app/lib/workspace/shell.dart create mode 100644 frontend/app/lib/workspace/summary.dart create mode 100644 frontend/app/lib/workspace/transfer.dart create mode 100644 frontend/app/test/workspace_test.dart create mode 100644 frontend/packages/sxchnge_kyc/lib/limits_transport.dart create mode 100644 frontend/packages/sxchnge_wallet/.gitignore create mode 100644 frontend/packages/sxchnge_wallet/README.md create mode 100644 frontend/packages/sxchnge_wallet/lib/src/models/wallet.dart create mode 100644 frontend/packages/sxchnge_wallet/lib/src/providers/limits.dart create mode 100644 frontend/packages/sxchnge_wallet/lib/src/providers/transfer.dart create mode 100644 frontend/packages/sxchnge_wallet/lib/src/providers/wallet.dart create mode 100644 frontend/packages/sxchnge_wallet/lib/src/services/contracts.dart create mode 100644 frontend/packages/sxchnge_wallet/lib/src/services/dummy.dart create mode 100644 frontend/packages/sxchnge_wallet/lib/src/transport/wallet.dart create mode 100644 frontend/packages/sxchnge_wallet/lib/sxchnge_wallet.dart create mode 100644 frontend/packages/sxchnge_wallet/pubspec.yaml create mode 100644 frontend/packages/sxchnge_wallet/test/wallet_test.dart diff --git a/Makefile b/Makefile index 79b83f4..0652fcd 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,7 @@ .PHONY: test-frontend test-frontend: + cd frontend/packages/sxchnge_wallet && flutter test cd frontend/packages/sxchnge_kyc && flutter test cd frontend/packages/sxchnge_analytics && flutter test cd frontend/packages/sxchnge_auth && flutter test diff --git a/README.md b/README.md index e0083c6..da77542 100644 --- a/README.md +++ b/README.md @@ -172,8 +172,7 @@ limitations in the task and [implementation record](docs/implementation.md). ## Identity verification preview -Open `/#/identity` or select **Explore verification & limits** from login or email -confirmation. The optional flow covers passport and selfie selection, review, +Open `/#/identity` or use **Limits → Verify identity** in the workspace. The optional flow covers passport and selfie selection, review, submission, pending review, approval, correction requests and denial in EN/RU. Use sample images only: photos are never uploaded and drafts are not persisted. @@ -184,3 +183,20 @@ review decisions demonstrate the future backend contract. Higher limits apply only when returned by that service. Production review/storage/support and financial authorization are not connected. See the [UX plan](docs/ux/kyc.md) for state, error, privacy and measurement details. + +## Exchange workspace preview + +Choose **Explore your accounts** on login/email confirmation, or open `/#/accounts`. +Currency cards show only available funds. Select an account, then top up, exchange +or pay out through input → review → status. Review shows fees and actual debit and +credit; status explains where the money is, including processing and failed returns. +Activity, account balances and limits use one coherent demo ledger. + +Settings owns English/Russian account language. Limits links to optional KYC; +no verification remains a valid account state with basic limits. + +The shared [wallet package](frontend/packages/sxchnge_wallet/README.md) is reusable +by mobile clients. Transactions, rates, fees, recipients and profile storage are +explicitly simulated and reset on reload. No real money moves. See the +[workspace UX plan](docs/ux/exchange-workspace.md) and +[implementation record](docs/implementation.md) for scope and validation. diff --git a/docs/implementation.md b/docs/implementation.md index c7af80d..7e4b922 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -195,3 +195,42 @@ essential information, accessibility, and recovery. Documentation only. Validation: skill-creator `quick_validate.py` passed in an isolated environment with PyYAML (the system Python lacked it); `git diff --check` passed. No runtime tests were needed. The PR is not merged. + +## Exchange workspace — task #28 + +- Branch `workspace-28`, stacked on `limits-22` / PR #23. Merge KYC #21 first, + then limits #23, then retarget this workspace contribution to main. The approved + plan from #24/#25 is included and updated for the user's available-only direction. +- Implemented Accounts, Activity/detail, Limits and Settings in a responsive + go_router shell, with top-up, exchange and payout input/review/status flows. + One action group follows the selected currency; all colors use theme roles. + Only available balances are displayed. Status explains credit timing, outgoing + amount plus fee, completion and full return after failure. +- Shared `sxchnge_wallet` service/providers own coherent demo balances, quotes, + idempotent submissions, reconciliation, history, account language and live + snapshot refresh. The limits adapter reuses the KYC DTO/mapper and refreshes + usage when operations change. Widgets consume provider models. +- Language is in account settings, with failed-save feedback and retry. Public + auth and verified-email screens enter the workspace. KYC remains optional; + the demo service enforces its basic/verified outgoing caps at submission. +- UX review: EN/RU at 320px with 1.6 text scaling; available-only presentation; + field preservation after errors; explicit draft discard; quote expiry; + loading/stale/empty states; operation outcome explanations; accessible labels, + semantic status announcements and focus after quote/errors. Browser walkthrough + verifies quote amounts and service-confirmed credit with the exchange handoff. + Native mobile and dedicated VoiceOver/TalkBack testing remain release QA. +- Measurement review: typed draft/quote/submission/settlement steps with scoped + journey IDs, safe failure enums, simulated flag and deduplicated terminal + outcomes. No financial amounts, recipients or account IDs in events. Pending + app closure is not draft abandonment. Real PostHog delivery and cross-session + server outcomes remain integration work. +- Validation passed: `make test-frontend` (115 tests), `flutter analyze` in app, + sxchnge_wallet, sxchnge_analytics and sxchnge_kyc; format check of all 36 changed + Dart files; `flutter build web`; `git diff --check`. Review fixes included + narrow Russian layouts, limit-provider typing, automatic pending refresh, + single-use quotes and ambiguous-response recovery. No security scan requested. +- Demo limits: opening EUR 75, fixed rates/fees and three currencies, no real + payment methods/bank validation/storage/auth enforcement. Account preferences, + history and balances reset on reload. No receipt export, date pagination, + notification preferences or security settings beyond an honest capability note. + Backend adapters must supply these before production use; see the wallet README. diff --git a/docs/ux/exchange-workspace.md b/docs/ux/exchange-workspace.md new file mode 100644 index 0000000..f828190 --- /dev/null +++ b/docs/ux/exchange-workspace.md @@ -0,0 +1,409 @@ +# Exchange workspace — UX plan + +Task [#24](https://git.sendico.io/tech/sxchnge/issues/24). Approved plan; implementation tracked in task #28. + +## Product direction + +Make it easy to fund a currency account, exchange into another currency, and pay +out. Accounts answer “what can I use?”, Activity answers “what happened?”, Limits +answers “what can I do next?”, and Settings owns account preferences. + +A new user should see a clear first step: **Top up an account**. A funded user +should be able to exchange or pay out from that account without navigating through +settings or a generic dashboard. Unverified is a valid state with basic limits. +KYC is offered when an upgrade is useful, not as a forced onboarding detour. + +Use the existing SXCHNGE theme, rounded surfaces, typography and semantic colors. +Keep decoration secondary to balances, amounts, recipients and transaction terms. + +## Navigation and screen hierarchy + +| Destination | What it contains | Main next action | +|---|---|---| +| Accounts (default) | Currency accounts, available balances, recent activity, actionable pending items | Top up, exchange, pay out | +| Currency account | Available balance, relevant activity and applicable limits | Act on this currency | +| Activity | All operations, filters, search, processing items | Inspect an operation or resolve an actionable issue | +| Limits | Current caps, used and available, period/reset information and upgrade benefits | Verify identity when useful | +| Settings | Profile, language, security, notifications and help | Manage the account | + +Desktop: a persistent sidebar with **Accounts · Activity · Limits · Settings**. +Mobile: the same four destinations in bottom navigation. Highlight the active +section. Identity verification is reached from Limits and Settings; it does not +need a separate primary navigation item. + +Top up, exchange and pay out are actions, not three competing dashboard tabs. +On Accounts and currency-detail screens, put them beside the selected account on +wide screens and in a clearly labeled action area beneath its balance on mobile. +The transaction flow uses a focused page with Back and Close, a visible source +account and a review step. Avoid full-screen wizard chrome on simple settings. + +Proposed routes, to be finalized during implementation: + +| Route | Purpose | +|---|---| +| `/accounts` | Overview | +| `/accounts/:accountId` | Currency account detail | +| `/top-up` | Account → method → instructions/payment → operation | +| `/exchange` | Source/destination/amount → quote review → operation | +| `/payout` | Account/method/recipient/amount → review → operation | +| `/activity` | Operation list | +| `/activity/:operationId` | Canonical operation detail | +| `/limits` | Usage, constraints and KYC upgrade | +| `/identity` | Existing identity verification flow | +| `/settings` | Account settings | +| `/settings/preferences` | Language and other supported preferences | +| `/settings/security` | Supported security actions | + +Use opaque IDs in paths. Never put recipient details, document data, amounts, +verification tokens or sensitive filter text into route parameters/history. +URLs identify screens; providers hold transient input. Deep links to operation +status must reload authorized service data, not depend on a previous screen. + +## Accounts overview + +The first viewport should show account balances and useful actions, rather than +charts or promotional blocks. + +```text +ACCOUNTS Account menu + +Your accounts [Add currency account, if supported] +[ EUR · available balance ] [ USD · available balance ] … + +Selected account: EUR +Available: … +[Top up] [Exchange] [Pay out] + +Action needed / in progress (only when there is something useful to show) +Recent activity [See all] +``` + +- Each card shows currency code/name, available amount and a text status when + restricted. Do not rely on flags to identify currencies. +- Show only available balances. Held/reserved/booked amounts are internal service + details, not customer-facing concepts. Backend balances remain authoritative. +- Do not sum different currencies directly. A future total in a reporting currency + must be labeled estimated, with valuation currency/time and a service-provided + conversion. It is not needed for the first interface. +- Zero balance: explain that the account is ready and make **Top up** primary. + Funded account: make **Exchange** primary, with Top up and Pay out visible. + Never rearrange actions while the user is interacting with them. +- No accounts: offer account creation only if the backend supports it. Otherwise + explain provisioning status and provide Refresh/Help; do not invent accounts. +- Partial failure: keep successfully loaded accounts usable. Mark stale balances, + offer refresh, and obtain fresh eligibility/quote data before execution. +- Context travels into the flow: starting from EUR preselects EUR. Account switching + invalidates any quote and refreshes methods and eligibility. + +## Top-up journey + +**Outcome: funds are credited and become available**, not merely instructions shown +or a payment button pressed. + +1. Choose the receiving account. If there is one eligible account, preselect it. +2. Choose from service-provided methods. Show fees, minimum/maximum, expected timing + when supplied, and whether an external step is required. Do not advertise rails + that are not actually available. +3. Collect only the fields required by the selected method. Amount is required + only when that method needs it. Validate currency precision and current bounds. +4. Review what the user pays, fees, and what is expected to be credited. For bank + instructions, show exact destination/reference from the service with labeled + copy actions and “Copied” feedback. For redirects, explain the next step before + opening the provider. Never collect card details in generic custom fields. +5. Create/continue the operation and show its canonical status. “Awaiting payment” + or “Processing” remains pending until the service confirms credit. +6. On credit, refresh balances, usage and activity. Offer **Exchange this balance** + with the funded currency preselected, plus View account. + +Returned from a provider does not mean paid. Reconcile using the operation ID. +Handle abandoned redirects, payment decline, expired instructions, wrong/partial +amount, delayed credit and service failure with method-specific service responses. +Do not invent a completion SLA. Never expose a “Mark paid” control as proof of funds. + +## Exchange journey + +**Outcome: the exchange is executed and both currency accounts reflect the result.** + +1. One composition screen: source account, destination currency/account, “You send” + and “You receive.” Clearly mark which amount is editable; editing the other + side requests a new quote if the backend supports receive-exact quotations. +2. Show available source balance and eligibility near the input. Currency swap + exchanges the selection and invalidates the prior quote. Unsupported pairs are + explained before the review step. Offer Add account only if supported. +3. Quote response owns the rate, fees, total debit, net credit, quote ID and expiry. + Show **1 source currency = … destination currency**, the actual debit and net + receipt; never bury fees behind an unexplained estimate. +4. **Review exchange** summarizes both accounts, amounts, fees and rate. The final + button says **Exchange [amount/currency]**. Disable final execution while the + quote is missing, stale, expired or being refreshed, with a nearby reason. +5. If terms change, show the new quote and require confirmation again. Never + silently execute a refreshed rate. Prevent double submission and reuse the + idempotency key when recovering the same request. +6. Processing leads to operation detail. Only service-confirmed execution shows + **Exchange complete**. Refresh both accounts, activity and limits. Offer + **Pay out [destination currency]** and View account. + +“Max” must use a service-provided spendable amount inclusive of fees and applicable +limits; do not assume the displayed balance can all be exchanged. If the amount is +above the current permitted limit, show the actual blocker and an upgrade link only +when KYC would help. Keep the entered amount when requesting a new quote. + +## Payout journey + +**Outcome: the payout reaches a service-confirmed terminal result.** Distinguish +“submitted,” “sent” and “received” when those are separate states in the contract. + +1. Preselect the source currency account. Choose a supported payout method and an + existing recipient, or enter a new destination. Saved recipients are optional + future functionality; do not imply they exist without a service. +2. Method-specific fields and validation come from the service contract. Clearly + identify the beneficiary/destination, bank or network when applicable. Do not + guess which currencies support which destination types. +3. Enter amount; show fee, total debit, destination amount/currency, available funds, + limits and expected timing supplied by the backend. +4. Review the recipient and full destination sufficiently to detect a mistake, + alongside the amount and costs. The final button says **Pay out [amount/currency]**. + Explain irreversibility where applicable; avoid a redundant confirmation dialog + after an already clear review page. Perform step-up authentication when required + by the backend, without losing the pending operation context. +5. Show processing/status with a reference. Cancellation is offered only when the + service says the operation remains cancellable. If supported, confirm the + cancellation and refresh status rather than optimistically removing the payout. +6. On confirmed success, show the outcome and account/activity links. A returned or + reversed payout remains visible with its own explanation and linked reversal. + +Insufficient funds should offer **Top up this account** or **Exchange into this +currency**, preserving the draft locally. After funding, revalidate recipient, +fees, available balance and limits before allowing submission. Never auto-submit. +A transfer destination must never be included in analytics, logs or URLs. + +## Shared transaction states and recovery + +| State | User-facing behavior | +|---|---| +| Initial/draft | Clear primary action, no invented balances/rates | +| Loading balance/methods/quote | Specific progress; keep unrelated navigation usable | +| Validation issue | Inline field message and focus on first problem; preserve input | +| Not eligible | Reason plus relevant next step: email, limits, account funding or support | +| No supported method/pair | Explain unavailability; let user change the selection | +| Quote expired | Keep input; request a new quote and require fresh confirmation | +| Submission in progress | Prevent duplicate execution and show progress | +| Connection lost after submit | “Checking the outcome”; retrieve by operation/idempotency ID, not a fresh submission | +| Pending external/human processing | Status and next useful action; pending is not failure or abandonment | +| Confirmed failure | Safe reason, support/reference where available; retry only when service permits | +| Confirmed success | Authoritative receipt, refreshed balances/limits and useful next action | +| Session expired | Reauthenticate and rehydrate operation status; do not retain sensitive fields in browser storage | + +Close/back preserves an in-memory draft for a clear session-scoped lifetime. +Warn before discarding meaningful recipient/payment input. Quotes always expire +independently of the draft. Hard reload can discard draft input; submitted operations +must survive because they are reloaded from the backend. Logging out clears all +account-scoped providers and drafts to prevent cross-account leakage. + +## Activity and operation detail + +Activity is the common destination for the outcome of every transaction flow. +Show type, source/destination currencies, meaningful amount(s), time, status and +reference. Exchanges are one parent operation showing both debit and credit, not +two unrelated rows that look like duplicate transactions. + +- Filters: all/top-ups/exchanges/payouts, status, currency/account and date range. + Preserve filters when returning from a detail page. Provide Clear filters and + distinguish “no operations yet” from “no matching results” and a load failure. +- Account-detail activity is the same view scoped to that account. Paginate with + stable service cursors; refresh without duplicating rows. +- Detail includes status timeline, amount breakdown, fees, relevant destination, + timestamps, available receipt/reference and service-permitted actions. +- Pending items show what is known and whether the user needs to act. Avoid endless + spinners or a fake percentage for external settlement. +- **Try again** creates a new draft only after confirmed failure and backend + permission; never replays a consequential request from a history button. + +## Limits and identity + +Reuse the current KYC and usage work ([PR #21](https://git.sendico.io/tech/sxchnge/pulls/21), +[PR #23](https://git.sendico.io/tech/sxchnge/pulls/23)). Limits must show used, +reserved, available and caps for the relevant operation/currency/period. Include +window/reset information only when supplied; do not imply a calendar reset for a +rolling window. Preserve service ordering and applicability. + +The full page explains active tier and what verification would unlock. Transaction +forms show the relevant constraint in context and link to that page. A KYC upgrade +must not claim to resolve insufficient funds, an unsupported method or an unrelated +restriction. Submission/pending review does not increase limits; refetch the actual +backend projection after any decision. + +## Account settings and language + +**Language belongs to the account.** Remove page-level language selectors from the +main workspace and identity pages. Settings → Preferences contains English/Russian +with one clear selected value. Change language without resetting drafts or filters. + +- Profile service owns `preferredLocale`; an account provider loads and updates it. + Device storage is a cache/guest fallback, not the source of truth for a signed-in + account. Use an injectable stub until profile endpoints are available. +- On sign-in, the server preference takes precedence. Before account creation, + use browser/device locale plus a remembered guest preference. If a guest needs + a manual override, expose it once through the auth shell's settings/help entry, + not on every login/verification page. +- On save failure, clearly state “Not saved to your account,” retain the choice for + the current session and offer Retry. Do not report a successful account update. +- Server-provided human-readable labels must be fetched for the selected language; + preserve stable IDs and in-progress inputs. Avoid mixed-language stale responses. +- Security settings show only capabilities actually implemented (password change, + sessions, MFA when supported). Do not present decorative toggles as protection. +- Identity status is linked here but managed in the existing verification flow. + Notification preferences require a real preference service; support has a genuine + destination before production. No placeholder action should pretend to complete. + +## Service and architecture boundaries + +Keep shared DTO → mapper → service → model → provider ownership for future mobile +reuse. Widgets consume provider state; they do not compute financial outcomes. +Proposed domains, not speculative API endpoints: + +| Domain service | Owns authoritative data | +|---|---| +| Accounts | Account IDs, currencies, booked/available/held balances, status | +| Funding | Eligible methods, instructions, fees, top-up creation/status | +| Exchange | Pair eligibility, quotes/expiry, idempotent execution | +| Payout | Method/recipient validation, quotes, submission and permissible actions | +| Operations | History, canonical status, references, receipts and reconciliation | +| Limits | Applicable caps, committed/reserved usage, remaining and upgrade projection | +| Profile | Account preferences including language | + +Use currency-aware decimal/minor-unit values with currency precision metadata; +never floating-point money or a universal two-decimal assumption. Balance held +amounts and limits reserved usage are different concepts, even when values match. +Backend authorization and limit reservations remain necessary at execution time. +A visible balance, valid quote or frontend KYC state cannot authorize a payment. + +For the demo, shared stub state must be coherent across screens: exchange affects +both balances; pending payouts hold capacity; failures release simulated holds +where appropriate; activity and limits reflect the same simulated operation. +Never mix a simulated transaction with a live success claim. Avoid seeded history +that contradicts displayed balances or usage without explaining the fixtures. + +## Responsiveness, accessibility and trust + +- Mobile: one-column forms and review summaries; no horizontal table required to + complete a task. Wide screens may place quote summary beside input. +- Large values and long beneficiary names wrap safely. Use EN/RU, realistic currency + precision and text scaling in design/test fixtures. +- Monetary fields identify currency and precision; keyboard input accepts the active + locale's decimal separator without silently altering value. Avoid formatting the + value under the user's cursor on every keystroke. +- Provide labeled 48px targets, visible keyboard focus and predictable focus after + validation/navigation. Statuses have text/icons, never color alone. +- Announce meaningful quote/state changes, not every countdown tick. Honor reduced + motion. An expiry countdown accompanies a clear expired state. +- Mask sensitive destinations in lists, reveal enough in explicit review/detail + to verify them. No session replay/autocapture on money, recipient or identity forms. +- Keep unrelated accounts/history accessible during a pending operation. Do not + block the whole product behind an exchange spinner. + +## Journey measurement plan + +Reuse the typed analytics abstraction and maintain a separate journey per active +transaction draft. Random journey IDs correlate steps; opaque operation IDs link +accepted requests to server outcomes. Retries reuse the journey and idempotency +lineage. Never capture amount, balance, currency-account IDs, recipient, form input, +URLs or raw exceptions in client UX events by default. + +| Journey | Start | Useful states | Actual success | +|---|---|---|---| +| Top up | User begins funding | Method ready, instructions/payment ready, accepted/pending, failure | Service-confirmed credit | +| Exchange | User begins exchange | Valid quote, reviewed, accepted/pending, expired/failure | Service-confirmed execution | +| Payout | User begins payout | Valid destination/quote, reviewed, accepted/pending, failure | Contract-defined confirmed payout outcome | + +Measure start-to-acceptance and acceptance-to-terminal conversion separately. Use +one count per journey for draft conversion and one per operation for financial +outcomes. Track review-to-submit drop-off, quote expiry, recoverable failure rate, +time to acceptance and server time to terminal outcome. Classify failures with safe +enums, such as insufficient funds, limits, expired quote, unavailable method, +session and service error; emitted events must have a concrete diagnostic use. + +An explicit draft discard or 30 minutes of settled inactivity is draft abandonment. +Changing tabs while preserving a draft is not immediate abandonment. Accepted +operations leave the draft abandonment window and remain pending until authoritative +status; app closure must not fabricate failure. Multi-session reconciliation and +server-side outcome events are required before claiming production funnel coverage. +Deduplicate by event/operation identity. Analytics errors must not delay execution. + +Account/language preference updates reuse profile outcome signals; do not add +click events for every setting, card or navigation tab. Simulated events are always +flagged and excluded from real transaction/verification conversion dashboards. + +## Implementation sequence after approval + +1. **Shared workspace foundation:** accounts/operations/profile contracts and coherent + stubs, shell/routes, account overview/detail, Activity/detail, account language + provider/settings. Move language selectors into settings. Preserve auth/email + and existing identity/limits navigation. +2. **Top-up vertical slice:** method/instruction/review/status, balance refresh and + an exchange handoff. Include cancellation, pending and failed outcomes. +3. **Exchange vertical slice:** input/quote/review, expiry, idempotent execution, + coherent balances/history/usage and payout handoff. +4. **Payout vertical slice:** method/destination/review, step-up contract hooks, + reconciliation and confirmed failure/return paths. +5. **Integrated review:** cross-flow drafts, limits/KYC return paths, language changes, + responsive/keyboard/screen-reader checks, shared-service tests and semantic event + assertions. Complete each slice's tests as it is built, not only at the end. + +Actual supported currencies/rails, account provisioning, fee/rate policies, +recipient requirements, cancellation and support contracts must come from backend +capabilities. Demo fixtures can illustrate them but must be labeled and isolated. +Real execution is outside this UX-plan task. + +## Plan acceptance and validation boundary + +The plan connects all requested areas and defines optional KYC, usage, language +ownership, consequential review, state recovery, privacy and measurement. Before +calling implementation complete, walk: new account → top up → exchange → payout → +Activity; pending/unknown outcome recovery; exceeded limits → KYC → resume/requote; +and locale change with a preserved draft. Test stale/out-of-order responses, +repeated submit, expired quote, zero balances, no methods, denied KYC, and logout. + +This document is a design deliverable, not evidence that these screens exist or +that accessibility/payment flows have been tested. Existing demo implementation +and pending PRs remain unchanged by this task. + +## Implementation simplification — user direction for #28 + +Only available balances are shown. Customer limits show used and available, with +no held/reserved breakdown or explanatory jargon. Keep four navigation destinations +and straightforward input → review → result flows. Demo funding/payout methods +are explicitly simulated, not representations of live supported rails. + +## Implemented demo scope — #28 + +Four primary destinations and three transaction journeys are implemented. The +single demo method needs no method picker; top-up and exchange collect an amount, +payout additionally collects sample destination text. Review shows fees, source +debit, destination credit and rate. There are no live payment instructions, +redirects, saved recipients, security controls or notification preferences. +Settings implements account language and links to identity. Unsupported production +capabilities remain future work, not decorative controls. + +Pending top-up: money has not reached available balance. Pending outgoing: total +amount plus fee has left available balance, and processing continues. Completed +exchange/top-up: destination balance is credited. Failed outgoing: amount plus fee +is available again. These explanations appear on canonical operation detail. +Pending snapshots refresh automatically across screens; refresh failures mark data +potentially stale. The limits service independently supplies usage and remaining. +The customer sees aggregate consumed capacity and available capacity, not internal +reservations. + +Activity offers kind/status/currency filters and reference search; operation detail +is deep-linkable while the demo session exists. Date filtering, pagination, +receipts, persistent history, actual reset timestamps and production support await +backend contracts. Transient drafts survive pushed settings/limits screens and +locale changes; closing a draft requires discard confirmation. Browser reload +resets the entire demo and is not durable recovery. + +Measurement uses existing random journey IDs and typed quote/submission/settlement +steps. Only service-confirmed terminal status closes the transaction outcome. +Accepted operations are excluded from draft-abandonment on app disposal. Actual +server events, persistent reconciliation and cross-session conversion remain +integration gaps. No automatic click tracking, replay or financial data collection +is introduced. diff --git a/frontend/app/lib/auth/form.dart b/frontend/app/lib/auth/form.dart index e97b243..38c8a49 100644 --- a/frontend/app/lib/auth/form.dart +++ b/frontend/app/lib/auth/form.dart @@ -311,8 +311,8 @@ class _AuthFormState extends State { ], const SizedBox(height: 20), TextButton( - onPressed: () => context.goNamed(AppRoutes.identity), - child: Text(l.kycPreview), + onPressed: () => context.go('/accounts'), + child: Text(l.wEnter), ), Wrap( alignment: WrapAlignment.center, diff --git a/frontend/app/lib/auth/page.dart b/frontend/app/lib/auth/page.dart index 101a586..7a5d386 100644 --- a/frontend/app/lib/auth/page.dart +++ b/frontend/app/lib/auth/page.dart @@ -1,12 +1,14 @@ import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + import 'package:sxchnge_theme/sxchnge_theme.dart'; -import '../widgets/locale.dart'; - import 'brand_panel.dart'; import 'form.dart'; +import '../generated/i18n/app_localizations.dart'; + class AuthPage extends StatefulWidget { const AuthPage({super.key}); @@ -36,9 +38,12 @@ class _AuthPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const Align( + Align( alignment: Alignment.centerRight, - child: LocaleSelector(), + child: TextButton( + onPressed: () => context.push('/settings'), + child: Text(AppLocalizations.of(context)!.wSettings), + ), ), const SizedBox(height: 16), if (!wide) ...[ diff --git a/frontend/app/lib/generated/i18n/app_localizations.dart b/frontend/app/lib/generated/i18n/app_localizations.dart index 32dec96..bd91178 100644 --- a/frontend/app/lib/generated/i18n/app_localizations.dart +++ b/frontend/app/lib/generated/i18n/app_localizations.dart @@ -1021,6 +1021,528 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'No capacity available'** String get kycNoCapacity; + + /// No description provided for @wAccounts. + /// + /// In en, this message translates to: + /// **'Accounts'** + String get wAccounts; + + /// No description provided for @wActivity. + /// + /// In en, this message translates to: + /// **'Activity'** + String get wActivity; + + /// No description provided for @wLimits. + /// + /// In en, this message translates to: + /// **'Limits'** + String get wLimits; + + /// No description provided for @wSettings. + /// + /// In en, this message translates to: + /// **'Settings'** + String get wSettings; + + /// No description provided for @wHeadline. + /// + /// In en, this message translates to: + /// **'Your money.\nYour next move.'** + String get wHeadline; + + /// No description provided for @wSubheading. + /// + /// In en, this message translates to: + /// **'Top up. Exchange. Go further.'** + String get wSubheading; + + /// No description provided for @wAvailable. + /// + /// In en, this message translates to: + /// **'Available to you'** + String get wAvailable; + + /// No description provided for @wTopUp. + /// + /// In en, this message translates to: + /// **'Top up'** + String get wTopUp; + + /// No description provided for @wExchange. + /// + /// In en, this message translates to: + /// **'Exchange'** + String get wExchange; + + /// No description provided for @wPayout. + /// + /// In en, this message translates to: + /// **'Pay out'** + String get wPayout; + + /// No description provided for @wRecent. + /// + /// In en, this message translates to: + /// **'Recent activity'** + String get wRecent; + + /// No description provided for @wSeeAll. + /// + /// In en, this message translates to: + /// **'See all'** + String get wSeeAll; + + /// No description provided for @wEmptyActivity. + /// + /// In en, this message translates to: + /// **'Your next move starts here. Your transactions will appear below.'** + String get wEmptyActivity; + + /// No description provided for @wEmptyAccounts. + /// + /// In en, this message translates to: + /// **'Your accounts are not ready yet. Try refreshing.'** + String get wEmptyAccounts; + + /// No description provided for @wRefresh. + /// + /// In en, this message translates to: + /// **'Refresh'** + String get wRefresh; + + /// No description provided for @wDemo. + /// + /// In en, this message translates to: + /// **'Demo workspace · No real money moves.'** + String get wDemo; + + /// No description provided for @wSource. + /// + /// In en, this message translates to: + /// **'From account'** + String get wSource; + + /// No description provided for @wDestination. + /// + /// In en, this message translates to: + /// **'To account'** + String get wDestination; + + /// No description provided for @wReceiveAccount. + /// + /// In en, this message translates to: + /// **'Receiving account'** + String get wReceiveAccount; + + /// No description provided for @wAmount. + /// + /// In en, this message translates to: + /// **'Amount'** + String get wAmount; + + /// No description provided for @wRecipient. + /// + /// In en, this message translates to: + /// **'Recipient'** + String get wRecipient; + + /// No description provided for @wRecipientHint. + /// + /// In en, this message translates to: + /// **'Sample recipient — demo only'** + String get wRecipientHint; + + /// No description provided for @wReview. + /// + /// In en, this message translates to: + /// **'Review'** + String get wReview; + + /// No description provided for @wReviewTitle. + /// + /// In en, this message translates to: + /// **'One last look.'** + String get wReviewTitle; + + /// No description provided for @wFee. + /// + /// In en, this message translates to: + /// **'Fee'** + String get wFee; + + /// No description provided for @wTotalDebit. + /// + /// In en, this message translates to: + /// **'Leaves your account'** + String get wTotalDebit; + + /// No description provided for @wCredit. + /// + /// In en, this message translates to: + /// **'Arrives in your account'** + String get wCredit; + + /// No description provided for @wRecipientGets. + /// + /// In en, this message translates to: + /// **'Recipient receives'** + String get wRecipientGets; + + /// No description provided for @wYouPay. + /// + /// In en, this message translates to: + /// **'You pay'** + String get wYouPay; + + /// No description provided for @wRate. + /// + /// In en, this message translates to: + /// **'Exchange rate'** + String get wRate; + + /// No description provided for @wQuoteNotice. + /// + /// In en, this message translates to: + /// **'These terms are valid for 60 seconds. Confirm them before continuing.'** + String get wQuoteNotice; + + /// No description provided for @wExpired. + /// + /// In en, this message translates to: + /// **'This quote expired. Refresh it and review the new terms.'** + String get wExpired; + + /// No description provided for @wNewQuote. + /// + /// In en, this message translates to: + /// **'Refresh quote'** + String get wNewQuote; + + /// No description provided for @wConfirm. + /// + /// In en, this message translates to: + /// **'Confirm demo transaction'** + String get wConfirm; + + /// No description provided for @wEdit. + /// + /// In en, this message translates to: + /// **'Edit details'** + String get wEdit; + + /// No description provided for @wClose. + /// + /// In en, this message translates to: + /// **'Close'** + String get wClose; + + /// No description provided for @wWorking. + /// + /// In en, this message translates to: + /// **'Working on it…'** + String get wWorking; + + /// No description provided for @wPending. + /// + /// In en, this message translates to: + /// **'Processing'** + String get wPending; + + /// No description provided for @wCompleted. + /// + /// In en, this message translates to: + /// **'Completed'** + String get wCompleted; + + /// No description provided for @wFailed. + /// + /// In en, this message translates to: + /// **'Not completed'** + String get wFailed; + + /// No description provided for @wPendingTopUp. + /// + /// In en, this message translates to: + /// **'Your top-up is processing. Your available balance has not changed yet.'** + String get wPendingTopUp; + + /// No description provided for @wPendingExchange. + /// + /// In en, this message translates to: + /// **'The amount and fee have left your available balance. The exchanged amount will appear in the receiving account when completed.'** + String get wPendingExchange; + + /// No description provided for @wPendingPayout. + /// + /// In en, this message translates to: + /// **'The amount and fee have left your available balance. The payout is still processing.'** + String get wPendingPayout; + + /// No description provided for @wCompleteTopUp. + /// + /// In en, this message translates to: + /// **'Your account has been credited. The money is available to use.'** + String get wCompleteTopUp; + + /// No description provided for @wCompleteExchange. + /// + /// In en, this message translates to: + /// **'Your exchange is complete. The money is available in the receiving account.'** + String get wCompleteExchange; + + /// No description provided for @wCompletePayout. + /// + /// In en, this message translates to: + /// **'Your payout is complete. The amount and fee have been deducted from your account.'** + String get wCompletePayout; + + /// No description provided for @wFailedTopUp. + /// + /// In en, this message translates to: + /// **'The top-up did not complete. No money was added to your account.'** + String get wFailedTopUp; + + /// No description provided for @wFailedOutgoing. + /// + /// In en, this message translates to: + /// **'The transaction did not complete. The amount and fee are available in your account again.'** + String get wFailedOutgoing; + + /// No description provided for @wDemoMethod. + /// + /// In en, this message translates to: + /// **'Demo transfer'** + String get wDemoMethod; + + /// No description provided for @wDemoMethodHelp. + /// + /// In en, this message translates to: + /// **'Try the flow with sample data. No bank or payment provider is contacted.'** + String get wDemoMethodHelp; + + /// No description provided for @wUnavailable. + /// + /// In en, this message translates to: + /// **'Could not refresh. The displayed information may be out of date. Try again.'** + String get wUnavailable; + + /// No description provided for @wInvalidAmount. + /// + /// In en, this message translates to: + /// **'Enter an amount greater than zero, using the currency’s decimal precision.'** + String get wInvalidAmount; + + /// No description provided for @wInsufficient. + /// + /// In en, this message translates to: + /// **'Not enough available money for this amount and fee. Top up or enter a smaller amount.'** + String get wInsufficient; + + /// No description provided for @wLimitError. + /// + /// In en, this message translates to: + /// **'This amount exceeds your available limit. Check your limits before continuing.'** + String get wLimitError; + + /// No description provided for @wRecipientError. + /// + /// In en, this message translates to: + /// **'Enter a sample recipient between 6 and 140 characters.'** + String get wRecipientError; + + /// No description provided for @wUnsupported. + /// + /// In en, this message translates to: + /// **'This combination is unavailable. Choose another account.'** + String get wUnsupported; + + /// No description provided for @wNotFound. + /// + /// In en, this message translates to: + /// **'This item could not be found. Return to your accounts.'** + String get wNotFound; + + /// No description provided for @wUncertain. + /// + /// In en, this message translates to: + /// **'We could not confirm the outcome. Check it before trying another transaction.'** + String get wUncertain; + + /// No description provided for @wCheckOutcome. + /// + /// In en, this message translates to: + /// **'Check outcome'** + String get wCheckOutcome; + + /// No description provided for @wNoSubmission. + /// + /// In en, this message translates to: + /// **'No transaction was found. You can review and confirm again.'** + String get wNoSubmission; + + /// No description provided for @wDiscardTitle. + /// + /// In en, this message translates to: + /// **'Leave this transaction?'** + String get wDiscardTitle; + + /// No description provided for @wDiscardBody. + /// + /// In en, this message translates to: + /// **'Your entered details will be cleared. No new transaction will be submitted.'** + String get wDiscardBody; + + /// No description provided for @wStay. + /// + /// In en, this message translates to: + /// **'Keep editing'** + String get wStay; + + /// No description provided for @wAll. + /// + /// In en, this message translates to: + /// **'All'** + String get wAll; + + /// No description provided for @wSearch. + /// + /// In en, this message translates to: + /// **'Search by reference'** + String get wSearch; + + /// No description provided for @wNoMatches. + /// + /// In en, this message translates to: + /// **'No matching transactions. Try another filter.'** + String get wNoMatches; + + /// No description provided for @wClearFilters. + /// + /// In en, this message translates to: + /// **'Clear filters'** + String get wClearFilters; + + /// No description provided for @wReference. + /// + /// In en, this message translates to: + /// **'Reference'** + String get wReference; + + /// No description provided for @wWhen. + /// + /// In en, this message translates to: + /// **'Created'** + String get wWhen; + + /// No description provided for @wViewAccount. + /// + /// In en, this message translates to: + /// **'View account'** + String get wViewAccount; + + /// No description provided for @wPreferences. + /// + /// In en, this message translates to: + /// **'Make it yours.'** + String get wPreferences; + + /// No description provided for @wLanguage. + /// + /// In en, this message translates to: + /// **'Account language'** + String get wLanguage; + + /// No description provided for @wLanguageHelp. + /// + /// In en, this message translates to: + /// **'Used throughout your account.'** + String get wLanguageHelp; + + /// No description provided for @wLanguageSaved. + /// + /// In en, this message translates to: + /// **'Saved to your demo account.'** + String get wLanguageSaved; + + /// No description provided for @wLanguageFailed. + /// + /// In en, this message translates to: + /// **'Changed for this session, but not saved to your account. Retry below.'** + String get wLanguageFailed; + + /// No description provided for @wProfileError. + /// + /// In en, this message translates to: + /// **'Account preferences could not be loaded. Try again.'** + String get wProfileError; + + /// No description provided for @wSecurity. + /// + /// In en, this message translates to: + /// **'Security'** + String get wSecurity; + + /// No description provided for @wSecurityBody. + /// + /// In en, this message translates to: + /// **'Live sessions, password changes and two-step verification are not connected in this demo.'** + String get wSecurityBody; + + /// No description provided for @wIdentity. + /// + /// In en, this message translates to: + /// **'Identity verification'** + String get wIdentity; + + /// No description provided for @wIdentityHelp. + /// + /// In en, this message translates to: + /// **'Optional verification for higher limits.'** + String get wIdentityHelp; + + /// No description provided for @wBackLogin. + /// + /// In en, this message translates to: + /// **'Back to login'** + String get wBackLogin; + + /// No description provided for @wEnter. + /// + /// In en, this message translates to: + /// **'Explore your accounts'** + String get wEnter; + + /// No description provided for @wUsage. + /// + /// In en, this message translates to: + /// **'Used this period'** + String get wUsage; + + /// No description provided for @wLimitsScope. + /// + /// In en, this message translates to: + /// **'Outgoing activity · EUR equivalent'** + String get wLimitsScope; + + /// No description provided for @wPendingHint. + /// + /// In en, this message translates to: + /// **'Refresh to check the latest status. You can keep using your other accounts.'** + String get wPendingHint; + + /// No description provided for @wFundsHeading. + /// + /// In en, this message translates to: + /// **'Where is my money?'** + String get wFundsHeading; + + /// No description provided for @wAnother. + /// + /// In en, this message translates to: + /// **'Make another transaction'** + String get wAnother; } class _AppLocalizationsDelegate diff --git a/frontend/app/lib/generated/i18n/app_localizations_en.dart b/frontend/app/lib/generated/i18n/app_localizations_en.dart index be62464..1a374e7 100644 --- a/frontend/app/lib/generated/i18n/app_localizations_en.dart +++ b/frontend/app/lib/generated/i18n/app_localizations_en.dart @@ -518,4 +518,292 @@ class AppLocalizationsEn extends AppLocalizations { @override String get kycNoCapacity => 'No capacity available'; + + @override + String get wAccounts => 'Accounts'; + + @override + String get wActivity => 'Activity'; + + @override + String get wLimits => 'Limits'; + + @override + String get wSettings => 'Settings'; + + @override + String get wHeadline => 'Your money.\nYour next move.'; + + @override + String get wSubheading => 'Top up. Exchange. Go further.'; + + @override + String get wAvailable => 'Available to you'; + + @override + String get wTopUp => 'Top up'; + + @override + String get wExchange => 'Exchange'; + + @override + String get wPayout => 'Pay out'; + + @override + String get wRecent => 'Recent activity'; + + @override + String get wSeeAll => 'See all'; + + @override + String get wEmptyActivity => + 'Your next move starts here. Your transactions will appear below.'; + + @override + String get wEmptyAccounts => + 'Your accounts are not ready yet. Try refreshing.'; + + @override + String get wRefresh => 'Refresh'; + + @override + String get wDemo => 'Demo workspace · No real money moves.'; + + @override + String get wSource => 'From account'; + + @override + String get wDestination => 'To account'; + + @override + String get wReceiveAccount => 'Receiving account'; + + @override + String get wAmount => 'Amount'; + + @override + String get wRecipient => 'Recipient'; + + @override + String get wRecipientHint => 'Sample recipient — demo only'; + + @override + String get wReview => 'Review'; + + @override + String get wReviewTitle => 'One last look.'; + + @override + String get wFee => 'Fee'; + + @override + String get wTotalDebit => 'Leaves your account'; + + @override + String get wCredit => 'Arrives in your account'; + + @override + String get wRecipientGets => 'Recipient receives'; + + @override + String get wYouPay => 'You pay'; + + @override + String get wRate => 'Exchange rate'; + + @override + String get wQuoteNotice => + 'These terms are valid for 60 seconds. Confirm them before continuing.'; + + @override + String get wExpired => + 'This quote expired. Refresh it and review the new terms.'; + + @override + String get wNewQuote => 'Refresh quote'; + + @override + String get wConfirm => 'Confirm demo transaction'; + + @override + String get wEdit => 'Edit details'; + + @override + String get wClose => 'Close'; + + @override + String get wWorking => 'Working on it…'; + + @override + String get wPending => 'Processing'; + + @override + String get wCompleted => 'Completed'; + + @override + String get wFailed => 'Not completed'; + + @override + String get wPendingTopUp => + 'Your top-up is processing. Your available balance has not changed yet.'; + + @override + String get wPendingExchange => + 'The amount and fee have left your available balance. The exchanged amount will appear in the receiving account when completed.'; + + @override + String get wPendingPayout => + 'The amount and fee have left your available balance. The payout is still processing.'; + + @override + String get wCompleteTopUp => + 'Your account has been credited. The money is available to use.'; + + @override + String get wCompleteExchange => + 'Your exchange is complete. The money is available in the receiving account.'; + + @override + String get wCompletePayout => + 'Your payout is complete. The amount and fee have been deducted from your account.'; + + @override + String get wFailedTopUp => + 'The top-up did not complete. No money was added to your account.'; + + @override + String get wFailedOutgoing => + 'The transaction did not complete. The amount and fee are available in your account again.'; + + @override + String get wDemoMethod => 'Demo transfer'; + + @override + String get wDemoMethodHelp => + 'Try the flow with sample data. No bank or payment provider is contacted.'; + + @override + String get wUnavailable => + 'Could not refresh. The displayed information may be out of date. Try again.'; + + @override + String get wInvalidAmount => + 'Enter an amount greater than zero, using the currency’s decimal precision.'; + + @override + String get wInsufficient => + 'Not enough available money for this amount and fee. Top up or enter a smaller amount.'; + + @override + String get wLimitError => + 'This amount exceeds your available limit. Check your limits before continuing.'; + + @override + String get wRecipientError => + 'Enter a sample recipient between 6 and 140 characters.'; + + @override + String get wUnsupported => + 'This combination is unavailable. Choose another account.'; + + @override + String get wNotFound => + 'This item could not be found. Return to your accounts.'; + + @override + String get wUncertain => + 'We could not confirm the outcome. Check it before trying another transaction.'; + + @override + String get wCheckOutcome => 'Check outcome'; + + @override + String get wNoSubmission => + 'No transaction was found. You can review and confirm again.'; + + @override + String get wDiscardTitle => 'Leave this transaction?'; + + @override + String get wDiscardBody => + 'Your entered details will be cleared. No new transaction will be submitted.'; + + @override + String get wStay => 'Keep editing'; + + @override + String get wAll => 'All'; + + @override + String get wSearch => 'Search by reference'; + + @override + String get wNoMatches => 'No matching transactions. Try another filter.'; + + @override + String get wClearFilters => 'Clear filters'; + + @override + String get wReference => 'Reference'; + + @override + String get wWhen => 'Created'; + + @override + String get wViewAccount => 'View account'; + + @override + String get wPreferences => 'Make it yours.'; + + @override + String get wLanguage => 'Account language'; + + @override + String get wLanguageHelp => 'Used throughout your account.'; + + @override + String get wLanguageSaved => 'Saved to your demo account.'; + + @override + String get wLanguageFailed => + 'Changed for this session, but not saved to your account. Retry below.'; + + @override + String get wProfileError => + 'Account preferences could not be loaded. Try again.'; + + @override + String get wSecurity => 'Security'; + + @override + String get wSecurityBody => + 'Live sessions, password changes and two-step verification are not connected in this demo.'; + + @override + String get wIdentity => 'Identity verification'; + + @override + String get wIdentityHelp => 'Optional verification for higher limits.'; + + @override + String get wBackLogin => 'Back to login'; + + @override + String get wEnter => 'Explore your accounts'; + + @override + String get wUsage => 'Used this period'; + + @override + String get wLimitsScope => 'Outgoing activity · EUR equivalent'; + + @override + String get wPendingHint => + 'Refresh to check the latest status. You can keep using your other accounts.'; + + @override + String get wFundsHeading => 'Where is my money?'; + + @override + String get wAnother => 'Make another transaction'; } diff --git a/frontend/app/lib/generated/i18n/app_localizations_ru.dart b/frontend/app/lib/generated/i18n/app_localizations_ru.dart index b85054f..2f62fe0 100644 --- a/frontend/app/lib/generated/i18n/app_localizations_ru.dart +++ b/frontend/app/lib/generated/i18n/app_localizations_ru.dart @@ -519,4 +519,289 @@ class AppLocalizationsRu extends AppLocalizations { @override String get kycNoCapacity => 'Доступный объём исчерпан'; + + @override + String get wAccounts => 'Счета'; + + @override + String get wActivity => 'История'; + + @override + String get wLimits => 'Лимиты'; + + @override + String get wSettings => 'Настройки'; + + @override + String get wHeadline => 'Ваши деньги.\nВаш следующий шаг.'; + + @override + String get wSubheading => 'Пополняйте. Обменивайте. Двигайтесь дальше.'; + + @override + String get wAvailable => 'Вам доступно'; + + @override + String get wTopUp => 'Пополнить'; + + @override + String get wExchange => 'Обменять'; + + @override + String get wPayout => 'Вывести'; + + @override + String get wRecent => 'Последние операции'; + + @override + String get wSeeAll => 'Смотреть все'; + + @override + String get wEmptyActivity => + 'Ваш следующий шаг начинается здесь. Здесь появятся ваши операции.'; + + @override + String get wEmptyAccounts => + 'Ваши счета ещё не готовы. Попробуйте обновить страницу.'; + + @override + String get wRefresh => 'Обновить'; + + @override + String get wDemo => 'Демонстрация · Реальные деньги не переводятся.'; + + @override + String get wSource => 'Со счёта'; + + @override + String get wDestination => 'На счёт'; + + @override + String get wReceiveAccount => 'Счёт для зачисления'; + + @override + String get wAmount => 'Сумма'; + + @override + String get wRecipient => 'Получатель'; + + @override + String get wRecipientHint => 'Тестовый получатель — только демо'; + + @override + String get wReview => 'Проверить'; + + @override + String get wReviewTitle => 'Проверьте детали.'; + + @override + String get wFee => 'Комиссия'; + + @override + String get wTotalDebit => 'Спишется со счёта'; + + @override + String get wCredit => 'Поступит на счёт'; + + @override + String get wRecipientGets => 'Получатель получит'; + + @override + String get wYouPay => 'Вы платите'; + + @override + String get wRate => 'Курс обмена'; + + @override + String get wQuoteNotice => + 'Эти условия действуют 60 секунд. Подтвердите их, прежде чем продолжить.'; + + @override + String get wExpired => + 'Срок действия расчёта истёк. Обновите его и проверьте новые условия.'; + + @override + String get wNewQuote => 'Обновить расчёт'; + + @override + String get wConfirm => 'Подтвердить тестовую операцию'; + + @override + String get wEdit => 'Изменить данные'; + + @override + String get wClose => 'Закрыть'; + + @override + String get wWorking => 'Обрабатываем…'; + + @override + String get wPending => 'В обработке'; + + @override + String get wCompleted => 'Завершено'; + + @override + String get wFailed => 'Не выполнено'; + + @override + String get wPendingTopUp => + 'Пополнение обрабатывается. Доступный баланс пока не изменился.'; + + @override + String get wPendingExchange => + 'Сумма и комиссия уже вычтены из доступного баланса. После завершения обмена деньги появятся на счёте зачисления.'; + + @override + String get wPendingPayout => + 'Сумма и комиссия уже вычтены из доступного баланса. Вывод ещё обрабатывается.'; + + @override + String get wCompleteTopUp => 'Счёт пополнен. Деньги доступны для операций.'; + + @override + String get wCompleteExchange => + 'Обмен завершён. Деньги доступны на счёте зачисления.'; + + @override + String get wCompletePayout => + 'Вывод завершён. Сумма и комиссия списаны с вашего счёта.'; + + @override + String get wFailedTopUp => + 'Пополнение не выполнено. На счёт ничего не зачислено.'; + + @override + String get wFailedOutgoing => + 'Операция не выполнена. Сумма и комиссия снова доступны на вашем счёте.'; + + @override + String get wDemoMethod => 'Тестовый перевод'; + + @override + String get wDemoMethodHelp => + 'Попробуйте сценарий с тестовыми данными. Банки и платёжные системы не подключены.'; + + @override + String get wUnavailable => + 'Не удалось обновить данные. Информация может быть устаревшей. Повторите попытку.'; + + @override + String get wInvalidAmount => + 'Введите сумму больше нуля с допустимым для валюты числом знаков после запятой.'; + + @override + String get wInsufficient => + 'Для этой суммы и комиссии недостаточно доступных средств. Пополните счёт или уменьшите сумму.'; + + @override + String get wLimitError => + 'Сумма превышает доступный лимит. Проверьте лимиты перед продолжением.'; + + @override + String get wRecipientError => + 'Введите тестового получателя длиной от 6 до 140 символов.'; + + @override + String get wUnsupported => 'Эта комбинация недоступна. Выберите другой счёт.'; + + @override + String get wNotFound => 'Не удалось найти запись. Вернитесь к счетам.'; + + @override + String get wUncertain => + 'Не удалось подтвердить результат. Проверьте его, прежде чем создавать другую операцию.'; + + @override + String get wCheckOutcome => 'Проверить результат'; + + @override + String get wNoSubmission => + 'Операция не найдена. Вы можете снова проверить и подтвердить её.'; + + @override + String get wDiscardTitle => 'Выйти из операции?'; + + @override + String get wDiscardBody => + 'Введённые данные будут удалены. Новая операция не будет отправлена.'; + + @override + String get wStay => 'Продолжить'; + + @override + String get wAll => 'Все'; + + @override + String get wSearch => 'Поиск по номеру'; + + @override + String get wNoMatches => 'Операции не найдены. Измените фильтр.'; + + @override + String get wClearFilters => 'Сбросить фильтры'; + + @override + String get wReference => 'Номер операции'; + + @override + String get wWhen => 'Создано'; + + @override + String get wViewAccount => 'Открыть счёт'; + + @override + String get wPreferences => 'Настройте под себя.'; + + @override + String get wLanguage => 'Язык аккаунта'; + + @override + String get wLanguageHelp => 'Используется во всём аккаунте.'; + + @override + String get wLanguageSaved => 'Сохранено в тестовом аккаунте.'; + + @override + String get wLanguageFailed => + 'Изменено для текущей сессии, но не сохранено в аккаунте. Повторите попытку ниже.'; + + @override + String get wProfileError => + 'Не удалось загрузить настройки аккаунта. Повторите попытку.'; + + @override + String get wSecurity => 'Безопасность'; + + @override + String get wSecurityBody => + 'В этой демонстрации не подключены рабочие сессии, смена пароля и двухэтапная проверка.'; + + @override + String get wIdentity => 'Подтверждение личности'; + + @override + String get wIdentityHelp => 'Необязательная проверка для повышения лимитов.'; + + @override + String get wBackLogin => 'Вернуться ко входу'; + + @override + String get wEnter => 'Открыть свои счета'; + + @override + String get wUsage => 'Использовано за период'; + + @override + String get wLimitsScope => 'Исходящие операции · эквивалент в EUR'; + + @override + String get wPendingHint => + 'Обновите, чтобы узнать текущий статус. Остальными счетами можно пользоваться.'; + + @override + String get wFundsHeading => 'Где мои деньги?'; + + @override + String get wAnother => 'Создать новую операцию'; } diff --git a/frontend/app/lib/kyc/limits.dart b/frontend/app/lib/kyc/limits.dart index 2e39513..8dd8122 100644 --- a/frontend/app/lib/kyc/limits.dart +++ b/frontend/app/lib/kyc/limits.dart @@ -50,7 +50,6 @@ class LimitsPanel extends StatelessWidget { if (data.simulated) Text(l.kycLimitsDemo, style: t.textTheme.bodySmall), const SizedBox(height: 12), - Text(l.kycUsageHelp, style: t.textTheme.bodySmall), if (data.rows.isEmpty) Text(l.kycLimitsEmpty), for (final row in data.rows) ...[ const Divider(height: 32), @@ -69,11 +68,10 @@ class LimitsPanel extends StatelessWidget { row.currentMinor, row.currency, ), - _amount(context, l.kycUsed, row.usedMinor, row.currency), _amount( context, - l.kycReserved, - row.reservedMinor, + l.wUsage, + row.usedMinor + row.reservedMinor, row.currency, ), _amount( @@ -98,7 +96,7 @@ class LimitsPanel extends StatelessWidget { minHeight: 8, borderRadius: BorderRadius.circular(4), semanticsLabel: - '${row.period == LimitPeriod.day ? l.kycDay : l.kycMonth}: ${l.kycOccupied}', + '${row.period == LimitPeriod.day ? l.kycDay : l.kycMonth}: ${l.wUsage}', ), if (row.remainingMinor <= 0) ...[ const SizedBox(height: 8), diff --git a/frontend/app/lib/kyc/page.dart b/frontend/app/lib/kyc/page.dart index 57b0047..ad80c9b 100644 --- a/frontend/app/lib/kyc/page.dart +++ b/frontend/app/lib/kyc/page.dart @@ -8,9 +8,6 @@ import 'package:sxchnge_analytics/sxchnge_analytics.dart'; import 'package:sxchnge_kyc/sxchnge_kyc.dart'; -import '../auth/brand_panel.dart'; -import '../routing/routes.dart'; -import '../widgets/locale.dart'; import 'copy.dart'; import 'form.dart'; import 'limits.dart'; @@ -100,7 +97,13 @@ class IdentityPageState extends State { Future _leave({bool account = false}) async { if (!await allowExit() || !mounted) return; _identity.exit(); - if (account) context.goNamed(AppRoutes.login); + if (account) { + if (context.canPop()) { + context.pop(); + } else { + context.go('/limits'); + } + } } @override @@ -136,12 +139,6 @@ class IdentityPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const Wrap( - alignment: WrapAlignment.spaceBetween, - crossAxisAlignment: WrapCrossAlignment.center, - spacing: 16, - children: [BrandWordmark(), LocaleSelector()], - ), const SizedBox(height: 36), Semantics( header: true, @@ -225,7 +222,7 @@ class IdentityPageState extends State { onPressed: p.busy ? null : () => _leave(account: true), - child: Text(l.kycClose), + child: Text(l.wLimits), ), ], ), diff --git a/frontend/app/lib/l10n/en.arb b/frontend/app/lib/l10n/en.arb index 3cf71c8..53efba6 100644 --- a/frontend/app/lib/l10n/en.arb +++ b/frontend/app/lib/l10n/en.arb @@ -153,5 +153,92 @@ "kycReserved": "Reserved", "kycUsageHelp": "Used is completed activity. Reserved is held for pending transactions. Both count toward your limit.", "kycOccupied": "Used + reserved", - "kycNoCapacity": "No capacity available" + "kycNoCapacity": "No capacity available", + "wAccounts": "Accounts", + "wActivity": "Activity", + "wLimits": "Limits", + "wSettings": "Settings", + "wHeadline": "Your money.\nYour next move.", + "wSubheading": "Top up. Exchange. Go further.", + "wAvailable": "Available to you", + "wTopUp": "Top up", + "wExchange": "Exchange", + "wPayout": "Pay out", + "wRecent": "Recent activity", + "wSeeAll": "See all", + "wEmptyActivity": "Your next move starts here. Your transactions will appear below.", + "wEmptyAccounts": "Your accounts are not ready yet. Try refreshing.", + "wRefresh": "Refresh", + "wDemo": "Demo workspace · No real money moves.", + "wSource": "From account", + "wDestination": "To account", + "wReceiveAccount": "Receiving account", + "wAmount": "Amount", + "wRecipient": "Recipient", + "wRecipientHint": "Sample recipient — demo only", + "wReview": "Review", + "wReviewTitle": "One last look.", + "wFee": "Fee", + "wTotalDebit": "Leaves your account", + "wCredit": "Arrives in your account", + "wRecipientGets": "Recipient receives", + "wYouPay": "You pay", + "wRate": "Exchange rate", + "wQuoteNotice": "These terms are valid for 60 seconds. Confirm them before continuing.", + "wExpired": "This quote expired. Refresh it and review the new terms.", + "wNewQuote": "Refresh quote", + "wConfirm": "Confirm demo transaction", + "wEdit": "Edit details", + "wClose": "Close", + "wWorking": "Working on it…", + "wPending": "Processing", + "wCompleted": "Completed", + "wFailed": "Not completed", + "wPendingTopUp": "Your top-up is processing. Your available balance has not changed yet.", + "wPendingExchange": "The amount and fee have left your available balance. The exchanged amount will appear in the receiving account when completed.", + "wPendingPayout": "The amount and fee have left your available balance. The payout is still processing.", + "wCompleteTopUp": "Your account has been credited. The money is available to use.", + "wCompleteExchange": "Your exchange is complete. The money is available in the receiving account.", + "wCompletePayout": "Your payout is complete. The amount and fee have been deducted from your account.", + "wFailedTopUp": "The top-up did not complete. No money was added to your account.", + "wFailedOutgoing": "The transaction did not complete. The amount and fee are available in your account again.", + "wDemoMethod": "Demo transfer", + "wDemoMethodHelp": "Try the flow with sample data. No bank or payment provider is contacted.", + "wUnavailable": "Could not refresh. The displayed information may be out of date. Try again.", + "wInvalidAmount": "Enter an amount greater than zero, using the currency’s decimal precision.", + "wInsufficient": "Not enough available money for this amount and fee. Top up or enter a smaller amount.", + "wLimitError": "This amount exceeds your available limit. Check your limits before continuing.", + "wRecipientError": "Enter a sample recipient between 6 and 140 characters.", + "wUnsupported": "This combination is unavailable. Choose another account.", + "wNotFound": "This item could not be found. Return to your accounts.", + "wUncertain": "We could not confirm the outcome. Check it before trying another transaction.", + "wCheckOutcome": "Check outcome", + "wNoSubmission": "No transaction was found. You can review and confirm again.", + "wDiscardTitle": "Leave this transaction?", + "wDiscardBody": "Your entered details will be cleared. No new transaction will be submitted.", + "wStay": "Keep editing", + "wAll": "All", + "wSearch": "Search by reference", + "wNoMatches": "No matching transactions. Try another filter.", + "wClearFilters": "Clear filters", + "wReference": "Reference", + "wWhen": "Created", + "wViewAccount": "View account", + "wPreferences": "Make it yours.", + "wLanguage": "Account language", + "wLanguageHelp": "Used throughout your account.", + "wLanguageSaved": "Saved to your demo account.", + "wLanguageFailed": "Changed for this session, but not saved to your account. Retry below.", + "wProfileError": "Account preferences could not be loaded. Try again.", + "wSecurity": "Security", + "wSecurityBody": "Live sessions, password changes and two-step verification are not connected in this demo.", + "wIdentity": "Identity verification", + "wIdentityHelp": "Optional verification for higher limits.", + "wBackLogin": "Back to login", + "wEnter": "Explore your accounts", + "wUsage": "Used this period", + "wLimitsScope": "Outgoing activity · EUR equivalent", + "wPendingHint": "Refresh to check the latest status. You can keep using your other accounts.", + "wFundsHeading": "Where is my money?", + "wAnother": "Make another transaction" } diff --git a/frontend/app/lib/l10n/ru.arb b/frontend/app/lib/l10n/ru.arb index b967206..d1a19e3 100644 --- a/frontend/app/lib/l10n/ru.arb +++ b/frontend/app/lib/l10n/ru.arb @@ -153,5 +153,92 @@ "kycReserved": "Зарезервировано", "kycUsageHelp": "Использовано — завершённые операции. Зарезервировано — суммы, удержанные для ожидающих операций. Обе суммы учитываются в лимите.", "kycOccupied": "Использовано + зарезервировано", - "kycNoCapacity": "Доступный объём исчерпан" + "kycNoCapacity": "Доступный объём исчерпан", + "wAccounts": "Счета", + "wActivity": "История", + "wLimits": "Лимиты", + "wSettings": "Настройки", + "wHeadline": "Ваши деньги.\nВаш следующий шаг.", + "wSubheading": "Пополняйте. Обменивайте. Двигайтесь дальше.", + "wAvailable": "Вам доступно", + "wTopUp": "Пополнить", + "wExchange": "Обменять", + "wPayout": "Вывести", + "wRecent": "Последние операции", + "wSeeAll": "Смотреть все", + "wEmptyActivity": "Ваш следующий шаг начинается здесь. Здесь появятся ваши операции.", + "wEmptyAccounts": "Ваши счета ещё не готовы. Попробуйте обновить страницу.", + "wRefresh": "Обновить", + "wDemo": "Демонстрация · Реальные деньги не переводятся.", + "wSource": "Со счёта", + "wDestination": "На счёт", + "wReceiveAccount": "Счёт для зачисления", + "wAmount": "Сумма", + "wRecipient": "Получатель", + "wRecipientHint": "Тестовый получатель — только демо", + "wReview": "Проверить", + "wReviewTitle": "Проверьте детали.", + "wFee": "Комиссия", + "wTotalDebit": "Спишется со счёта", + "wCredit": "Поступит на счёт", + "wRecipientGets": "Получатель получит", + "wYouPay": "Вы платите", + "wRate": "Курс обмена", + "wQuoteNotice": "Эти условия действуют 60 секунд. Подтвердите их, прежде чем продолжить.", + "wExpired": "Срок действия расчёта истёк. Обновите его и проверьте новые условия.", + "wNewQuote": "Обновить расчёт", + "wConfirm": "Подтвердить тестовую операцию", + "wEdit": "Изменить данные", + "wClose": "Закрыть", + "wWorking": "Обрабатываем…", + "wPending": "В обработке", + "wCompleted": "Завершено", + "wFailed": "Не выполнено", + "wPendingTopUp": "Пополнение обрабатывается. Доступный баланс пока не изменился.", + "wPendingExchange": "Сумма и комиссия уже вычтены из доступного баланса. После завершения обмена деньги появятся на счёте зачисления.", + "wPendingPayout": "Сумма и комиссия уже вычтены из доступного баланса. Вывод ещё обрабатывается.", + "wCompleteTopUp": "Счёт пополнен. Деньги доступны для операций.", + "wCompleteExchange": "Обмен завершён. Деньги доступны на счёте зачисления.", + "wCompletePayout": "Вывод завершён. Сумма и комиссия списаны с вашего счёта.", + "wFailedTopUp": "Пополнение не выполнено. На счёт ничего не зачислено.", + "wFailedOutgoing": "Операция не выполнена. Сумма и комиссия снова доступны на вашем счёте.", + "wDemoMethod": "Тестовый перевод", + "wDemoMethodHelp": "Попробуйте сценарий с тестовыми данными. Банки и платёжные системы не подключены.", + "wUnavailable": "Не удалось обновить данные. Информация может быть устаревшей. Повторите попытку.", + "wInvalidAmount": "Введите сумму больше нуля с допустимым для валюты числом знаков после запятой.", + "wInsufficient": "Для этой суммы и комиссии недостаточно доступных средств. Пополните счёт или уменьшите сумму.", + "wLimitError": "Сумма превышает доступный лимит. Проверьте лимиты перед продолжением.", + "wRecipientError": "Введите тестового получателя длиной от 6 до 140 символов.", + "wUnsupported": "Эта комбинация недоступна. Выберите другой счёт.", + "wNotFound": "Не удалось найти запись. Вернитесь к счетам.", + "wUncertain": "Не удалось подтвердить результат. Проверьте его, прежде чем создавать другую операцию.", + "wCheckOutcome": "Проверить результат", + "wNoSubmission": "Операция не найдена. Вы можете снова проверить и подтвердить её.", + "wDiscardTitle": "Выйти из операции?", + "wDiscardBody": "Введённые данные будут удалены. Новая операция не будет отправлена.", + "wStay": "Продолжить", + "wAll": "Все", + "wSearch": "Поиск по номеру", + "wNoMatches": "Операции не найдены. Измените фильтр.", + "wClearFilters": "Сбросить фильтры", + "wReference": "Номер операции", + "wWhen": "Создано", + "wViewAccount": "Открыть счёт", + "wPreferences": "Настройте под себя.", + "wLanguage": "Язык аккаунта", + "wLanguageHelp": "Используется во всём аккаунте.", + "wLanguageSaved": "Сохранено в тестовом аккаунте.", + "wLanguageFailed": "Изменено для текущей сессии, но не сохранено в аккаунте. Повторите попытку ниже.", + "wProfileError": "Не удалось загрузить настройки аккаунта. Повторите попытку.", + "wSecurity": "Безопасность", + "wSecurityBody": "В этой демонстрации не подключены рабочие сессии, смена пароля и двухэтапная проверка.", + "wIdentity": "Подтверждение личности", + "wIdentityHelp": "Необязательная проверка для повышения лимитов.", + "wBackLogin": "Вернуться ко входу", + "wEnter": "Открыть свои счета", + "wUsage": "Использовано за период", + "wLimitsScope": "Исходящие операции · эквивалент в EUR", + "wPendingHint": "Обновите, чтобы узнать текущий статус. Остальными счетами можно пользоваться.", + "wFundsHeading": "Где мои деньги?", + "wAnother": "Создать новую операцию" } diff --git a/frontend/app/lib/main.dart b/frontend/app/lib/main.dart index 8986a0e..45e7217 100644 --- a/frontend/app/lib/main.dart +++ b/frontend/app/lib/main.dart @@ -10,6 +10,8 @@ import 'package:sxchnge_kyc/sxchnge_kyc.dart'; import 'package:sxchnge_theme/sxchnge_theme.dart'; +import 'package:sxchnge_wallet/sxchnge_wallet.dart'; + import 'providers/locale.dart'; import 'routing/router.dart'; @@ -28,6 +30,7 @@ class SxchngeApp extends StatefulWidget { this.analyticsService, this.identityService, this.limitsService, + this.walletService, this.photoService = const FilePhotoService(), this.loginService = const DummyLoginService(), this.signupService = const DummySignupService(), @@ -37,6 +40,7 @@ class SxchngeApp extends StatefulWidget { final AnalyticsService? analyticsService; final IdentityService? identityService; final LimitsService? limitsService; + final WalletService? walletService; final PhotoService photoService; final LoginService loginService; final SignupService signupService; @@ -51,7 +55,15 @@ class _SxchngeAppState extends State { late final _analytics = widget.analyticsService ?? DummyAnalyticsService(); late final _identity = widget.identityService ?? DummyIdentityService(); - late final _limits = widget.limitsService ?? DummyLimitsService(_identity); + late final _walletService = + widget.walletService ?? + DummyWalletService(_identity, initialLocale: _locale.locale.languageCode); + late final _wallet = WalletProvider(_walletService, _analytics); + late final _limits = + widget.limitsService ?? + (_walletService is DummyWalletService + ? WalletLimitsService(_walletService) + : DummyLimitsService(_identity)); late final _journeys = JourneyRecorder(_analytics); late final _services = InstrumentedAuthServices( recorder: _journeys, @@ -82,6 +94,7 @@ class _SxchngeAppState extends State { _router.routeInformationProvider.removeListener(_routeChanged); _router.dispose(); _journeys.dispose(); + _wallet.dispose(); _locale.dispose(); super.dispose(); } @@ -89,6 +102,7 @@ class _SxchngeAppState extends State { @override Widget build(BuildContext context) => MultiProvider( providers: [ + ChangeNotifierProvider.value(value: _wallet), Provider.value(value: _journeys), Provider.value(value: _analytics), Provider.value(value: _identity), diff --git a/frontend/app/lib/providers/locale.dart b/frontend/app/lib/providers/locale.dart index 2798933..c65c56e 100644 --- a/frontend/app/lib/providers/locale.dart +++ b/frontend/app/lib/providers/locale.dart @@ -2,12 +2,15 @@ import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sxchnge_wallet/sxchnge_wallet.dart'; + class LocaleProvider extends ChangeNotifier { static const storageKey = 'sxchnge_last_locale'; LocaleProvider({Locale initialLocale = const Locale('en'), this.preferences}) : _locale = resolve(initialLocale.languageCode, const []); Locale _locale; + bool _disposed = false; final SharedPreferences? preferences; Future _pendingSave = Future.value(true); Locale get locale => _locale; @@ -31,6 +34,22 @@ class LocaleProvider extends ChangeNotifier { } } + Future loadAccount(WalletProvider wallet) async { + await wallet.loadProfile(); + if (_disposed) return; + if (wallet.preferredLocale != null && !wallet.profileFailed) { + _locale = resolve(wallet.preferredLocale, const []); + notifyListeners(); + } + } + + Future saveAccountLocale(WalletProvider wallet, Locale value) async { + if (_disposed || wallet.saving) return; + _locale = resolve(value.languageCode, const []); + notifyListeners(); + await wallet.saveLocale(_locale.languageCode); + } + Future setLocale(Locale value) { final next = resolve(value.languageCode, const []); if (next == _locale) return _pendingSave; @@ -47,4 +66,10 @@ class LocaleProvider extends ChangeNotifier { }); return _pendingSave; } + + @override + void dispose() { + _disposed = true; + super.dispose(); + } } diff --git a/frontend/app/lib/routing/router.dart b/frontend/app/lib/routing/router.dart index f7c5da1..74fc17c 100644 --- a/frontend/app/lib/routing/router.dart +++ b/frontend/app/lib/routing/router.dart @@ -4,23 +4,101 @@ import 'package:flutter/widgets.dart'; import 'package:go_router/go_router.dart'; +import 'package:sxchnge_wallet/sxchnge_wallet.dart'; + import '../auth/page.dart'; import '../kyc/page.dart'; import '../verification/page.dart'; +import '../workspace/accounts.dart'; +import '../workspace/activity.dart'; +import '../workspace/limits.dart'; +import '../workspace/operation.dart'; +import '../workspace/settings.dart'; +import '../workspace/shell.dart'; +import '../workspace/transfer.dart'; import 'routes.dart'; GoRouter createAppRouter() { + final topUpKey = GlobalKey(); + final exchangeKey = GlobalKey(); + final payoutKey = GlobalKey(); final identityKey = GlobalKey(); return GoRouter( // Signup email is an in-memory navigation extra, never browser-history data. extraCodec: const _TransientExtras(), routes: [ - GoRoute( - path: '/identity', - name: AppRoutes.identity, - builder: (context, state) => IdentityPage(key: identityKey), - onExit: (context, state) async => - await identityKey.currentState?.allowExit() ?? true, + ShellRoute( + builder: (context, state, child) => + WorkspaceShell(path: state.uri.path, child: child), + routes: [ + GoRoute( + path: '/accounts', + builder: (context, state) => const AccountsPage(), + routes: [ + GoRoute( + path: ':accountId', + builder: (context, state) => + AccountsPage(accountId: state.pathParameters['accountId']), + ), + ], + ), + GoRoute( + path: '/activity', + builder: (context, state) => const ActivityPage(), + routes: [ + GoRoute( + path: ':operationId', + builder: (context, state) => + OperationPage(id: state.pathParameters['operationId']!), + ), + ], + ), + GoRoute( + path: '/limits', + builder: (context, state) => const LimitsPage(), + ), + GoRoute( + path: '/settings', + builder: (context, state) => const SettingsPage(), + ), + GoRoute( + path: '/top-up', + builder: (context, state) => TransferPage( + key: topUpKey, + kind: TransferKind.topUp, + source: state.extra is String ? state.extra as String : null, + ), + onExit: (context, state) async => + await topUpKey.currentState?.allowExit() ?? true, + ), + GoRoute( + path: '/exchange', + builder: (context, state) => TransferPage( + key: exchangeKey, + kind: TransferKind.exchange, + source: state.extra is String ? state.extra as String : null, + ), + onExit: (context, state) async => + await exchangeKey.currentState?.allowExit() ?? true, + ), + GoRoute( + path: '/payout', + builder: (context, state) => TransferPage( + key: payoutKey, + kind: TransferKind.payout, + source: state.extra is String ? state.extra as String : null, + ), + onExit: (context, state) async => + await payoutKey.currentState?.allowExit() ?? true, + ), + GoRoute( + path: '/identity', + name: AppRoutes.identity, + builder: (context, state) => IdentityPage(key: identityKey), + onExit: (context, state) async => + await identityKey.currentState?.allowExit() ?? true, + ), + ], ), GoRoute( path: '/', diff --git a/frontend/app/lib/verification/page.dart b/frontend/app/lib/verification/page.dart index 90cc69f..4663552 100644 --- a/frontend/app/lib/verification/page.dart +++ b/frontend/app/lib/verification/page.dart @@ -11,7 +11,6 @@ import 'package:sxchnge_auth/sxchnge_auth.dart'; import '../auth/brand_panel.dart'; import '../auth/validation.dart'; import '../routing/routes.dart'; -import '../widgets/locale.dart'; import '../generated/i18n/app_localizations.dart'; @@ -160,10 +159,6 @@ class _VerificationPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const Align( - alignment: Alignment.centerRight, - child: LocaleSelector(), - ), const Align( alignment: Alignment.centerLeft, child: BrandWordmark(), @@ -328,8 +323,8 @@ class _VerificationPageState extends State { if (verified) ...[ const SizedBox(height: 16), FilledButton( - onPressed: () => context.goNamed(AppRoutes.identity), - child: Text(l.kycPreview), + onPressed: () => context.go('/accounts'), + child: Text(l.wEnter), ), ], const SizedBox(height: 16), diff --git a/frontend/app/lib/widgets/locale.dart b/frontend/app/lib/widgets/locale.dart deleted file mode 100644 index 0e02876..0000000 --- a/frontend/app/lib/widgets/locale.dart +++ /dev/null @@ -1,54 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:provider/provider.dart'; - -import '../providers/locale.dart'; - -import '../generated/i18n/app_localizations.dart'; - -class LocaleSelector extends StatelessWidget { - const LocaleSelector({super.key}); - - @override - Widget build(BuildContext context) { - final provider = context.watch(); - return PopupMenuButton( - key: const ValueKey('locale-selector'), - tooltip: AppLocalizations.of(context)!.language, - onSelected: (locale) async { - final saved = await provider.setLocale(locale); - if (!saved && context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppLocalizations.of(context)!.preferenceNotSaved), - ), - ); - } - }, - itemBuilder: (_) => AppLocalizations.supportedLocales - .map( - (locale) => CheckedPopupMenuItem( - value: locale, - checked: locale == provider.locale, - child: Text(locale.languageCode == 'ru' ? 'Русский' : 'English'), - ), - ) - .toList(), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.language, size: 20), - const SizedBox(width: 8), - Text( - provider.locale.languageCode.toUpperCase(), - style: Theme.of(context).textTheme.labelLarge, - ), - const Icon(Icons.expand_more, size: 20), - ], - ), - ), - ); - } -} diff --git a/frontend/app/lib/workspace/accounts.dart b/frontend/app/lib/workspace/accounts.dart new file mode 100644 index 0000000..996a9d1 --- /dev/null +++ b/frontend/app/lib/workspace/accounts.dart @@ -0,0 +1,245 @@ +import 'package:flutter/material.dart'; + +import 'package:go_router/go_router.dart'; + +import 'package:provider/provider.dart'; + +import 'package:sxchnge_wallet/sxchnge_wallet.dart'; + +import 'activity.dart'; +import 'copy.dart'; + +import '../generated/i18n/app_localizations.dart'; + +class AccountsPage extends StatelessWidget { + const AccountsPage({super.key, this.accountId}); + final String? accountId; + @override + Widget build(BuildContext context) { + final p = context.watch(); + final l = AppLocalizations.of(context)!; + final t = Theme.of(context); + final c = t.colorScheme; + final accounts = p.data?.accounts ?? []; + final selected = accountId == null + ? accounts.firstOrNull + : p.account(accountId); + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 1120), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text(l.wHeadline, style: t.textTheme.displayMedium), + ), + IconButton( + tooltip: l.wRefresh, + onPressed: p.loading ? null : p.refresh, + icon: const Icon(Icons.refresh), + ), + ], + ), + const SizedBox(height: 12), + Text(l.wSubheading, style: t.textTheme.bodyLarge), + const SizedBox(height: 28), + if (p.loading) const LinearProgressIndicator(), + if (p.failed) + Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Text(l.wUnavailable, style: TextStyle(color: c.error)), + ), + if (!p.loading && accounts.isEmpty) Text(l.wEmptyAccounts), + if (accountId != null && + p.account(accountId) == null && + !p.loading) + Text(l.wNotFound), + if (selected != null) ...[ + const SizedBox(height: 24), + Text(selected.currency, style: t.textTheme.titleLarge), + const SizedBox(height: 12), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + for (final kind in TransferKind.values) + _action( + context, + kind, + selected, + primary: + kind == + (selected.available == 0 + ? TransferKind.topUp + : TransferKind.exchange), + ), + ], + ), + ], + const SizedBox(height: 24), + LayoutBuilder( + builder: (context, box) { + final width = box.maxWidth >= 660 + ? (box.maxWidth - 32) / 3 + : box.maxWidth; + return Wrap( + spacing: 16, + runSpacing: 16, + children: [ + for (var i = 0; i < accounts.length; i++) + SizedBox( + width: width, + child: _AccountCard( + account: accounts[i], + selected: accounts[i].id == selected?.id, + index: i, + ), + ), + ], + ); + }, + ), + const SizedBox(height: 36), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l.wRecent, style: t.textTheme.headlineSmall), + TextButton( + onPressed: () => context.go('/activity'), + child: Text(l.wSeeAll), + ), + ], + ), + const SizedBox(height: 12), + ...?p.data?.operations + .where( + (o) => + accountId == null || + o.quote.request.source == accountId || + o.quote.request.destination == accountId, + ) + .take(5) + .map((o) => OperationTile(operation: o)), + if (!(p.data?.operations.any( + (o) => + accountId == null || + o.quote.request.source == accountId || + o.quote.request.destination == accountId, + ) ?? + false)) + Container( + padding: const EdgeInsets.all(28), + decoration: BoxDecoration( + color: c.surfaceContainer, + borderRadius: BorderRadius.circular(24), + ), + child: Text(l.wEmptyActivity), + ), + ], + ), + ), + ), + ); + } + + Widget _action( + BuildContext context, + TransferKind kind, + CurrencyAccount a, { + required bool primary, + }) { + final l = AppLocalizations.of(context)!; + void go() => context.go( + '/${kind.name == "topUp" ? "top-up" : kind.name}', + extra: a.id, + ); + return primary + ? FilledButton.icon( + key: ValueKey('${kind.name}-${a.id}'), + onPressed: go, + icon: Icon(kindIcon(kind)), + label: Text(kindText(l, kind)), + ) + : FilledButton.tonalIcon( + key: ValueKey('${kind.name}-${a.id}'), + onPressed: go, + icon: Icon(kindIcon(kind)), + label: Text(kindText(l, kind)), + ); + } +} + +class _AccountCard extends StatelessWidget { + const _AccountCard({ + required this.account, + required this.selected, + required this.index, + }); + final CurrencyAccount account; + final bool selected; + final int index; + @override + Widget build(BuildContext context) { + final l = AppLocalizations.of(context)!; + final t = Theme.of(context); + final c = t.colorScheme; + final bg = selected + ? c.primary + : index == 1 + ? c.tertiaryContainer + : c.primaryContainer; + final fg = selected + ? c.onPrimary + : index == 1 + ? c.onTertiaryContainer + : c.onPrimaryContainer; + return Semantics( + selected: selected, + child: Material( + color: bg, + borderRadius: BorderRadius.circular(28), + child: InkWell( + key: ValueKey('account-${account.id}'), + borderRadius: BorderRadius.circular(28), + onTap: () => context.go('/accounts/${account.id}'), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Text( + account.currency, + style: t.textTheme.titleLarge?.copyWith(color: fg), + ), + const Spacer(), + Icon( + selected + ? Icons.check_circle_outline + : Icons.arrow_outward_rounded, + color: fg, + ), + ], + ), + const SizedBox(height: 32), + Text(l.wAvailable, style: TextStyle(color: fg)), + const SizedBox(height: 8), + Text( + money(context, account, account.available), + key: ValueKey('balance-${account.id}'), + style: t.textTheme.headlineMedium?.copyWith(color: fg), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/frontend/app/lib/workspace/activity.dart b/frontend/app/lib/workspace/activity.dart new file mode 100644 index 0000000..fa931ec --- /dev/null +++ b/frontend/app/lib/workspace/activity.dart @@ -0,0 +1,196 @@ +import 'package:flutter/material.dart'; + +import 'package:go_router/go_router.dart'; + +import 'package:intl/intl.dart'; + +import 'package:provider/provider.dart'; + +import 'package:sxchnge_wallet/sxchnge_wallet.dart'; + +import 'copy.dart'; + +import '../generated/i18n/app_localizations.dart'; + +class ActivityPage extends StatefulWidget { + const ActivityPage({super.key}); + @override + State createState() => _ActivityPageState(); +} + +class _ActivityPageState extends State { + TransferKind? _kind; + OperationStatus? _status; + String _search = ''; + String? _currency; + @override + Widget build(BuildContext context) { + final p = context.watch(); + final l = AppLocalizations.of(context)!; + final list = + p.data?.operations + .where( + (o) => + (_kind == null || o.quote.request.kind == _kind) && + (_status == null || o.status == _status) && + (_currency == null || + o.quote.request.source == _currency || + o.quote.request.destination == _currency) && + o.id.toLowerCase().contains(_search.toLowerCase()), + ) + .toList() ?? + []; + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 1000), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: Text( + l.wActivity, + style: Theme.of(context).textTheme.displaySmall, + ), + ), + IconButton( + tooltip: l.wRefresh, + onPressed: p.loading ? null : p.refresh, + icon: const Icon(Icons.refresh), + ), + ], + ), + const SizedBox(height: 24), + TextField( + decoration: InputDecoration( + labelText: l.wSearch, + prefixIcon: const Icon(Icons.search), + ), + onChanged: (value) => setState(() => _search = value), + ), + const SizedBox(height: 16), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + ChoiceChip( + label: Text(l.wAll), + selected: _kind == null, + onSelected: (_) => setState(() => _kind = null), + ), + for (final k in TransferKind.values) + ChoiceChip( + label: Text(kindText(l, k)), + selected: _kind == k, + onSelected: (_) => setState(() => _kind = k), + ), + ], + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final s in OperationStatus.values) + FilterChip( + label: Text(statusText(l, s)), + selected: _status == s, + onSelected: (value) => + setState(() => _status = value ? s : null), + ), + for (final a in p.data?.accounts ?? []) + FilterChip( + label: Text(a.currency), + selected: _currency == a.id, + onSelected: (value) => + setState(() => _currency = value ? a.id : null), + ), + ], + ), + const SizedBox(height: 20), + if (p.loading) const LinearProgressIndicator(), + if (p.failed) Text(l.wUnavailable), + if (list.isEmpty && !p.loading) + Padding( + padding: const EdgeInsets.all(24), + child: Text( + (p.data?.operations.isEmpty ?? true) + ? l.wEmptyActivity + : l.wNoMatches, + ), + ), + for (final o in list) OperationTile(operation: o), + ], + ), + ), + ), + ); + } +} + +class OperationTile extends StatelessWidget { + const OperationTile({super.key, required this.operation}); + final WalletOperation operation; + @override + Widget build(BuildContext context) { + final p = context.read(); + final l = AppLocalizations.of(context)!; + final o = operation; + final a = p.account(o.quote.request.source); + final to = p.account(o.quote.request.destination); + final t = Theme.of(context); + return Card( + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: () => context.push('/activity/${o.id}'), + child: Padding( + padding: const EdgeInsets.all(20), + child: Row( + children: [ + CircleAvatar( + backgroundColor: t.colorScheme.primaryContainer, + child: Icon( + kindIcon(o.quote.request.kind), + color: t.colorScheme.onPrimaryContainer, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + kindText(l, o.quote.request.kind), + style: t.textTheme.titleMedium, + ), + Text( + '${a?.currency ?? ''}${to == null ? '' : ' → ${to.currency}'} · ${statusText(l, o.status)}', + ), + Text( + DateFormat.yMMMd( + Localizations.localeOf(context).toLanguageTag(), + ).add_Hm().format(o.createdAt), + style: t.textTheme.bodySmall, + ), + ], + ), + ), + const SizedBox(width: 8), + Flexible( + child: Text( + a == null ? '' : money(context, a, o.quote.request.amount), + textAlign: TextAlign.end, + style: t.textTheme.titleMedium, + ), + ), + const Icon(Icons.chevron_right), + ], + ), + ), + ), + ); + } +} diff --git a/frontend/app/lib/workspace/copy.dart b/frontend/app/lib/workspace/copy.dart new file mode 100644 index 0000000..bfe3001 --- /dev/null +++ b/frontend/app/lib/workspace/copy.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; + +import 'package:intl/intl.dart'; + +import 'package:sxchnge_wallet/sxchnge_wallet.dart'; + +import '../generated/i18n/app_localizations.dart'; + +String money(BuildContext context, CurrencyAccount account, int amount) => + NumberFormat.currency( + locale: Localizations.localeOf(context).toLanguageTag(), + name: account.currency, + symbol: '${account.currency} ', + decimalDigits: account.precision, + ).format(amount / account.scale); +String kindText(AppLocalizations l, TransferKind kind) => switch (kind) { + TransferKind.topUp => l.wTopUp, + TransferKind.exchange => l.wExchange, + TransferKind.payout => l.wPayout, +}; +IconData kindIcon(TransferKind kind) => switch (kind) { + TransferKind.topUp => Icons.add_rounded, + TransferKind.exchange => Icons.swap_horiz_rounded, + TransferKind.payout => Icons.north_east_rounded, +}; +String statusText(AppLocalizations l, OperationStatus s) => switch (s) { + OperationStatus.pending => l.wPending, + OperationStatus.completed => l.wCompleted, + OperationStatus.failed => l.wFailed, +}; +String fundsText(AppLocalizations l, WalletOperation o) => + switch ((o.status, o.quote.request.kind)) { + (OperationStatus.pending, TransferKind.topUp) => l.wPendingTopUp, + (OperationStatus.pending, TransferKind.exchange) => l.wPendingExchange, + (OperationStatus.pending, TransferKind.payout) => l.wPendingPayout, + (OperationStatus.completed, TransferKind.topUp) => l.wCompleteTopUp, + (OperationStatus.completed, TransferKind.exchange) => l.wCompleteExchange, + (OperationStatus.completed, TransferKind.payout) => l.wCompletePayout, + (OperationStatus.failed, TransferKind.topUp) => l.wFailedTopUp, + _ => l.wFailedOutgoing, + }; +String walletError(AppLocalizations l, WalletError e) => switch (e) { + WalletError.invalidAmount => l.wInvalidAmount, + WalletError.insufficientFunds => l.wInsufficient, + WalletError.limitExceeded => l.wLimitError, + WalletError.expiredQuote => l.wExpired, + WalletError.invalidRecipient => l.wRecipientError, + WalletError.unsupported => l.wUnsupported, + WalletError.notFound => l.wNotFound, + WalletError.uncertain => l.wUncertain, + _ => l.wUnavailable, +}; diff --git a/frontend/app/lib/workspace/limits.dart b/frontend/app/lib/workspace/limits.dart new file mode 100644 index 0000000..543af5c --- /dev/null +++ b/frontend/app/lib/workspace/limits.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; + +import 'package:go_router/go_router.dart'; + +import 'package:provider/provider.dart'; + +import 'package:sxchnge_kyc/sxchnge_kyc.dart'; + +import 'package:sxchnge_wallet/sxchnge_wallet.dart'; + +import '../kyc/limits.dart'; + +import '../generated/i18n/app_localizations.dart'; + +class LimitsPage extends StatefulWidget { + const LimitsPage({super.key}); + @override + State createState() => _LimitsPageState(); +} + +class _LimitsPageState extends State { + late final _provider = WorkspaceLimitsProvider( + context.read(), + context.read(), + ); + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _provider.refresh(); + }); + } + + @override + void dispose() { + _provider.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l = AppLocalizations.of(context)!; + return ChangeNotifierProvider.value( + value: _provider, + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 720), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + l.wLimits, + style: Theme.of(context).textTheme.displaySmall, + ), + const SizedBox(height: 12), + Text(l.wLimitsScope), + const SizedBox(height: 24), + const LimitsPanel(), + const SizedBox(height: 20), + FilledButton( + onPressed: () async { + await context.push('/identity'); + if (mounted) _provider.refresh(); + }, + child: Text(l.wIdentity), + ), + TextButton( + onPressed: _provider.refresh, + child: Text(l.wRefresh), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/frontend/app/lib/workspace/operation.dart b/frontend/app/lib/workspace/operation.dart new file mode 100644 index 0000000..79ce917 --- /dev/null +++ b/frontend/app/lib/workspace/operation.dart @@ -0,0 +1,152 @@ +import 'package:flutter/material.dart'; + +import 'package:go_router/go_router.dart'; + +import 'package:provider/provider.dart'; + +import 'package:sxchnge_wallet/sxchnge_wallet.dart'; + +import 'copy.dart'; +import 'summary.dart'; + +import '../generated/i18n/app_localizations.dart'; + +class OperationPage extends StatefulWidget { + const OperationPage({super.key, required this.id}); + final String id; + @override + State createState() => _OperationPageState(); +} + +class _OperationPageState extends State { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) context.read().refresh(); + }); + } + + @override + Widget build(BuildContext context) { + final p = context.watch(); + final o = p.operation(widget.id); + final l = AppLocalizations.of(context)!; + final t = Theme.of(context); + final c = t.colorScheme; + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 640), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + onPressed: () => context.go('/activity'), + icon: const Icon(Icons.arrow_back), + label: Text(l.wActivity), + ), + ), + if (p.failed) + Text(l.wUnavailable, style: TextStyle(color: c.error)), + if (o == null) ...[ + Text(p.loading ? l.wWorking : l.wNotFound), + TextButton( + onPressed: p.loading ? null : p.refresh, + child: Text(l.wRefresh), + ), + ] else ...[ + Container( + padding: const EdgeInsets.all(28), + decoration: BoxDecoration( + color: o.status == OperationStatus.failed + ? c.errorContainer + : c.primaryContainer, + borderRadius: BorderRadius.circular(28), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + o.status == OperationStatus.completed + ? Icons.check_circle_outline + : o.status == OperationStatus.pending + ? Icons.schedule + : Icons.info_outline, + size: 40, + color: o.status == OperationStatus.failed + ? c.onErrorContainer + : c.onPrimaryContainer, + ), + const SizedBox(height: 16), + Semantics( + header: true, + liveRegion: true, + child: Text( + statusText(l, o.status), + key: ValueKey('operation-${o.status.name}'), + style: t.textTheme.displaySmall?.copyWith( + color: o.status == OperationStatus.failed + ? c.onErrorContainer + : c.onPrimaryContainer, + ), + ), + ), + const SizedBox(height: 16), + Text( + fundsText(l, o), + style: TextStyle( + color: o.status == OperationStatus.failed + ? c.onErrorContainer + : c.onPrimaryContainer, + ), + ), + ], + ), + ), + const SizedBox(height: 20), + TransferSummary(quote: o.quote), + const SizedBox(height: 16), + Text('${l.wReference}: ${o.id}'), + if (o.status == OperationStatus.pending) ...[ + const SizedBox(height: 12), + Text(l.wPendingHint), + TextButton( + onPressed: p.loading ? null : p.refresh, + child: Text(l.wRefresh), + ), + ], + const SizedBox(height: 24), + if (o.status == OperationStatus.completed && + o.quote.request.kind != TransferKind.payout) + FilledButton( + onPressed: () => context.go( + o.quote.request.kind == TransferKind.topUp + ? '/exchange' + : '/payout', + extra: + o.quote.request.destination ?? o.quote.request.source, + ), + child: Text( + o.quote.request.kind == TransferKind.topUp + ? l.wExchange + : l.wPayout, + ), + ), + TextButton( + onPressed: () => context.go( + '/accounts/${o.quote.request.destination ?? o.quote.request.source}', + ), + child: Text(l.wViewAccount), + ), + ], + ], + ), + ), + ), + ); + } +} diff --git a/frontend/app/lib/workspace/settings.dart b/frontend/app/lib/workspace/settings.dart new file mode 100644 index 0000000..ac691f9 --- /dev/null +++ b/frontend/app/lib/workspace/settings.dart @@ -0,0 +1,132 @@ +import 'package:flutter/material.dart'; + +import 'package:go_router/go_router.dart'; + +import 'package:provider/provider.dart'; + +import 'package:sxchnge_wallet/sxchnge_wallet.dart'; + +import '../providers/locale.dart'; + +import '../generated/i18n/app_localizations.dart'; + +class SettingsPage extends StatelessWidget { + const SettingsPage({super.key}); + @override + Widget build(BuildContext context) { + final p = context.watch(); + final locale = context.watch(); + final l = AppLocalizations.of(context)!; + final t = Theme.of(context); + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 720), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (context.canPop()) + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + onPressed: () => context.pop(), + icon: const Icon(Icons.arrow_back), + label: Text(l.kycBack), + ), + ), + Text(l.wPreferences, style: t.textTheme.displayMedium), + const SizedBox(height: 28), + Card( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(l.wLanguage, style: t.textTheme.titleLarge), + const SizedBox(height: 8), + Text(l.wLanguageHelp), + const SizedBox(height: 20), + if (p.profileLoading) const LinearProgressIndicator(), + if (p.profileFailed) ...[ + Text(l.wProfileError), + TextButton( + onPressed: () => locale.loadAccount(p), + child: Text(l.wRefresh), + ), + ], + DropdownButtonFormField( + key: ValueKey( + 'account-language-${locale.locale.languageCode}', + ), + initialValue: locale.locale.languageCode, + isExpanded: true, + items: const [ + DropdownMenuItem(value: 'en', child: Text('English')), + DropdownMenuItem(value: 'ru', child: Text('Русский')), + ], + onChanged: p.saving || p.profileLoading + ? null + : (value) { + if (value != null) { + locale.saveAccountLocale(p, Locale(value)); + } + }, + decoration: InputDecoration(labelText: l.wLanguage), + ), + if (p.saving) const LinearProgressIndicator(), + if (p.saveFailed) ...[ + const SizedBox(height: 12), + Semantics( + liveRegion: true, + child: Text(l.wLanguageFailed), + ), + TextButton( + onPressed: p.saving + ? null + : () => + locale.saveAccountLocale(p, locale.locale), + child: Text(l.wRefresh), + ), + ], + ], + ), + ), + ), + const SizedBox(height: 16), + Card( + child: ListTile( + contentPadding: const EdgeInsets.all(24), + leading: const Icon(Icons.verified_user_outlined), + title: Text(l.wIdentity), + subtitle: Text(l.wIdentityHelp), + trailing: const Icon(Icons.chevron_right), + onTap: () => context.go('/identity'), + ), + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l.wSecurity, style: t.textTheme.titleLarge), + const SizedBox(height: 12), + Text(l.wSecurityBody), + ], + ), + ), + ), + const SizedBox(height: 20), + TextButton( + onPressed: () => context.go('/'), + child: Text(l.wBackLogin), + ), + ], + ), + ), + ), + ); + } +} diff --git a/frontend/app/lib/workspace/shell.dart b/frontend/app/lib/workspace/shell.dart new file mode 100644 index 0000000..9c9292b --- /dev/null +++ b/frontend/app/lib/workspace/shell.dart @@ -0,0 +1,134 @@ +import 'package:flutter/material.dart'; + +import 'package:go_router/go_router.dart'; + +import 'package:provider/provider.dart'; + +import 'package:sxchnge_wallet/sxchnge_wallet.dart'; + +import '../auth/brand_panel.dart'; +import '../providers/locale.dart'; + +import '../generated/i18n/app_localizations.dart'; + +class WorkspaceShell extends StatefulWidget { + const WorkspaceShell({super.key, required this.child, required this.path}); + final Widget child; + final String path; + @override + State createState() => _WorkspaceShellState(); +} + +class _WorkspaceShellState extends State { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final wallet = context.read(); + if (wallet.data == null) wallet.refresh(); + context.read().loadAccount(wallet); + }); + } + + @override + Widget build(BuildContext context) { + final l = AppLocalizations.of(context)!; + final t = Theme.of(context); + final c = t.colorScheme; + final paths = ['/accounts', '/activity', '/limits', '/settings']; + final labels = [l.wAccounts, l.wActivity, l.wLimits, l.wSettings]; + final icons = [ + Icons.account_balance_wallet_outlined, + Icons.receipt_long_outlined, + Icons.speed_outlined, + Icons.tune_rounded, + ]; + final index = widget.path.startsWith('/activity') + ? 1 + : widget.path.startsWith('/limits') || + widget.path.startsWith('/identity') + ? 2 + : widget.path.startsWith('/settings') + ? 3 + : 0; + void navigate(int i) { + if (['/top-up', '/exchange', '/payout'].contains(widget.path) && i >= 2) { + context.push(paths[i]); + } else { + context.go(paths[i]); + } + } + + final wide = MediaQuery.sizeOf(context).width >= 900; + final content = Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + child: Column( + crossAxisAlignment: wide + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, + children: [ + if (!wide) ...[const BrandWordmark(), const SizedBox(height: 8)], + Text(l.wDemo, style: t.textTheme.bodySmall), + ], + ), + ), + Expanded(child: widget.child), + ], + ); + return Scaffold( + body: SafeArea( + child: Row( + children: [ + if (wide) + Container( + width: 220, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: c.surfaceContainerLow, + border: Border(right: BorderSide(color: c.outlineVariant)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: BrandWordmark(), + ), + const SizedBox(height: 48), + for (var i = 0; i < 4; i++) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: ListTile( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(18), + ), + selected: i == index, + selectedTileColor: c.primaryContainer, + leading: Icon(icons[i]), + title: Text(labels[i]), + onTap: () => navigate(i), + ), + ), + ], + ), + ), + Expanded(child: content), + ], + ), + ), + bottomNavigationBar: wide + ? null + : NavigationBar( + selectedIndex: index, + onDestinationSelected: navigate, + destinations: [ + for (var i = 0; i < 4; i++) + NavigationDestination(icon: Icon(icons[i]), label: labels[i]), + ], + ), + ); + } +} diff --git a/frontend/app/lib/workspace/summary.dart b/frontend/app/lib/workspace/summary.dart new file mode 100644 index 0000000..e36323f --- /dev/null +++ b/frontend/app/lib/workspace/summary.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; + +import 'package:intl/intl.dart'; + +import 'package:provider/provider.dart'; + +import 'package:sxchnge_wallet/sxchnge_wallet.dart'; + +import 'copy.dart'; + +import '../generated/i18n/app_localizations.dart'; + +class TransferSummary extends StatelessWidget { + const TransferSummary({super.key, required this.quote}); + final Quote quote; + @override + Widget build(BuildContext context) { + final p = context.read(); + final l = AppLocalizations.of(context)!; + final q = quote; + final a = p.account(q.request.source); + final b = p.account(q.request.destination) ?? a; + if (a == null || b == null) return Text(l.wNotFound); + return Card( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _row( + context, + q.request.kind == TransferKind.topUp ? l.wYouPay : l.wTotalDebit, + money(context, a, q.debit), + large: true, + ), + const Divider(height: 32), + _row(context, l.wFee, money(context, a, q.fee)), + if (q.request.kind == TransferKind.exchange) ...[ + const SizedBox(height: 16), + _row( + context, + l.wRate, + '1 ${a.currency} = ${NumberFormat('0.0000', Localizations.localeOf(context).toLanguageTag()).format(q.rateNumerator / q.rateDenominator)} ${b.currency}', + ), + ], + const SizedBox(height: 16), + _row( + context, + q.request.kind == TransferKind.payout + ? l.wRecipientGets + : l.wCredit, + money(context, b, q.credit), + large: true, + ), + if (q.request.kind == TransferKind.payout) ...[ + const Divider(height: 32), + Text(l.wRecipient), + SelectableText(q.request.recipient), + ], + ], + ), + ), + ); + } + + Widget _row( + BuildContext context, + String label, + String value, { + bool large = false, + }) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label), + const SizedBox(height: 4), + Text( + value, + style: large + ? Theme.of(context).textTheme.headlineSmall + : Theme.of(context).textTheme.titleMedium, + ), + ], + ); +} diff --git a/frontend/app/lib/workspace/transfer.dart b/frontend/app/lib/workspace/transfer.dart new file mode 100644 index 0000000..b4fd263 --- /dev/null +++ b/frontend/app/lib/workspace/transfer.dart @@ -0,0 +1,310 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import 'package:go_router/go_router.dart'; + +import 'package:provider/provider.dart'; + +import 'package:sxchnge_wallet/sxchnge_wallet.dart'; + +import 'copy.dart'; +import 'summary.dart'; + +import '../generated/i18n/app_localizations.dart'; + +class TransferPage extends StatefulWidget { + const TransferPage({super.key, required this.kind, this.source}); + final TransferKind kind; + final String? source; + @override + State createState() => TransferPageState(); +} + +class TransferPageState extends State { + late final _provider = TransferProvider( + context.read(), + widget.kind, + source: widget.source, + ); + final _amount = TextEditingController(); + final _recipient = TextEditingController(); + final _focus = FocusNode(); + final _content = GlobalKey(); + Timer? _timer; + bool _navigated = false; + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _provider.addListener(_changed); + }); + _timer = Timer.periodic(const Duration(seconds: 1), (_) { + if (mounted && _provider.quote != null) setState(() {}); + }); + } + + void _changed() { + if (_provider.result != null && !_navigated) { + _navigated = true; + context.go('/activity/${_provider.result!.id}'); + return; + } + if (!_provider.busy && + (_provider.quote != null || _provider.error != null)) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _content.currentContext != null) { + Scrollable.ensureVisible( + _content.currentContext!, + duration: const Duration(milliseconds: 150), + ); + _focus.requestFocus(); + } + }); + } + } + + Future allowExit() async { + if (_provider.result != null) return true; + if (_provider.busy || _provider.uncertain) return false; + if (!_provider.hasDraft) return true; + final l = AppLocalizations.of(context)!; + return await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(l.wDiscardTitle), + content: Text(l.wDiscardBody), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(l.wStay), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: Text(l.wClose), + ), + ], + ), + ) ?? + false; + } + + @override + void dispose() { + _timer?.cancel(); + _provider.removeListener(_changed); + _provider.dispose(); + _amount.dispose(); + _recipient.dispose(); + _focus.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => ListenableBuilder( + listenable: _provider, + builder: (context, _) { + final p = _provider; + final wallet = context.watch(); + final l = AppLocalizations.of(context)!; + final t = Theme.of(context); + final accounts = wallet.data?.accounts ?? []; + final source = wallet.account(p.source); + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 640), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: Text( + p.quote == null + ? kindText(l, widget.kind) + : l.wReviewTitle, + style: t.textTheme.displaySmall, + ), + ), + IconButton( + tooltip: l.wClose, + onPressed: p.busy || p.uncertain + ? null + : () => context.go('/accounts'), + icon: const Icon(Icons.close), + ), + ], + ), + const SizedBox(height: 20), + Text(l.wDemoMethodHelp, style: t.textTheme.bodySmall), + const SizedBox(height: 24), + Focus( + key: _content, + focusNode: _focus, + skipTraversal: true, + includeSemantics: false, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (p.busy) ...[ + const LinearProgressIndicator(), + const SizedBox(height: 12), + Semantics(liveRegion: true, child: Text(l.wWorking)), + ], + if (p.error != null) + Padding( + padding: const EdgeInsets.only(bottom: 20), + child: Semantics( + liveRegion: true, + child: Text( + walletError(l, p.error!), + key: const ValueKey('transfer-error'), + style: TextStyle(color: t.colorScheme.error), + ), + ), + ), + if (p.noSubmission) Text(l.wNoSubmission), + if (p.uncertain) ...[ + Text(l.wUncertain), + FilledButton( + onPressed: p.busy ? null : p.reconcile, + child: Text(l.wCheckOutcome), + ), + ] else if (p.quote != null) ...[ + TransferSummary(quote: p.quote!), + const SizedBox(height: 16), + Text(p.expired ? l.wExpired : l.wQuoteNotice), + const SizedBox(height: 20), + if (p.expired) + FilledButton( + onPressed: p.busy ? null : p.review, + child: Text(l.wNewQuote), + ) + else + FilledButton( + key: const ValueKey('transfer-confirm'), + onPressed: p.busy ? null : p.submit, + child: Text(l.wConfirm), + ), + TextButton( + onPressed: p.busy ? null : p.edit, + child: Text(l.wEdit), + ), + ] else ...[ + if (accounts.isEmpty) ...[ + Text(wallet.loading ? l.wWorking : l.wEmptyAccounts), + TextButton( + onPressed: wallet.loading ? null : wallet.refresh, + child: Text(l.wRefresh), + ), + ] else ...[ + DropdownButtonFormField( + key: ValueKey('source-${p.source}'), + initialValue: accounts.any((a) => a.id == p.source) + ? p.source + : null, + isExpanded: true, + decoration: InputDecoration( + labelText: widget.kind == TransferKind.topUp + ? l.wReceiveAccount + : l.wSource, + ), + items: accounts + .map( + (a) => DropdownMenuItem( + value: a.id, + child: Text(a.currency), + ), + ) + .toList(), + onChanged: p.busy + ? null + : (v) => p.change(source: v), + ), + if (source != null) ...[ + const SizedBox(height: 10), + Text( + '${l.wAvailable}: ${money(context, source, source.available)}', + ), + ], + if (widget.kind == TransferKind.exchange) ...[ + const SizedBox(height: 20), + DropdownButtonFormField( + key: ValueKey('destination-${p.destination}'), + initialValue: p.destination, + isExpanded: true, + decoration: InputDecoration( + labelText: l.wDestination, + ), + items: accounts + .map( + (a) => DropdownMenuItem( + value: a.id, + child: Text(a.currency), + ), + ) + .toList(), + onChanged: p.busy + ? null + : (v) => p.change(destination: v), + ), + ], + const SizedBox(height: 20), + TextField( + key: const ValueKey('transfer-amount'), + controller: _amount, + enabled: !p.busy, + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + decoration: InputDecoration( + labelText: l.wAmount, + suffixText: source?.currency, + ), + onChanged: (v) => p.change(amount: v), + ), + if (widget.kind == TransferKind.payout) ...[ + const SizedBox(height: 20), + TextField( + key: const ValueKey('transfer-recipient'), + controller: _recipient, + enabled: !p.busy, + decoration: InputDecoration( + labelText: l.wRecipient, + hintText: l.wRecipientHint, + ), + onChanged: (v) => p.change(recipient: v), + ), + ], + const SizedBox(height: 28), + FilledButton( + key: const ValueKey('transfer-review'), + onPressed: p.busy ? null : p.review, + child: Text(l.wReview), + ), + if (p.error == WalletError.limitExceeded) + TextButton( + onPressed: () => context.push('/limits'), + child: Text(l.wLimits), + ), + if (p.error == WalletError.insufficientFunds) + TextButton( + onPressed: () => + context.push('/top-up', extra: p.source), + child: Text(l.wTopUp), + ), + ], + ], + ], + ), + ), + ], + ), + ), + ), + ); + }, + ); +} diff --git a/frontend/app/pubspec.lock b/frontend/app/pubspec.lock index 81e429f..7b13355 100644 --- a/frontend/app/pubspec.lock +++ b/frontend/app/pubspec.lock @@ -494,6 +494,13 @@ packages: relative: true source: path version: "0.1.0" + sxchnge_wallet: + dependency: "direct main" + description: + path: "../packages/sxchnge_wallet" + relative: true + source: path + version: "0.1.0" term_glyph: dependency: transitive description: diff --git a/frontend/app/pubspec.yaml b/frontend/app/pubspec.yaml index c89dde1..f659714 100644 --- a/frontend/app/pubspec.yaml +++ b/frontend/app/pubspec.yaml @@ -6,6 +6,8 @@ environment: sdk: '>=3.13.0 <4.0.0' flutter: '>=3.47.0' dependencies: + sxchnge_wallet: + path: ../packages/sxchnge_wallet sxchnge_kyc: path: ../packages/sxchnge_kyc sxchnge_analytics: diff --git a/frontend/app/test/kyc_test.dart b/frontend/app/test/kyc_test.dart index f088859..6e2ae9f 100644 --- a/frontend/app/test/kyc_test.dart +++ b/frontend/app/test/kyc_test.dart @@ -178,7 +178,7 @@ void main() { await t.pumpAndSettle(); await tap(t, find.text('Discard & leave')); expect(find.byKey(const ValueKey('kyc-start')), findsNothing); - expect(find.text('Explore verification & limits'), findsOneWidget); + expect(find.text('Explore your accounts'), findsOneWidget); }); testWidgets('overview controls have accessible labels and touch targets', ( t, diff --git a/frontend/app/test/limits_test.dart b/frontend/app/test/limits_test.dart index 553bebd..cfac36a 100644 --- a/frontend/app/test/limits_test.dart +++ b/frontend/app/test/limits_test.dart @@ -63,12 +63,14 @@ void main() { ); await tester.pumpAndSettle(); expect( - find.text(locale == 'en' ? 'Used' : 'Использовано'), + find.text( + locale == 'en' ? 'Used this period' : 'Использовано за период', + ), findsOneWidget, ); expect( find.text(locale == 'en' ? 'Reserved' : 'Зарезервировано'), - findsOneWidget, + findsNothing, ); expect( tester diff --git a/frontend/app/test/localization_test.dart b/frontend/app/test/localization_test.dart index 2aa8f3e..971586d 100644 --- a/frontend/app/test/localization_test.dart +++ b/frontend/app/test/localization_test.dart @@ -83,15 +83,7 @@ void main() { await tester.tap(find.byKey(const ValueKey('submit'))); await tester.pumpAndSettle(); expect(find.text('Enter a valid email address.'), findsOneWidget); - await tester.ensureVisible(find.byKey(const ValueKey('locale-selector'))); - await tester.tap(find.byKey(const ValueKey('locale-selector'))); - await tester.pumpAndSettle(); - await tester.tap( - find.ancestor( - of: find.text('Русский'), - matching: find.byType(CheckedPopupMenuItem), - ), - ); + await locale.setLocale(const Locale('ru')); await tester.pumpAndSettle(); expect(find.text('Введите корректный адрес почты.'), findsOneWidget); expect(find.text('Алексей'), findsOneWidget); diff --git a/frontend/app/test/verification_test.dart b/frontend/app/test/verification_test.dart index 28744bb..5bb15a8 100644 --- a/frontend/app/test/verification_test.dart +++ b/frontend/app/test/verification_test.dart @@ -2,9 +2,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + import 'package:sxchnge_auth/sxchnge_auth.dart'; import 'package:sxchnge_app/main.dart'; +import 'package:sxchnge_app/providers/locale.dart'; class RecoveringService implements VerificationService { int sends = 0; @@ -120,16 +123,10 @@ void main() { addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); await openVerification(tester, route); expect(tester.takeException(), isNull); - await tester.tap(find.byKey(const ValueKey('locale-selector'))); + final element = tester.element(find.byType(Scaffold).first); + await element.read().setLocale(const Locale('ru')); await tester.pumpAndSettle(); - await tester.tap( - find.ancestor( - of: find.text('Русский'), - matching: find.byType(CheckedPopupMenuItem), - ), - ); - await tester.pumpAndSettle(); - expect(find.text('RU'), findsOneWidget); + expect(find.byKey(const ValueKey('locale-selector')), findsNothing); expect(tester.takeException(), isNull); }); } diff --git a/frontend/app/test/workspace_test.dart b/frontend/app/test/workspace_test.dart new file mode 100644 index 0000000..bbb9be2 --- /dev/null +++ b/frontend/app/test/workspace_test.dart @@ -0,0 +1,220 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:go_router/go_router.dart'; + +import 'package:sxchnge_kyc/sxchnge_kyc.dart'; + +import 'package:sxchnge_wallet/sxchnge_wallet.dart'; + +import 'package:sxchnge_app/main.dart'; +import 'package:sxchnge_app/providers/locale.dart'; + +Future tap(WidgetTester t, Finder f) async { + await t.ensureVisible(f); + await t.tap(f); + await t.pumpAndSettle(); +} + +Future open( + WidgetTester t, { + String path = '/accounts', + String locale = 'en', + Duration settlement = Duration.zero, +}) async { + t.platformDispatcher.defaultRouteNameTestValue = path; + addTearDown(t.platformDispatcher.clearDefaultRouteNameTestValue); + final identity = DummyIdentityService(delay: Duration.zero); + final service = DummyWalletService( + identity, + delay: Duration.zero, + settlement: settlement, + initialLocale: locale, + ); + await t.pumpWidget( + SxchngeApp( + identityService: identity, + walletService: service, + localeProvider: LocaleProvider(initialLocale: Locale(locale)), + ), + ); + await t.pumpAndSettle(); + addTearDown(() => t.pumpWidget(const SizedBox.shrink())); + return service; +} + +void main() { + testWidgets('available-only accounts expose one set of actions', (t) async { + await open(t); + expect(find.text('Reserved'), findsNothing); + expect(find.text('Held'), findsNothing); + expect(find.byKey(const ValueKey('exchange-eur')), findsOneWidget); + expect(find.byKey(const ValueKey('exchange-usd')), findsNothing); + await tap(t, find.byKey(const ValueKey('account-usd'))); + expect(find.byKey(const ValueKey('topUp-usd')), findsOneWidget); + expect(find.byKey(const ValueKey('exchange-eur')), findsNothing); + }); + testWidgets('top-up exchange payout end-to-end and history', (t) async { + final s = await open(t); + await tap(t, find.byKey(const ValueKey('topUp-eur'))); + await t.enterText(find.byKey(const ValueKey('transfer-amount')), '10'); + await tap(t, find.byKey(const ValueKey('transfer-review'))); + expect(find.text('You pay'), findsOneWidget); + await tap(t, find.byKey(const ValueKey('transfer-confirm'))); + expect(find.byKey(const ValueKey('operation-completed')), findsOneWidget); + expect( + find.text( + 'Your account has been credited. The money is available to use.', + ), + findsOneWidget, + ); + await tap(t, find.text('Exchange').last); + await t.enterText(find.byKey(const ValueKey('transfer-amount')), '20'); + await tap(t, find.byKey(const ValueKey('transfer-review'))); + expect(find.text('Leaves your account'), findsOneWidget); + expect(find.text('Fee'), findsOneWidget); + await tap(t, find.byKey(const ValueKey('transfer-confirm'))); + expect(find.byKey(const ValueKey('operation-completed')), findsOneWidget); + await tap(t, find.text('Pay out').last); + await t.enterText(find.byKey(const ValueKey('transfer-amount')), '5'); + await t.enterText( + find.byKey(const ValueKey('transfer-recipient')), + 'Sample recipient', + ); + await tap(t, find.byKey(const ValueKey('transfer-review'))); + expect(find.text('Recipient receives'), findsOneWidget); + await tap(t, find.byKey(const ValueKey('transfer-confirm'))); + expect(find.byKey(const ValueKey('operation-completed')), findsOneWidget); + final data = (await t.runAsync(() => s.fetch()))!; + expect(data.operations.length, 3); + expect(data.accounts.first.available, 6490); + expect(data.accounts[1].available, 1600); + }); + testWidgets( + 'processing payout explains reduced available balance then failed refund', + (t) async { + final s = await open( + t, + path: '/payout', + settlement: const Duration(milliseconds: 100), + ); + s.failNextOperation = true; + await t.enterText(find.byKey(const ValueKey('transfer-amount')), '10'); + await t.enterText( + find.byKey(const ValueKey('transfer-recipient')), + 'Sample recipient', + ); + await tap(t, find.byKey(const ValueKey('transfer-review'))); + await tap(t, find.byKey(const ValueKey('transfer-confirm'))); + // Settlement is service-owned; refresh after the simulated processing period. + await t.runAsync( + () => Future.delayed(const Duration(milliseconds: 150)), + ); + await t.pump(const Duration(seconds: 3)); + await t.pumpAndSettle(); + expect(find.byKey(const ValueKey('operation-failed')), findsOneWidget); + expect( + find.text( + 'The transaction did not complete. The amount and fee are available in your account again.', + ), + findsOneWidget, + ); + expect( + ((await t.runAsync(() => s.fetch()))!).accounts.first.available, + 7500, + ); + }, + ); + testWidgets( + 'settings saves account language and hides page language selectors', + (t) async { + final s = await open(t, path: '/settings'); + expect(find.byKey(const ValueKey('locale-selector')), findsNothing); + await tap(t, find.byKey(const ValueKey('account-language-en'))); + await tap(t, find.text('Русский').last); + expect(((await t.runAsync(() => s.profile()))!).locale, 'ru'); + expect(find.text('Язык аккаунта'), findsWidgets); + }, + ); + testWidgets( + 'language save failure retains session choice and provides retry', + (t) async { + final s = await open(t, path: '/settings'); + s.nextError = WalletError.unavailable; + await tap(t, find.byKey(const ValueKey('account-language-en'))); + await tap(t, find.text('Русский').last); + expect( + find.text( + 'Изменено для текущей сессии, но не сохранено в аккаунте. Повторите попытку ниже.', + ), + findsOneWidget, + ); + expect(((await t.runAsync(() => s.profile()))!).locale, 'en'); + await tap(t, find.text('Обновить').last); + expect(((await t.runAsync(() => s.profile()))!).locale, 'ru'); + }, + ); + testWidgets( + 'insufficient funds preserves entered amount and can be corrected', + (t) async { + await open(t, path: '/exchange'); + await t.enterText(find.byKey(const ValueKey('transfer-amount')), '900'); + await tap(t, find.byKey(const ValueKey('transfer-review'))); + expect(find.byKey(const ValueKey('transfer-error')), findsOneWidget); + expect(find.text('900'), findsOneWidget); + await t.enterText(find.byKey(const ValueKey('transfer-amount')), '5'); + await tap(t, find.byKey(const ValueKey('transfer-review'))); + expect(find.byKey(const ValueKey('transfer-confirm')), findsOneWidget); + }, + ); + testWidgets('uncertain submission checks outcome instead of sending twice', ( + t, + ) async { + final s = await open(t, path: '/exchange'); + await t.enterText(find.byKey(const ValueKey('transfer-amount')), '5'); + await tap(t, find.byKey(const ValueKey('transfer-review'))); + s.loseNextResponse = true; + await tap(t, find.byKey(const ValueKey('transfer-confirm'))); + expect(find.text('Check outcome'), findsOneWidget); + await tap(t, find.text('Check outcome')); + expect(((await t.runAsync(() => s.fetch()))!).operations.length, 1); + expect(find.byKey(const ValueKey('operation-completed')), findsOneWidget); + }); + for (final path in [ + '/accounts', + '/exchange', + '/payout', + '/activity', + '/limits', + '/settings', + ]) { + testWidgets('$path fits Russian at 320px with larger text', (t) async { + t.view.physicalSize = const Size(320, 1100); + t.view.devicePixelRatio = 1; + t.platformDispatcher.textScaleFactorTestValue = 1.6; + addTearDown(t.view.resetPhysicalSize); + addTearDown(t.view.resetDevicePixelRatio); + addTearDown(t.platformDispatcher.clearTextScaleFactorTestValue); + await open(t, path: path, locale: 'ru'); + expect(t.takeException(), null); + if (path == '/exchange') { + await t.enterText(find.byKey(const ValueKey('transfer-amount')), '5'); + await tap(t, find.byKey(const ValueKey('transfer-review'))); + expect(t.takeException(), null); + } + }); + } + testWidgets('leaving a draft requires a clear discard choice', (t) async { + await open(t, path: '/exchange'); + await t.enterText(find.byKey(const ValueKey('transfer-amount')), '5'); + final router = GoRouter.of( + t.element(find.byKey(const ValueKey('transfer-amount'))), + ); + router.go('/accounts'); + await t.pumpAndSettle(); + expect(find.text('Leave this transaction?'), findsOneWidget); + await tap(t, find.text('Keep editing')); + expect(find.text('5'), findsOneWidget); + }); +} diff --git a/frontend/packages/sxchnge_analytics/lib/sxchnge_analytics.dart b/frontend/packages/sxchnge_analytics/lib/sxchnge_analytics.dart index 6ff550c..da8aa0f 100644 --- a/frontend/packages/sxchnge_analytics/lib/sxchnge_analytics.dart +++ b/frontend/packages/sxchnge_analytics/lib/sxchnge_analytics.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'dart:collection'; import 'dart:math'; -enum JourneyKind { login, signup, verification, kyc } +enum JourneyKind { login, signup, verification, kyc, topUp, exchange, payout } enum JourneyStep { login, @@ -14,6 +14,10 @@ enum JourneyStep { kycSelfie, kycSubmission, kycStatus, + transferDraft, + transferQuote, + transferSubmission, + settlement, } enum JourneySignal { @@ -27,6 +31,9 @@ enum JourneySignal { } enum JourneyFailure { + insufficientFunds, + limitExceeded, + declined, document, invalidCredentials, emailInUse, @@ -79,7 +86,13 @@ class AnalyticsEvent { final bool simulated; final DateTime occurredAt; String get name { - final area = kind == JourneyKind.kyc ? 'kyc' : 'auth'; + final area = switch (kind) { + JourneyKind.kyc => 'kyc', + JourneyKind.topUp => 'top_up', + JourneyKind.exchange => 'exchange', + JourneyKind.payout => 'payout', + _ => 'auth', + }; var stage = step == null ? 'journey' : _snake(step!.name); if (kind == JourneyKind.kyc) stage = stage.replaceFirst('kyc_', ''); return '${area}_${stage}_${signal.name}'; @@ -189,7 +202,7 @@ class JourneyRecorder { operation._finished = true; journey.pending--; journey.simulated |= simulated; - final complete = failure == null && !cancelled && terminal; + final complete = !cancelled && terminal; _emit( journey, cancelled @@ -257,8 +270,8 @@ class JourneyRecorder { ); } - void dispose() { - abandon(JourneyExit.disposed); + void dispose({bool abandonActive = true}) { + if (abandonActive) abandon(JourneyExit.disposed); _disposed = true; _idle?.cancel(); } diff --git a/frontend/packages/sxchnge_kyc/lib/limits_transport.dart b/frontend/packages/sxchnge_kyc/lib/limits_transport.dart new file mode 100644 index 0000000..e0980f2 --- /dev/null +++ b/frontend/packages/sxchnge_kyc/lib/limits_transport.dart @@ -0,0 +1,2 @@ +/// Shared limit transport contract for service adapters. +export 'src/transport/contracts.dart' show LimitsDTO, LimitDTO, LimitsMapper; diff --git a/frontend/packages/sxchnge_wallet/.gitignore b/frontend/packages/sxchnge_wallet/.gitignore new file mode 100644 index 0000000..61608ef --- /dev/null +++ b/frontend/packages/sxchnge_wallet/.gitignore @@ -0,0 +1,3 @@ +.dart_tool/ +build/ +pubspec.lock diff --git a/frontend/packages/sxchnge_wallet/README.md b/frontend/packages/sxchnge_wallet/README.md new file mode 100644 index 0000000..17a7fcc --- /dev/null +++ b/frontend/packages/sxchnge_wallet/README.md @@ -0,0 +1,48 @@ +# SXCHNGE wallet + +Shared Flutter domain, injectable services and providers for accounts, quotes, +operations, limits synchronization and account language. Web and mobile clients +consume the same models; widgets do not call transport APIs. + +`WalletService` returns mapped domain models. `DummyWalletService` is an in-memory +backend stand-in; `WalletProvider` owns snapshots and pending-operation refresh, +`TransferProvider` owns draft/review/submission/reconciliation, and +`WorkspaceLimitsProvider` refetches the separate `LimitsService` projection when +wallet state changes. The adapter reuses the KYC package's limit DTO and mapper. + +## Demo behavior + +- Opening balances: EUR 75, USD 0, GBP 0. No invented historical transactions. +- Fixed demo ratios: EUR/USD 1.1; GBP uses weight 1300 vs EUR 1100 and USD 1000. +- Top-up fee zero; exchange fee 0.5% rounded down, minimum one minor unit; + payout fee 100 minor units. Quote expiry is 60 seconds. +- Top-ups credit only after settlement. Outgoing total debit includes the fee; + it leaves available balance on acceptance. Failed outgoing operations restore + that exact total. Exchange destination credits only on completion. +- Outgoing usage, including fees, is converted upward to EUR minor units. + Basic caps: EUR 100/day and 500/month; verified: 2500/day and 10000/month. + Top-ups do not consume outgoing capacity. Windows follow the stub's local clock. + Policy, usage and remaining are returned by the service, not set in widgets. +- Submission rechecks balance, limits and expiry, is idempotent by request key, + and permits a quote to execute only once. Unknown outcomes reconcile before + any resubmission. `nextError`, `loseNextResponse`, `failNextOperation`, injectable + clock/delays support deterministic failure testing. +- Everything resets on application reload. Recipient values are demo text only; + no bank details, payment provider, storage or real money movement is connected. + +## Integration boundary + +Replace `WalletService` and `LimitsService` together with authenticated backend +adapters. The backend must provide authorization, authoritative quotes/balances, +atomic limits enforcement, durable idempotency and operation reconciliation. +Add supported payment methods and validated recipients from that contract, rather +than interpreting the demo input as production bank validation. Replace profile +storage with the signed-in account endpoint. No authorization is supplied by +public demo routes or an available balance. + +Analytics use the shared typed recorder: draft, quote, submission and authoritative +settlement. Retried submissions keep a journey; each accepted operation closes +once on success/failure. Closing the app while pending does not count as draft +abandonment. No amounts, account IDs or recipient data enter events. All demo +signals are simulated. Cross-session outcomes require backend event delivery; +client-only analytics are not production accounting or conversion evidence. diff --git a/frontend/packages/sxchnge_wallet/lib/src/models/wallet.dart b/frontend/packages/sxchnge_wallet/lib/src/models/wallet.dart new file mode 100644 index 0000000..7548ea8 --- /dev/null +++ b/frontend/packages/sxchnge_wallet/lib/src/models/wallet.dart @@ -0,0 +1,113 @@ +enum TransferKind { topUp, exchange, payout } + +enum OperationStatus { pending, completed, failed } + +enum WalletError { + unavailable, + invalidAmount, + insufficientFunds, + limitExceeded, + expiredQuote, + invalidRecipient, + unsupported, + notFound, + uncertain, +} + +class WalletException implements Exception { + const WalletException(this.code); + final WalletError code; +} + +class CurrencyAccount { + const CurrencyAccount({ + required this.id, + required this.currency, + required this.precision, + required this.available, + }); + final String id, currency; + final int precision, available; + + /// Locale-aware decimal input, integer money. Grouping/exponents are rejected. + int? parse(String input) { + final value = input.trim().replaceAll(',', '.'); + if (!RegExp(r'^\d+(\.\d+)?$').hasMatch(value)) return null; + final parts = value.split('.'); + if (parts.length > 1 && parts[1].length > precision) return null; + final major = int.tryParse(parts[0]); + if (major == null || major > 100000000) return null; + final fraction = parts.length > 1 + ? int.parse(parts[1].padRight(precision, '0')) + : 0; + return major * scale + fraction; + } + + int get scale { + var v = 1; + for (var i = 0; i < precision; i++) { + v *= 10; + } + return v; + } +} + +class TransferRequest { + const TransferRequest({ + required this.kind, + required this.source, + required this.amount, + this.destination, + this.recipient = '', + }); + final TransferKind kind; + final String source; + final int amount; + final String? destination; + final String recipient; +} + +class Quote { + const Quote({ + required this.id, + required this.request, + required this.fee, + required this.debit, + required this.credit, + required this.expiresAt, + required this.rateNumerator, + required this.rateDenominator, + }); + final String id; + final TransferRequest request; + final int fee, debit, credit, rateNumerator, rateDenominator; + final DateTime expiresAt; +} + +class WalletOperation { + const WalletOperation({ + required this.id, + required this.quote, + required this.status, + required this.createdAt, + }); + final String id; + final Quote quote; + final OperationStatus status; + final DateTime createdAt; +} + +class WalletSnapshot { + WalletSnapshot({ + required List accounts, + required List operations, + }) : accounts = List.unmodifiable(accounts), + operations = List.unmodifiable(operations); + final List accounts; + final List operations; +} + +class AccountProfile { + const AccountProfile(this.locale); + final String locale; +} diff --git a/frontend/packages/sxchnge_wallet/lib/src/providers/limits.dart b/frontend/packages/sxchnge_wallet/lib/src/providers/limits.dart new file mode 100644 index 0000000..4ec2ab7 --- /dev/null +++ b/frontend/packages/sxchnge_wallet/lib/src/providers/limits.dart @@ -0,0 +1,25 @@ +import 'package:sxchnge_kyc/sxchnge_kyc.dart'; + +import '../models/wallet.dart'; +import 'wallet.dart'; + +/// Refetch the backend projection when an operation changes the wallet. +class WorkspaceLimitsProvider extends LimitsProvider { + WorkspaceLimitsProvider(super.service, this.wallet) { + _snapshot = wallet.data; + wallet.addListener(_changed); + } + final WalletProvider wallet; + WalletSnapshot? _snapshot; + void _changed() { + if (identical(_snapshot, wallet.data)) return; + _snapshot = wallet.data; + refresh(); + } + + @override + void dispose() { + wallet.removeListener(_changed); + super.dispose(); + } +} diff --git a/frontend/packages/sxchnge_wallet/lib/src/providers/transfer.dart b/frontend/packages/sxchnge_wallet/lib/src/providers/transfer.dart new file mode 100644 index 0000000..8cd61bb --- /dev/null +++ b/frontend/packages/sxchnge_wallet/lib/src/providers/transfer.dart @@ -0,0 +1,213 @@ +import 'dart:math'; + +import 'package:flutter/foundation.dart'; + +import 'package:sxchnge_analytics/sxchnge_analytics.dart'; + +import '../models/wallet.dart'; +import 'wallet.dart'; + +class TransferProvider extends ChangeNotifier { + TransferProvider(this.wallet, this.kind, {String? source}) + : source = source ?? wallet.data?.accounts.firstOrNull?.id ?? 'eur', + recorder = JourneyRecorder(wallet.analytics) { + destination = + wallet.data?.accounts + .where((a) => a.id != this.source) + .firstOrNull + ?.id ?? + 'usd'; + final op = recorder.begin( + journey, + JourneyStep.transferDraft, + simulated: wallet.service.isSimulated, + ); + recorder.finish(op, simulated: wallet.service.isSimulated); + } + final WalletProvider wallet; + final TransferKind kind; + final JourneyRecorder recorder; + String source; + String? destination; + String amount = '', recipient = ''; + Quote? quote; + WalletOperation? result; + WalletError? error; + bool noSubmission = false; + bool busy = false, uncertain = false, _disposed = false, _handedOff = false; + int _revision = 0; + String? _key; + JourneyKind get journey => switch (kind) { + TransferKind.topUp => JourneyKind.topUp, + TransferKind.exchange => JourneyKind.exchange, + TransferKind.payout => JourneyKind.payout, + }; + bool get hasDraft => amount.isNotEmpty || recipient.isNotEmpty; + bool get expired => + quote != null && !DateTime.now().isBefore(quote!.expiresAt); + void change({ + String? source, + String? destination, + String? amount, + String? recipient, + }) { + if (busy || uncertain) return; + this.source = source ?? this.source; + this.destination = destination ?? this.destination; + this.amount = amount ?? this.amount; + this.recipient = recipient ?? this.recipient; + quote = null; + noSubmission = false; + error = null; + _key = null; + _revision++; + notifyListeners(); + } + + void edit() { + if (!busy && !uncertain) { + quote = null; + error = null; + _key = null; + notifyListeners(); + } + } + + Future review() async { + if (busy || uncertain) return; + final money = wallet.account(source)?.parse(amount); + if (money == null || money <= 0) { + error = WalletError.invalidAmount; + notifyListeners(); + return; + } + busy = true; + noSubmission = false; + error = null; + notifyListeners(); + final revision = ++_revision; + final op = recorder.begin( + journey, + JourneyStep.transferQuote, + simulated: wallet.service.isSimulated, + ); + try { + final q = await wallet.service.quote( + TransferRequest( + kind: kind, + source: source, + amount: money, + destination: kind == TransferKind.exchange ? destination : null, + recipient: recipient.trim(), + ), + ); + if (_disposed || revision != _revision) return; + quote = q; + _key = null; + recorder.finish(op, simulated: wallet.service.isSimulated); + } catch (e) { + if (_disposed) return; + error = _code(e); + recorder.finish(op, failure: _failure(error!)); + } + if (_disposed) return; + busy = false; + notifyListeners(); + } + + Future submit() async { + if (busy || quote == null || uncertain) return; + if (expired) { + error = WalletError.expiredQuote; + notifyListeners(); + return; + } + busy = true; + noSubmission = false; + error = null; + notifyListeners(); + _key ??= List.generate( + 16, + (_) => Random.secure().nextInt(256).toRadixString(16).padLeft(2, '0'), + ).join(); + final op = recorder.begin( + journey, + JourneyStep.transferSubmission, + simulated: wallet.service.isSimulated, + ); + try { + final r = await wallet.service.submit(quote!.id, _key!); + if (_disposed) return; + _accepted(r, op); + } catch (e) { + if (_disposed) return; + error = _code(e); + // All transport/unexpected failures are ambiguous after execution started. + uncertain = [ + WalletError.unavailable, + WalletError.uncertain, + ].contains(error); + recorder.finish(op, failure: _failure(error!)); + } + if (_disposed) return; + busy = false; + notifyListeners(); + } + + void _accepted(WalletOperation value, JourneyOperation op) { + result = value; + uncertain = false; + error = null; + recipient = ''; + recorder.finish(op, simulated: wallet.service.isSimulated); + wallet.track(value, recorder, journey); + _handedOff = true; + } + + Future reconcile() async { + if (busy || !uncertain || _key == null) return; + busy = true; + noSubmission = false; + error = null; + notifyListeners(); + final op = recorder.begin( + journey, + JourneyStep.transferSubmission, + simulated: wallet.service.isSimulated, + ); + try { + final r = await wallet.service.findSubmission(_key!); + if (_disposed) return; + if (r != null) { + _accepted(r, op); + } else { + uncertain = false; + noSubmission = true; + recorder.finish(op, simulated: wallet.service.isSimulated); + } + } catch (e) { + if (_disposed) return; + error = _code(e); + recorder.finish(op, failure: JourneyFailure.unavailable); + } + if (_disposed) return; + busy = false; + notifyListeners(); + } + + WalletError _code(Object e) => + e is WalletException ? e.code : WalletError.unavailable; + JourneyFailure _failure(WalletError e) => switch (e) { + WalletError.insufficientFunds => JourneyFailure.insufficientFunds, + WalletError.limitExceeded => JourneyFailure.limitExceeded, + WalletError.expiredQuote => JourneyFailure.expired, + _ => JourneyFailure.unavailable, + }; + @override + void dispose() { + _disposed = true; + recipient = ''; + if (!_handedOff) recorder.dispose(); + super.dispose(); + } +} diff --git a/frontend/packages/sxchnge_wallet/lib/src/providers/wallet.dart b/frontend/packages/sxchnge_wallet/lib/src/providers/wallet.dart new file mode 100644 index 0000000..6e263b1 --- /dev/null +++ b/frontend/packages/sxchnge_wallet/lib/src/providers/wallet.dart @@ -0,0 +1,150 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import 'package:sxchnge_analytics/sxchnge_analytics.dart'; + +import '../models/wallet.dart'; +import '../services/contracts.dart'; + +class WalletProvider extends ChangeNotifier { + WalletProvider(this.service, this.analytics); + final WalletService service; + final AnalyticsService analytics; + WalletSnapshot? data; + bool loading = false, + failed = false, + profileLoading = false, + profileFailed = false, + saving = false, + saveFailed = false; + String? preferredLocale; + bool _disposed = false; + int _revision = 0; + Timer? _poll; + final _settlements = {}; + CurrencyAccount? account(String? id) { + for (final a in data?.accounts ?? []) { + if (a.id == id) return a; + } + return null; + } + + WalletOperation? operation(String id) { + for (final o in data?.operations ?? []) { + if (o.id == id) return o; + } + return null; + } + + bool get hasPending => + data?.operations.any((o) => o.status == OperationStatus.pending) ?? false; + Future refresh() async { + final rev = ++_revision; + loading = true; + failed = false; + notifyListeners(); + try { + final result = await service.fetch(); + if (_disposed || rev != _revision) return; + data = result; + for (final o in result.operations) { + if (o.status == OperationStatus.pending) continue; + final pair = _settlements.remove(o.id); + if (pair != null) { + pair.$1.finish( + pair.$2, + simulated: service.isSimulated, + failure: o.status == OperationStatus.failed + ? JourneyFailure.declined + : null, + terminal: true, + ); + pair.$1.dispose(); + } + } + } catch (_) { + if (_disposed || rev != _revision) return; + failed = true; + } + if (_disposed || rev != _revision) return; + loading = false; + _poll?.cancel(); + if (hasPending) { + _poll = Timer(const Duration(seconds: 3), () { + if (!_disposed) refresh(); + }); + } + notifyListeners(); + } + + void track( + WalletOperation operation, + JourneyRecorder recorder, + JourneyKind kind, + ) { + final step = recorder.begin( + kind, + JourneyStep.settlement, + simulated: service.isSimulated, + ); + _settlements[operation.id] = (recorder, step); + final snapshot = data; + if (snapshot != null) { + data = WalletSnapshot( + accounts: snapshot.accounts, + operations: [ + operation, + ...snapshot.operations.where((o) => o.id != operation.id), + ], + ); + } + unawaited(refresh()); + } + + Future loadProfile() async { + if (profileLoading) return; + profileLoading = true; + profileFailed = false; + notifyListeners(); + try { + final result = await service.profile(); + if (_disposed) return; + preferredLocale = result.locale; + } catch (_) { + if (_disposed) return; + profileFailed = true; + } + profileLoading = false; + notifyListeners(); + } + + Future saveLocale(String locale) async { + if (saving) return false; + saving = true; + saveFailed = false; + notifyListeners(); + try { + final result = await service.saveLocale(locale); + if (_disposed) return false; + preferredLocale = result.locale; + } catch (_) { + if (_disposed) return false; + saveFailed = true; + } + saving = false; + notifyListeners(); + return !saveFailed; + } + + @override + void dispose() { + _disposed = true; + _poll?.cancel(); + for (final p in _settlements.values) { + p.$1.dispose(abandonActive: false); + } + _settlements.clear(); + super.dispose(); + } +} diff --git a/frontend/packages/sxchnge_wallet/lib/src/services/contracts.dart b/frontend/packages/sxchnge_wallet/lib/src/services/contracts.dart new file mode 100644 index 0000000..874c6d0 --- /dev/null +++ b/frontend/packages/sxchnge_wallet/lib/src/services/contracts.dart @@ -0,0 +1,13 @@ +import '../models/wallet.dart'; + +abstract interface class WalletService { + bool get isSimulated; + Future fetch(); + Future quote(TransferRequest request); + + /// Same key must reconcile uncertain submissions without duplicate execution. + Future submit(String quoteId, String idempotencyKey); + Future findSubmission(String idempotencyKey); + Future profile(); + Future saveLocale(String locale); +} diff --git a/frontend/packages/sxchnge_wallet/lib/src/services/dummy.dart b/frontend/packages/sxchnge_wallet/lib/src/services/dummy.dart new file mode 100644 index 0000000..ec8cde2 --- /dev/null +++ b/frontend/packages/sxchnge_wallet/lib/src/services/dummy.dart @@ -0,0 +1,259 @@ +import 'dart:math'; + +import 'package:sxchnge_kyc/limits_transport.dart'; +import 'package:sxchnge_kyc/sxchnge_kyc.dart'; + +import '../models/wallet.dart'; +import '../transport/wallet.dart'; +import 'contracts.dart'; + +/// Coherent, account-scoped backend stand-in. No network or persistence. +class DummyWalletService implements WalletService { + DummyWalletService( + this.identity, { + this.delay = const Duration(milliseconds: 350), + this.settlement = const Duration(seconds: 2), + DateTime Function()? now, + String initialLocale = 'en', + }) : now = now ?? DateTime.now, + _locale = initialLocale; + final IdentityService identity; + final Duration delay, settlement; + final DateTime Function() now; + final _accounts = [ + AccountDTO('eur', 'EUR', 2, 7500), + AccountDTO('usd', 'USD', 2, 0), + AccountDTO('gbp', 'GBP', 2, 0), + ]; + final Map _quotes = {}; + final _usedQuotes = {}; + final Map _submissions = {}; + String _locale; + int _serial = 0; + WalletError? nextError; + bool failNextOperation = false; + bool loseNextResponse = false; + @override + bool get isSimulated => true; + Future _wait() async { + await Future.delayed(delay); + final error = nextError; + nextError = null; + if (error != null) throw WalletException(error); + } + + AccountDTO _account(String id) => _accounts.firstWhere( + (a) => a.id == id, + orElse: () => throw const WalletException(WalletError.notFound), + ); + // Fixed demo ratios to EUR; never marketed as live rates. + int _weight(String id) => switch (id) { + 'eur' => 1100, + 'usd' => 1000, + 'gbp' => 1300, + _ => throw const WalletException(WalletError.unsupported), + }; + int _eur(int minor, String id) => (minor * _weight(id) + 1099) ~/ 1100; + void _settle() { + for (final o in _submissions.values) { + if (o.status != 'pending' || now().isBefore(o.ready)) continue; + o.status = o.fail ? 'failed' : 'completed'; + final q = o.quote; + if (o.fail) { + if (q.request.kind != 'topUp') + _account(q.request.source).available += q.debit; + } else if (q.request.kind == 'topUp') { + _account(q.request.source).available += q.credit; + } else if (q.request.kind == 'exchange') { + _account(q.request.destination!).available += q.credit; + } + } + } + + int _usage({required bool monthly, required bool pending}) { + final current = now(); + return _submissions.values + .where( + (o) => + o.quote.request.kind != 'topUp' && + o.status == (pending ? 'pending' : 'completed') && + o.created.year == current.year && + o.created.month == current.month && + (monthly || o.created.day == current.day), + ) + .fold( + 0, + (total, o) => total + _eur(o.quote.debit, o.quote.request.source), + ); + } + + Future _verified() async => + (await identity.fetch()).status == IdentityStatus.approved; + void _check(QuoteDTO q, bool verified) { + if (q.request.kind == 'topUp') return; + if (_account(q.request.source).available < q.debit) + throw const WalletException(WalletError.insufficientFunds); + final charge = _eur(q.debit, q.request.source); + if (charge + + _usage(monthly: false, pending: false) + + _usage(monthly: false, pending: true) > + (verified ? 250000 : 10000) || + charge + + _usage(monthly: true, pending: false) + + _usage(monthly: true, pending: true) > + (verified ? 1000000 : 50000)) + throw const WalletException(WalletError.limitExceeded); + } + + @override + Future fetch() async { + await _wait(); + _settle(); + return WalletSnapshot( + accounts: _accounts.map(WalletMapper.account).toList(), + operations: _submissions.values + .toList() + .reversed + .map(WalletMapper.operation) + .toList(), + ); + } + + @override + Future quote(TransferRequest r) async { + await _wait(); + _settle(); + _account(r.source); + if (r.amount <= 0 || r.amount > 100000000) + throw const WalletException(WalletError.invalidAmount); + if (r.kind == TransferKind.exchange && + (r.source == r.destination || r.destination == null)) + throw const WalletException(WalletError.unsupported); + if (r.kind == TransferKind.payout && + (r.recipient.trim().length < 6 || r.recipient.length > 140)) + throw const WalletException(WalletError.invalidRecipient); + if (r.kind == TransferKind.exchange) _account(r.destination!); + final fee = switch (r.kind) { + TransferKind.topUp => 0, + TransferKind.exchange => max(1, r.amount ~/ 200), + TransferKind.payout => 100, + }; + final n = r.kind == TransferKind.exchange ? _weight(r.source) : 1; + final d = r.kind == TransferKind.exchange ? _weight(r.destination!) : 1; + final q = QuoteDTO( + 'quote-${++_serial}', + WalletMapper.request(r), + fee, + r.kind == TransferKind.topUp ? r.amount : r.amount + fee, + r.amount * n ~/ d, + now().add(const Duration(seconds: 60)), + n, + d, + ); + if (q.credit <= 0) throw const WalletException(WalletError.invalidAmount); + final verified = await _verified(); + _settle(); + _check(q, verified); + _quotes[q.id] = q; + return WalletMapper.quote(q); + } + + @override + Future submit(String quoteId, String idempotencyKey) async { + await _wait(); + if (_submissions.containsKey(idempotencyKey)) + return WalletMapper.operation(_submissions[idempotencyKey]!); + final q = _quotes[quoteId]; + if (q == null) throw const WalletException(WalletError.expiredQuote); + final verified = await _verified(); + _settle(); + if (_submissions.containsKey(idempotencyKey)) + return WalletMapper.operation(_submissions[idempotencyKey]!); + if (!now().isBefore(q.expires)) + throw const WalletException(WalletError.expiredQuote); + if (_usedQuotes.contains(q.id)) + throw const WalletException(WalletError.expiredQuote); + _check(q, verified); + _usedQuotes.add(q.id); + if (q.request.kind != 'topUp') + _account(q.request.source).available -= q.debit; + final o = OperationDTO( + 'op-${++_serial}', + q, + now(), + now().add(settlement), + fail: failNextOperation, + ); + failNextOperation = false; + _submissions[idempotencyKey] = o; + if (loseNextResponse) { + loseNextResponse = false; + throw const WalletException(WalletError.uncertain); + } + return WalletMapper.operation(o); + } + + @override + Future findSubmission(String key) async { + await _wait(); + _settle(); + final o = _submissions[key]; + return o == null ? null : WalletMapper.operation(o); + } + + @override + Future profile() async { + await _wait(); + return WalletMapper.profile(_locale); + } + + @override + Future saveLocale(String locale) async { + await _wait(); + if (!['en', 'ru'].contains(locale)) + throw const WalletException(WalletError.unsupported); + _locale = locale; + return WalletMapper.profile(_locale); + } + + // Kept separate from wallet fetch because LimitsService has a different result. + Future fetchLimits() async { + await _wait(); + final verified = await _verified(); + _settle(); + final rows = []; + for (final monthly in [false, true]) { + final basic = monthly ? 50000 : 10000; + final upgraded = monthly ? 1000000 : 250000; + final current = verified ? upgraded : basic; + final used = _usage(monthly: monthly, pending: false); + final pending = _usage(monthly: monthly, pending: true); + rows.add( + LimitDTO( + monthly ? 'month' : 'day', + 'EUR', + basic, + upgraded, + current, + max(0, current - used - pending), + used, + pending, + ), + ); + } + return LimitsMapper.fromDTO( + LimitsDTO( + tier: verified ? 'verified' : 'basic', + rows: rows, + simulated: true, + ), + ); + } +} + +class WalletLimitsService implements LimitsService { + const WalletLimitsService(this.wallet); + final DummyWalletService wallet; + @override + Future fetch() => wallet.fetchLimits(); +} diff --git a/frontend/packages/sxchnge_wallet/lib/src/transport/wallet.dart b/frontend/packages/sxchnge_wallet/lib/src/transport/wallet.dart new file mode 100644 index 0000000..2614608 --- /dev/null +++ b/frontend/packages/sxchnge_wallet/lib/src/transport/wallet.dart @@ -0,0 +1,89 @@ +import '../models/wallet.dart'; + +class AccountDTO { + AccountDTO(this.id, this.currency, this.precision, this.available); + final String id, currency; + final int precision; + int available; +} + +class RequestDTO { + const RequestDTO( + this.kind, + this.source, + this.destination, + this.amount, + this.recipient, + ); + final String kind, source, recipient; + final String? destination; + final int amount; +} + +class QuoteDTO { + const QuoteDTO( + this.id, + this.request, + this.fee, + this.debit, + this.credit, + this.expires, + this.numerator, + this.denominator, + ); + final String id; + final RequestDTO request; + final int fee, debit, credit, numerator, denominator; + final DateTime expires; +} + +class OperationDTO { + OperationDTO( + this.id, + this.quote, + this.created, + this.ready, { + this.status = 'pending', + this.fail = false, + }); + final String id; + final QuoteDTO quote; + final DateTime created, ready; + String status; + final bool fail; +} + +abstract final class WalletMapper { + static CurrencyAccount account(AccountDTO d) => CurrencyAccount( + id: d.id, + currency: d.currency, + precision: d.precision, + available: d.available, + ); + static RequestDTO request(TransferRequest r) => + RequestDTO(r.kind.name, r.source, r.destination, r.amount, r.recipient); + static TransferRequest fromRequest(RequestDTO d) => TransferRequest( + kind: TransferKind.values.byName(d.kind), + source: d.source, + destination: d.destination, + amount: d.amount, + recipient: d.recipient, + ); + static Quote quote(QuoteDTO d) => Quote( + id: d.id, + request: fromRequest(d.request), + fee: d.fee, + debit: d.debit, + credit: d.credit, + expiresAt: d.expires, + rateNumerator: d.numerator, + rateDenominator: d.denominator, + ); + static WalletOperation operation(OperationDTO d) => WalletOperation( + id: d.id, + quote: quote(d.quote), + status: OperationStatus.values.byName(d.status), + createdAt: d.created, + ); + static AccountProfile profile(String locale) => AccountProfile(locale); +} diff --git a/frontend/packages/sxchnge_wallet/lib/sxchnge_wallet.dart b/frontend/packages/sxchnge_wallet/lib/sxchnge_wallet.dart new file mode 100644 index 0000000..bbc4d41 --- /dev/null +++ b/frontend/packages/sxchnge_wallet/lib/sxchnge_wallet.dart @@ -0,0 +1,6 @@ +export 'src/models/wallet.dart'; +export 'src/providers/wallet.dart'; +export 'src/providers/transfer.dart'; +export 'src/services/contracts.dart'; +export 'src/services/dummy.dart'; +export 'src/providers/limits.dart'; diff --git a/frontend/packages/sxchnge_wallet/pubspec.yaml b/frontend/packages/sxchnge_wallet/pubspec.yaml new file mode 100644 index 0000000..990ab45 --- /dev/null +++ b/frontend/packages/sxchnge_wallet/pubspec.yaml @@ -0,0 +1,17 @@ +name: sxchnge_wallet +description: Shared currency accounts, transaction flows and account preferences. +version: 0.1.0 +publish_to: none +environment: + sdk: '>=3.13.0 <4.0.0' + flutter: '>=3.47.0' +dependencies: + flutter: + sdk: flutter + sxchnge_analytics: + path: ../sxchnge_analytics + sxchnge_kyc: + path: ../sxchnge_kyc +dev_dependencies: + flutter_test: + sdk: flutter diff --git a/frontend/packages/sxchnge_wallet/test/wallet_test.dart b/frontend/packages/sxchnge_wallet/test/wallet_test.dart new file mode 100644 index 0000000..efc2435 --- /dev/null +++ b/frontend/packages/sxchnge_wallet/test/wallet_test.dart @@ -0,0 +1,289 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:sxchnge_analytics/sxchnge_analytics.dart'; + +import 'package:sxchnge_kyc/sxchnge_kyc.dart'; + +import 'package:sxchnge_wallet/sxchnge_wallet.dart'; + +void main() { + late DateTime now; + late DummyWalletService s; + late WalletProvider wallet; + late DummyAnalyticsService analytics; + setUp(() { + now = DateTime(2026, 9, 12, 10); + s = DummyWalletService( + DummyIdentityService(delay: Duration.zero), + delay: Duration.zero, + now: () => now, + ); + analytics = DummyAnalyticsService(); + wallet = WalletProvider(s, analytics); + }); + tearDown(() => wallet.dispose()); + Future transfer( + TransferKind kind, + int amount, { + String source = 'eur', + String? to, + String recipient = 'Sample recipient', + String key = 'one', + }) async { + final q = await s.quote( + TransferRequest( + kind: kind, + source: source, + destination: to, + amount: amount, + recipient: recipient, + ), + ); + return s.submit(q.id, key); + } + + test( + 'funding credits only on completion; history and available balance agree', + () async { + final o = await transfer(TransferKind.topUp, 2500); + expect(o.status, OperationStatus.pending); + expect((await s.fetch()).accounts.first.available, 7500); + now = now.add(const Duration(seconds: 3)); + final state = await s.fetch(); + expect(state.accounts.first.available, 10000); + expect(state.operations.single.status, OperationStatus.completed); + }, + ); + test( + 'exchange debits total once, then credits target and updates usage', + () async { + final q = await s.quote( + const TransferRequest( + kind: TransferKind.exchange, + source: 'eur', + destination: 'usd', + amount: 2000, + ), + ); + expect(q.debit, 2010); + expect(q.credit, 2200); + await Future.wait([s.submit(q.id, 'same'), s.submit(q.id, 'same')]); + var data = await s.fetch(); + expect(data.accounts.first.available, 5490); + expect(data.accounts[1].available, 0); + expect(data.operations.length, 1); + var limits = await s.fetchLimits(); + expect(limits.rows.first.reservedMinor, 2010); + expect(limits.rows.first.usedMinor, 0); + now = now.add(const Duration(seconds: 3)); + data = await s.fetch(); + expect(data.accounts[1].available, 2200); + limits = await s.fetchLimits(); + expect(limits.rows.first.usedMinor, 2010); + expect(limits.rows.first.reservedMinor, 0); + expect(limits.rows.first.remainingMinor, 7990); + await expectLater( + s.submit(q.id, 'different'), + throwsA(isA()), + ); + }, + ); + test('failed payout restores amount and fee, releases limit usage', () async { + s.failNextOperation = true; + await transfer(TransferKind.payout, 2000); + expect((await s.fetch()).accounts.first.available, 5400); + now = now.add(const Duration(seconds: 3)); + final data = await s.fetch(); + expect(data.accounts.first.available, 7500); + expect(data.operations.single.status, OperationStatus.failed); + expect((await s.fetchLimits()).rows.first.remainingMinor, 10000); + }); + test('expired quotes and stale balance cannot execute', () async { + final q = await s.quote( + const TransferRequest( + kind: TransferKind.payout, + source: 'eur', + amount: 7000, + recipient: 'Sample recipient', + ), + ); + now = now.add(const Duration(seconds: 61)); + await expectLater( + s.submit(q.id, 'expired'), + throwsA( + isA().having( + (e) => e.code, + 'code', + WalletError.expiredQuote, + ), + ), + ); + expect((await s.fetch()).accounts.first.available, 7500); + final a = await s.quote( + const TransferRequest( + kind: TransferKind.payout, + source: 'eur', + amount: 6000, + recipient: 'Sample recipient', + ), + ); + final b = await s.quote( + const TransferRequest( + kind: TransferKind.payout, + source: 'eur', + amount: 6000, + recipient: 'Sample recipient', + ), + ); + await s.submit(a.id, 'first'); + await expectLater( + s.submit(b.id, 'second'), + throwsA( + isA().having( + (e) => e.code, + 'code', + WalletError.insufficientFunds, + ), + ), + ); + }); + test('limits block a funded account independently of balance', () async { + await transfer(TransferKind.topUp, 20000); + now = now.add(const Duration(seconds: 3)); + await s.fetch(); + await expectLater( + s.quote( + const TransferRequest( + kind: TransferKind.payout, + source: 'eur', + amount: 11000, + recipient: 'Sample recipient', + ), + ), + throwsA( + isA().having( + (e) => e.code, + 'code', + WalletError.limitExceeded, + ), + ), + ); + }); + test('decimal parser accepts locale decimal, rejects grouping and excess precision', () { + const a = CurrencyAccount( + id: 'eur', + currency: 'EUR', + precision: 2, + available: 0, + ); + expect(a.parse('12,34'), 1234); + expect(a.parse('12.3'), 1230); + expect(a.parse('1,000.00'), null); + expect(a.parse('1.234'), null); + expect(a.parse('-2'), null); + expect(a.parse('1e5'), null); + }); + test( + 'preferences are service-owned; failed save preserves stored locale', + () async { + expect((await s.profile()).locale, 'en'); + await wallet.saveLocale('ru'); + expect((await s.profile()).locale, 'ru'); + s.nextError = WalletError.unavailable; + expect(await wallet.saveLocale('en'), false); + expect((await s.profile()).locale, 'ru'); + expect(wallet.saveFailed, true); + }, + ); + test( + 'provider reconciles lost response and records settlement once', + () async { + // Use real-clock quote lifetime for the provider UI expiry check. + final service = DummyWalletService( + DummyIdentityService(delay: Duration.zero), + delay: Duration.zero, + settlement: Duration.zero, + ); + final p = WalletProvider(service, analytics); + addTearDown(p.dispose); + await p.refresh(); + final draft = TransferProvider(p, TransferKind.exchange); + addTearDown(draft.dispose); + draft.change(amount: '20'); + await draft.review(); + service.loseNextResponse = true; + await draft.submit(); + expect(draft.uncertain, true); + expect(draft.result, null); + await draft.reconcile(); + expect(draft.uncertain, false); + expect(draft.result, isNotNull); + await p.refresh(); + await p.refresh(); + await Future.delayed(Duration.zero); + expect(p.data!.operations.length, 1); + expect( + analytics.events + .where((e) => e.step == JourneyStep.settlement && e.terminal) + .length, + 1, + ); + expect( + analytics.events.every( + (e) => + !e.properties.containsKey('amount') && + !e.properties.containsKey('recipient'), + ), + true, + ); + }, + ); + test( + 'pending operations refresh balances and limits without a detail screen', + () async { + await wallet.refresh(); + final limits = WorkspaceLimitsProvider(WalletLimitsService(s), wallet); + addTearDown(limits.dispose); + await limits.refresh(); + s.failNextOperation = true; + await transfer(TransferKind.payout, 2000); + await wallet.refresh(); + expect(wallet.account('eur')!.available, 5400); + now = now.add(const Duration(seconds: 3)); + final done = Completer(); + void changed() { + if (wallet.operation(wallet.data!.operations.single.id)?.status == + OperationStatus.failed && + !done.isCompleted) + done.complete(); + } + + wallet.addListener(changed); + await done.future.timeout(const Duration(seconds: 5)); + wallet.removeListener(changed); + await Future.delayed(const Duration(milliseconds: 20)); + expect(wallet.account('eur')!.available, 7500); + expect(limits.data!.rows.first.remainingMinor, 10000); + }, + ); + test( + 'closing a pending operation does not report draft abandonment', + () async { + final r = JourneyRecorder(analytics); + final op = r.begin( + JourneyKind.exchange, + JourneyStep.settlement, + simulated: true, + ); + expect(op, isNotNull); + r.dispose(abandonActive: false); + await Future.delayed(Duration.zero); + expect( + analytics.events.any((e) => e.signal == JourneySignal.abandoned), + false, + ); + }, + ); +} -- 2.54.0 From 2ae8086d4000a43a03ba6323b2a993a055d6a433 Mon Sep 17 00:00:00 2001 From: Stephan D Date: Sat, 12 Sep 2026 12:02:37 +0200 Subject: [PATCH 4/5] Record workspace PR and final browser validation (#28) --- docs/implementation.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/implementation.md b/docs/implementation.md index 7e4b922..cb3be75 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -234,3 +234,7 @@ tests were needed. The PR is not merged. history and balances reset on reload. No receipt export, date pagination, notification preferences or security settings beyond an honest capability note. Backend adapters must supply these before production use; see the wallet README. +- Delivery: [PR #29](https://git.sendico.io/tech/sxchnge/pulls/29), pushed and + unmerged. Task and PR have frontend label, tech owner and SXCHNGE project. + Browser demo walkthrough also completed exchange and payout, confirming source + currency handoffs and exact fee/debit/receipt values on the result screen. -- 2.54.0 From 54f50ad6b796bb0bcaf05ea22b51b6f1e48d9aac Mon Sep 17 00:00:00 2001 From: Stephan D Date: Sat, 12 Sep 2026 12:06:30 +0200 Subject: [PATCH 5/5] Remove repeated demo notes from product screens (#28) --- docs/implementation.md | 18 ++++ frontend/app/lib/auth/form.dart | 5 - .../lib/generated/i18n/app_localizations.dart | 98 ++----------------- .../generated/i18n/app_localizations_en.dart | 68 ++----------- .../generated/i18n/app_localizations_ru.dart | 69 ++----------- frontend/app/lib/kyc/form.dart | 2 +- frontend/app/lib/kyc/limits.dart | 2 - frontend/app/lib/kyc/page.dart | 15 --- frontend/app/lib/l10n/en.arb | 33 ++----- frontend/app/lib/l10n/ru.arb | 33 ++----- frontend/app/lib/verification/page.dart | 19 +--- frontend/app/lib/workspace/shell.dart | 18 ++-- frontend/app/lib/workspace/transfer.dart | 2 - frontend/app/test/routing_test.dart | 2 +- frontend/app/test/ux_analytics_test.dart | 8 +- frontend/app/test/verification_test.dart | 2 +- 16 files changed, 87 insertions(+), 307 deletions(-) diff --git a/docs/implementation.md b/docs/implementation.md index cb3be75..c7a539c 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -238,3 +238,21 @@ tests were needed. The PR is not merged. unmerged. Task and PR have frontend label, tech owner and SXCHNGE project. Browser demo walkthrough also completed exchange and payout, confirming source currency handoffs and exact fee/debit/receipt values on the result screen. + +### Workspace copy cleanup — follow-up to #28 + +At the user's request, removed repeated demo/preview banners from the workspace, +transaction forms, auth, email confirmation, identity and limits. Confirmation +and result copy now uses ordinary product wording in EN/RU; the mobile wordmark +remains without the extra note. Removed unused localization entries and duplicate +email-state copy. Existing journey measurement, simulation flags, service behavior, +funds explanations and recovery controls are unchanged. Development constraints +belong in this record and package README, not repeated throughout the product UI. + +Delivery continues in [PR #29](https://git.sendico.io/tech/sxchnge/pulls/29) on +`workspace-28`. This copy change does not connect real authentication, email, +payment, support or backoffice services. +Validation: all 115 frontend tests, app analysis, app Dart formatting, web build +and diff checks pass. Updated existing copy expectations; telemetry tests still +verify simulated events never count as real business success. No new click events +were needed for this presentation-only cleanup. diff --git a/frontend/app/lib/auth/form.dart b/frontend/app/lib/auth/form.dart index 38c8a49..9445146 100644 --- a/frontend/app/lib/auth/form.dart +++ b/frontend/app/lib/auth/form.dart @@ -133,11 +133,6 @@ class _AuthFormState extends State { ), ), ), - const SizedBox(height: 16), - Text( - l.earlyPreview, - style: text.bodySmall!.copyWith(color: colors.onSurfaceVariant), - ), const SizedBox(height: 24), Text( _signUp ? l.signupHeading : l.loginHeading, diff --git a/frontend/app/lib/generated/i18n/app_localizations.dart b/frontend/app/lib/generated/i18n/app_localizations.dart index bd91178..d16fb13 100644 --- a/frontend/app/lib/generated/i18n/app_localizations.dart +++ b/frontend/app/lib/generated/i18n/app_localizations.dart @@ -221,13 +221,13 @@ abstract class AppLocalizations { /// No description provided for @loginPreview. /// /// In en, this message translates to: - /// **'Demo login completed. No real session was created. Your credentials have not been sent or saved.'** + /// **'You’re signed in.'** String get loginPreview; /// No description provided for @signupPreview. /// /// In en, this message translates to: - /// **'Demo sign-up completed. No real account was created. Your credentials have not been sent or saved.'** + /// **'Your account is ready.'** String get signupPreview; /// No description provided for @alreadyMember. @@ -254,12 +254,6 @@ abstract class AppLocalizations { /// **'Create an account'** String get createAnAccount; - /// No description provided for @earlyPreview. - /// - /// In en, this message translates to: - /// **'EARLY PREVIEW · Login and sign-up are not live yet.'** - String get earlyPreview; - /// No description provided for @brandEyebrow. /// /// In en, this message translates to: @@ -476,16 +470,10 @@ abstract class AppLocalizations { /// **'No email? Check your spam folder or request another below.'** String get verificationHint; - /// No description provided for @verificationDemo. - /// - /// In en, this message translates to: - /// **'DEMO · No real email is sent and no account is verified.'** - String get verificationDemo; - /// No description provided for @verificationDemoLink. /// /// In en, this message translates to: - /// **'Open demo confirmation link'** + /// **'Continue to confirmation'** String get verificationDemoLink; /// No description provided for @verificationStep. @@ -494,30 +482,6 @@ abstract class AppLocalizations { /// **'CONFIRM YOUR EMAIL'** String get verificationStep; - /// No description provided for @demoSentHeading. - /// - /// In en, this message translates to: - /// **'Demo email prepared.'** - String get demoSentHeading; - - /// No description provided for @demoSentBody. - /// - /// In en, this message translates to: - /// **'No email was sent. Use the demo confirmation below to preview the next step.'** - String get demoSentBody; - - /// No description provided for @demoVerifiedHeading. - /// - /// In en, this message translates to: - /// **'Demo verification\ncomplete.'** - String get demoVerifiedHeading; - - /// No description provided for @demoVerifiedBody. - /// - /// In en, this message translates to: - /// **'You’ve previewed email confirmation. No account was verified. Return to login to explore the demo.'** - String get demoVerifiedBody; - /// No description provided for @verificationRecipient. /// /// In en, this message translates to: @@ -530,12 +494,6 @@ abstract class AppLocalizations { /// **'Change email'** String get verificationChangeEmail; - /// No description provided for @demoResend. - /// - /// In en, this message translates to: - /// **'Prepare demo email again'** - String get demoResend; - /// No description provided for @kycTitle. /// /// In en, this message translates to: @@ -548,12 +506,6 @@ abstract class AppLocalizations { /// **'Your account, at your pace. Verify your identity when you need higher limits.'** String get kycIntro; - /// No description provided for @kycDemo. - /// - /// In en, this message translates to: - /// **'DEMO · Use sample images only. Photos stay in memory, are never uploaded, and disappear when you leave or reload. Review decisions are simulated.'** - String get kycDemo; - /// No description provided for @kycUnverified. /// /// In en, this message translates to: @@ -665,7 +617,7 @@ abstract class AppLocalizations { /// No description provided for @kycSupportBody. /// /// In en, this message translates to: - /// **'Support is not connected in this preview. In the live app, you will be able to request a review here without submitting documents again.'** + /// **'Support is currently unavailable. Please try again later.'** String get kycSupportBody; /// No description provided for @kycBasic. @@ -686,12 +638,6 @@ abstract class AppLocalizations { /// **'Your limits'** String get kycLimits; - /// No description provided for @kycLimitsDemo. - /// - /// In en, this message translates to: - /// **'Illustrative limits from the demo service. These are not a live offer.'** - String get kycLimitsDemo; - /// No description provided for @kycDay. /// /// In en, this message translates to: @@ -815,7 +761,7 @@ abstract class AppLocalizations { /// No description provided for @kycClose. /// /// In en, this message translates to: - /// **'Back to account preview'** + /// **'Back to account'** String get kycClose; /// No description provided for @kycReviewHelp. @@ -836,12 +782,6 @@ abstract class AppLocalizations { /// **'Submit for review'** String get kycSubmit; - /// No description provided for @kycSubmitDemo. - /// - /// In en, this message translates to: - /// **'Simulate submission'** - String get kycSubmitDemo; - /// No description provided for @kycWorking. /// /// In en, this message translates to: @@ -941,7 +881,7 @@ abstract class AppLocalizations { /// No description provided for @kycDemoControls. /// /// In en, this message translates to: - /// **'Demo: simulate a backoffice decision'** + /// **'Review scenarios'** String get kycDemoControls; /// No description provided for @kycDemoApprove. @@ -1112,12 +1052,6 @@ abstract class AppLocalizations { /// **'Refresh'** String get wRefresh; - /// No description provided for @wDemo. - /// - /// In en, this message translates to: - /// **'Demo workspace · No real money moves.'** - String get wDemo; - /// No description provided for @wSource. /// /// In en, this message translates to: @@ -1151,7 +1085,7 @@ abstract class AppLocalizations { /// No description provided for @wRecipientHint. /// /// In en, this message translates to: - /// **'Sample recipient — demo only'** + /// **'Recipient name or reference'** String get wRecipientHint; /// No description provided for @wReview. @@ -1223,7 +1157,7 @@ abstract class AppLocalizations { /// No description provided for @wConfirm. /// /// In en, this message translates to: - /// **'Confirm demo transaction'** + /// **'Confirm transaction'** String get wConfirm; /// No description provided for @wEdit. @@ -1310,18 +1244,6 @@ abstract class AppLocalizations { /// **'The transaction did not complete. The amount and fee are available in your account again.'** String get wFailedOutgoing; - /// No description provided for @wDemoMethod. - /// - /// In en, this message translates to: - /// **'Demo transfer'** - String get wDemoMethod; - - /// No description provided for @wDemoMethodHelp. - /// - /// In en, this message translates to: - /// **'Try the flow with sample data. No bank or payment provider is contacted.'** - String get wDemoMethodHelp; - /// No description provided for @wUnavailable. /// /// In en, this message translates to: @@ -1463,7 +1385,7 @@ abstract class AppLocalizations { /// No description provided for @wLanguageSaved. /// /// In en, this message translates to: - /// **'Saved to your demo account.'** + /// **'Saved to your account.'** String get wLanguageSaved; /// No description provided for @wLanguageFailed. @@ -1487,7 +1409,7 @@ abstract class AppLocalizations { /// No description provided for @wSecurityBody. /// /// In en, this message translates to: - /// **'Live sessions, password changes and two-step verification are not connected in this demo.'** + /// **'Password and security settings are not available yet.'** String get wSecurityBody; /// No description provided for @wIdentity. diff --git a/frontend/app/lib/generated/i18n/app_localizations_en.dart b/frontend/app/lib/generated/i18n/app_localizations_en.dart index 1a374e7..e0774a2 100644 --- a/frontend/app/lib/generated/i18n/app_localizations_en.dart +++ b/frontend/app/lib/generated/i18n/app_localizations_en.dart @@ -70,12 +70,10 @@ class AppLocalizationsEn extends AppLocalizations { String get letsGo => 'Let’s go'; @override - String get loginPreview => - 'Demo login completed. No real session was created. Your credentials have not been sent or saved.'; + String get loginPreview => 'You’re signed in.'; @override - String get signupPreview => - 'Demo sign-up completed. No real account was created. Your credentials have not been sent or saved.'; + String get signupPreview => 'Your account is ready.'; @override String get alreadyMember => 'Already part of sxchnge?'; @@ -89,10 +87,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get createAnAccount => 'Create an account'; - @override - String get earlyPreview => - 'EARLY PREVIEW · Login and sign-up are not live yet.'; - @override String get brandEyebrow => 'A LITTLE CHANGE. A BIG NEXT STEP.'; @@ -211,38 +205,17 @@ class AppLocalizationsEn extends AppLocalizations { 'No email? Check your spam folder or request another below.'; @override - String get verificationDemo => - 'DEMO · No real email is sent and no account is verified.'; - - @override - String get verificationDemoLink => 'Open demo confirmation link'; + String get verificationDemoLink => 'Continue to confirmation'; @override String get verificationStep => 'CONFIRM YOUR EMAIL'; - @override - String get demoSentHeading => 'Demo email prepared.'; - - @override - String get demoSentBody => - 'No email was sent. Use the demo confirmation below to preview the next step.'; - - @override - String get demoVerifiedHeading => 'Demo verification\ncomplete.'; - - @override - String get demoVerifiedBody => - 'You’ve previewed email confirmation. No account was verified. Return to login to explore the demo.'; - @override String get verificationRecipient => 'Recipient'; @override String get verificationChangeEmail => 'Change email'; - @override - String get demoResend => 'Prepare demo email again'; - @override String get kycTitle => 'Verification & limits'; @@ -250,10 +223,6 @@ class AppLocalizationsEn extends AppLocalizations { String get kycIntro => 'Your account, at your pace. Verify your identity when you need higher limits.'; - @override - String get kycDemo => - 'DEMO · Use sample images only. Photos stay in memory, are never uploaded, and disappear when you leave or reload. Review decisions are simulated.'; - @override String get kycUnverified => 'Not verified'; @@ -319,7 +288,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get kycSupportBody => - 'Support is not connected in this preview. In the live app, you will be able to request a review here without submitting documents again.'; + 'Support is currently unavailable. Please try again later.'; @override String get kycBasic => 'Basic'; @@ -330,10 +299,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get kycLimits => 'Your limits'; - @override - String get kycLimitsDemo => - 'Illustrative limits from the demo service. These are not a live offer.'; - @override String get kycDay => 'Daily volume'; @@ -400,7 +365,7 @@ class AppLocalizationsEn extends AppLocalizations { String get kycSkip => 'Keep basic limits'; @override - String get kycClose => 'Back to account preview'; + String get kycClose => 'Back to account'; @override String get kycReviewHelp => @@ -412,9 +377,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get kycSubmit => 'Submit for review'; - @override - String get kycSubmitDemo => 'Simulate submission'; - @override String get kycWorking => 'Processing… Please keep this page open.'; @@ -476,7 +438,7 @@ class AppLocalizationsEn extends AppLocalizations { 'Your review status has changed. Refresh it before continuing.'; @override - String get kycDemoControls => 'Demo: simulate a backoffice decision'; + String get kycDemoControls => 'Review scenarios'; @override String get kycDemoApprove => 'Approve'; @@ -566,9 +528,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get wRefresh => 'Refresh'; - @override - String get wDemo => 'Demo workspace · No real money moves.'; - @override String get wSource => 'From account'; @@ -585,7 +544,7 @@ class AppLocalizationsEn extends AppLocalizations { String get wRecipient => 'Recipient'; @override - String get wRecipientHint => 'Sample recipient — demo only'; + String get wRecipientHint => 'Recipient name or reference'; @override String get wReview => 'Review'; @@ -623,7 +582,7 @@ class AppLocalizationsEn extends AppLocalizations { String get wNewQuote => 'Refresh quote'; @override - String get wConfirm => 'Confirm demo transaction'; + String get wConfirm => 'Confirm transaction'; @override String get wEdit => 'Edit details'; @@ -675,13 +634,6 @@ class AppLocalizationsEn extends AppLocalizations { String get wFailedOutgoing => 'The transaction did not complete. The amount and fee are available in your account again.'; - @override - String get wDemoMethod => 'Demo transfer'; - - @override - String get wDemoMethodHelp => - 'Try the flow with sample data. No bank or payment provider is contacted.'; - @override String get wUnavailable => 'Could not refresh. The displayed information may be out of date. Try again.'; @@ -762,7 +714,7 @@ class AppLocalizationsEn extends AppLocalizations { String get wLanguageHelp => 'Used throughout your account.'; @override - String get wLanguageSaved => 'Saved to your demo account.'; + String get wLanguageSaved => 'Saved to your account.'; @override String get wLanguageFailed => @@ -777,7 +729,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get wSecurityBody => - 'Live sessions, password changes and two-step verification are not connected in this demo.'; + 'Password and security settings are not available yet.'; @override String get wIdentity => 'Identity verification'; diff --git a/frontend/app/lib/generated/i18n/app_localizations_ru.dart b/frontend/app/lib/generated/i18n/app_localizations_ru.dart index 2f62fe0..817f49c 100644 --- a/frontend/app/lib/generated/i18n/app_localizations_ru.dart +++ b/frontend/app/lib/generated/i18n/app_localizations_ru.dart @@ -70,12 +70,10 @@ class AppLocalizationsRu extends AppLocalizations { String get letsGo => 'Продолжить'; @override - String get loginPreview => - 'Демонстрационный вход выполнен. Реальная сессия не создана. Учётные данные не отправлены и не сохранены.'; + String get loginPreview => 'Вы вошли в аккаунт.'; @override - String get signupPreview => - 'Демонстрационная регистрация выполнена. Реальный аккаунт не создан. Учётные данные не отправлены и не сохранены.'; + String get signupPreview => 'Ваш аккаунт готов.'; @override String get alreadyMember => 'Уже есть аккаунт sxchnge?'; @@ -89,10 +87,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get createAnAccount => 'Зарегистрироваться'; - @override - String get earlyPreview => - 'ПРЕДВАРИТЕЛЬНАЯ ВЕРСИЯ · Вход и регистрация пока недоступны.'; - @override String get brandEyebrow => 'МАЛЕНЬКАЯ ПЕРЕМЕНА. БОЛЬШОЙ ШАГ.'; @@ -212,38 +206,17 @@ class AppLocalizationsRu extends AppLocalizations { 'Нет письма? Проверьте папку «Спам» или запросите новое ниже.'; @override - String get verificationDemo => - 'ДЕМО · Письмо не отправляется, реальный аккаунт не подтверждается.'; - - @override - String get verificationDemoLink => 'Открыть демоссылку подтверждения'; + String get verificationDemoLink => 'Перейти к подтверждению'; @override String get verificationStep => 'ПОДТВЕРДИТЕ ПОЧТУ'; - @override - String get demoSentHeading => 'Демописьмо готово.'; - - @override - String get demoSentBody => - 'Письмо не отправлено. Используйте демоссылку ниже, чтобы посмотреть следующий шаг.'; - - @override - String get demoVerifiedHeading => 'Демоподтверждение\nзавершено.'; - - @override - String get demoVerifiedBody => - 'Вы посмотрели подтверждение почты. Реальный аккаунт не подтверждён. Вернитесь ко входу, чтобы продолжить знакомство с демоверсией.'; - @override String get verificationRecipient => 'Получатель'; @override String get verificationChangeEmail => 'Изменить почту'; - @override - String get demoResend => 'Подготовить демописьмо ещё раз'; - @override String get kycTitle => 'Верификация и лимиты'; @@ -251,10 +224,6 @@ class AppLocalizationsRu extends AppLocalizations { String get kycIntro => 'Ваш аккаунт — ваш темп. Подтвердите личность, когда понадобятся более высокие лимиты.'; - @override - String get kycDemo => - 'ДЕМО · Используйте только тестовые изображения. Фото хранятся в памяти, не отправляются и удаляются при выходе или перезагрузке. Решения проверки имитируются.'; - @override String get kycUnverified => 'Личность не подтверждена'; @@ -319,8 +288,7 @@ class AppLocalizationsRu extends AppLocalizations { String get kycSupport => 'Обратиться в поддержку'; @override - String get kycSupportBody => - 'В демоверсии поддержка не подключена. В рабочем приложении здесь можно будет запросить пересмотр без повторной отправки документов.'; + String get kycSupportBody => 'Поддержка сейчас недоступна. Попробуйте позже.'; @override String get kycBasic => 'Базовый'; @@ -331,10 +299,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get kycLimits => 'Ваши лимиты'; - @override - String get kycLimitsDemo => - 'Пример лимитов из демосервиса. Это не действующее предложение.'; - @override String get kycDay => 'Объём за день'; @@ -401,7 +365,7 @@ class AppLocalizationsRu extends AppLocalizations { String get kycSkip => 'Оставить базовые лимиты'; @override - String get kycClose => 'К демоаккаунту'; + String get kycClose => 'К аккаунту'; @override String get kycReviewHelp => @@ -413,9 +377,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get kycSubmit => 'Отправить на проверку'; - @override - String get kycSubmitDemo => 'Имитировать отправку'; - @override String get kycWorking => 'Обработка… Не закрывайте страницу.'; @@ -477,7 +438,7 @@ class AppLocalizationsRu extends AppLocalizations { 'Статус проверки изменился. Обновите его, прежде чем продолжить.'; @override - String get kycDemoControls => 'Демо: имитация решения сотрудника'; + String get kycDemoControls => 'Сценарии проверки'; @override String get kycDemoApprove => 'Одобрить'; @@ -567,9 +528,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get wRefresh => 'Обновить'; - @override - String get wDemo => 'Демонстрация · Реальные деньги не переводятся.'; - @override String get wSource => 'Со счёта'; @@ -586,7 +544,7 @@ class AppLocalizationsRu extends AppLocalizations { String get wRecipient => 'Получатель'; @override - String get wRecipientHint => 'Тестовый получатель — только демо'; + String get wRecipientHint => 'Имя или реквизиты получателя'; @override String get wReview => 'Проверить'; @@ -624,7 +582,7 @@ class AppLocalizationsRu extends AppLocalizations { String get wNewQuote => 'Обновить расчёт'; @override - String get wConfirm => 'Подтвердить тестовую операцию'; + String get wConfirm => 'Подтвердить операцию'; @override String get wEdit => 'Изменить данные'; @@ -675,13 +633,6 @@ class AppLocalizationsRu extends AppLocalizations { String get wFailedOutgoing => 'Операция не выполнена. Сумма и комиссия снова доступны на вашем счёте.'; - @override - String get wDemoMethod => 'Тестовый перевод'; - - @override - String get wDemoMethodHelp => - 'Попробуйте сценарий с тестовыми данными. Банки и платёжные системы не подключены.'; - @override String get wUnavailable => 'Не удалось обновить данные. Информация может быть устаревшей. Повторите попытку.'; @@ -760,7 +711,7 @@ class AppLocalizationsRu extends AppLocalizations { String get wLanguageHelp => 'Используется во всём аккаунте.'; @override - String get wLanguageSaved => 'Сохранено в тестовом аккаунте.'; + String get wLanguageSaved => 'Сохранено в аккаунте.'; @override String get wLanguageFailed => @@ -775,7 +726,7 @@ class AppLocalizationsRu extends AppLocalizations { @override String get wSecurityBody => - 'В этой демонстрации не подключены рабочие сессии, смена пароля и двухэтапная проверка.'; + 'Настройки пароля и безопасности пока недоступны.'; @override String get wIdentity => 'Подтверждение личности'; diff --git a/frontend/app/lib/kyc/form.dart b/frontend/app/lib/kyc/form.dart index f22a25e..e482f2d 100644 --- a/frontend/app/lib/kyc/form.dart +++ b/frontend/app/lib/kyc/form.dart @@ -77,7 +77,7 @@ class IdentityForm extends StatelessWidget { FilledButton( key: const ValueKey('kyc-submit'), onPressed: p.canSubmit ? p.submit : null, - child: Text(p.isDemo ? l.kycSubmitDemo : l.kycSubmit), + child: Text(l.kycSubmit), ), if (p.step > 0) TextButton(onPressed: p.busy ? null : p.back, child: Text(l.kycBack)), diff --git a/frontend/app/lib/kyc/limits.dart b/frontend/app/lib/kyc/limits.dart index 8dd8122..54551c4 100644 --- a/frontend/app/lib/kyc/limits.dart +++ b/frontend/app/lib/kyc/limits.dart @@ -47,8 +47,6 @@ class LimitsPanel extends StatelessWidget { data.tier == AccountTier.basic ? l.kycBasic : l.kycVerifiedTier, style: t.textTheme.titleLarge, ), - if (data.simulated) - Text(l.kycLimitsDemo, style: t.textTheme.bodySmall), const SizedBox(height: 12), if (data.rows.isEmpty) Text(l.kycLimitsEmpty), for (final row in data.rows) ...[ diff --git a/frontend/app/lib/kyc/page.dart b/frontend/app/lib/kyc/page.dart index ad80c9b..ac8efed 100644 --- a/frontend/app/lib/kyc/page.dart +++ b/frontend/app/lib/kyc/page.dart @@ -149,21 +149,6 @@ class IdentityPageState extends State { ), const SizedBox(height: 12), Text(l.kycIntro, style: t.textTheme.bodyLarge), - const SizedBox(height: 20), - if (p.isDemo) - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: t.colorScheme.tertiaryContainer, - borderRadius: BorderRadius.circular(16), - ), - child: Text( - l.kycDemo, - style: TextStyle( - color: t.colorScheme.onTertiaryContainer, - ), - ), - ), const SizedBox(height: 24), if (p.busy) ...[ const LinearProgressIndicator(), diff --git a/frontend/app/lib/l10n/en.arb b/frontend/app/lib/l10n/en.arb index 53efba6..0fd2e77 100644 --- a/frontend/app/lib/l10n/en.arb +++ b/frontend/app/lib/l10n/en.arb @@ -20,13 +20,12 @@ "hideConfirmation": "Hide confirmation", "createAccount": "Create account", "letsGo": "Let’s go", - "loginPreview": "Demo login completed. No real session was created. Your credentials have not been sent or saved.", - "signupPreview": "Demo sign-up completed. No real account was created. Your credentials have not been sent or saved.", + "loginPreview": "You’re signed in.", + "signupPreview": "Your account is ready.", "alreadyMember": "Already part of sxchnge?", "newHere": "New around here?", "loginInstead": "Log in instead", "createAnAccount": "Create an account", - "earlyPreview": "EARLY PREVIEW · Login and sign-up are not live yet.", "brandEyebrow": "A LITTLE CHANGE. A BIG NEXT STEP.", "brandHeading": "Good moves.\nStart here.", "brandIntro": "A fresh way forward.\nMake it yours with sxchnge.", @@ -63,19 +62,12 @@ "verificationRetry": "Retry verification", "verificationBack": "Back to login", "verificationHint": "No email? Check your spam folder or request another below.", - "verificationDemo": "DEMO · No real email is sent and no account is verified.", - "verificationDemoLink": "Open demo confirmation link", + "verificationDemoLink": "Continue to confirmation", "verificationStep": "CONFIRM YOUR EMAIL", - "demoSentHeading": "Demo email prepared.", - "demoSentBody": "No email was sent. Use the demo confirmation below to preview the next step.", - "demoVerifiedHeading": "Demo verification\ncomplete.", - "demoVerifiedBody": "You’ve previewed email confirmation. No account was verified. Return to login to explore the demo.", "verificationRecipient": "Recipient", "verificationChangeEmail": "Change email", - "demoResend": "Prepare demo email again", "kycTitle": "Verification & limits", "kycIntro": "Your account, at your pace. Verify your identity when you need higher limits.", - "kycDemo": "DEMO · Use sample images only. Photos stay in memory, are never uploaded, and disappear when you leave or reload. Review decisions are simulated.", "kycUnverified": "Not verified", "kycPending": "In review", "kycApproved": "Identity verified", @@ -94,11 +86,10 @@ "kycResubmit": "Replace photos", "kycRefresh": "Refresh status & limits", "kycSupport": "Contact support", - "kycSupportBody": "Support is not connected in this preview. In the live app, you will be able to request a review here without submitting documents again.", + "kycSupportBody": "Support is currently unavailable. Please try again later.", "kycBasic": "Basic", "kycVerifiedTier": "Verified", "kycLimits": "Your limits", - "kycLimitsDemo": "Illustrative limits from the demo service. These are not a live offer.", "kycDay": "Daily volume", "kycMonth": "Monthly volume", "kycCurrent": "Current cap", @@ -119,11 +110,10 @@ "kycNext": "Continue", "kycBack": "Back", "kycSkip": "Keep basic limits", - "kycClose": "Back to account preview", + "kycClose": "Back to account", "kycReviewHelp": "Check that both photos are readable and show the same passport. A staff member will review them; submission does not immediately raise your limits.", "kycConfirm": "These photos show me and my own valid passport.", "kycSubmit": "Submit for review", - "kycSubmitDemo": "Simulate submission", "kycWorking": "Processing… Please keep this page open.", "kycDiscardTitle": "Discard these photos?", "kycDiscardBody": "Your draft is only in memory. Leaving will remove the selected photos; you can start again later.", @@ -140,7 +130,7 @@ "kycErrorSession": "Your session has expired. Return to login, then check your verification status.", "kycErrorRate": "Too many requests. Wait a moment before trying again.", "kycErrorConflict": "Your review status has changed. Refresh it before continuing.", - "kycDemoControls": "Demo: simulate a backoffice decision", + "kycDemoControls": "Review scenarios", "kycDemoApprove": "Approve", "kycDemoCorrect": "Request clearer passport", "kycDemoSelfie": "Request clearer selfie", @@ -169,13 +159,12 @@ "wEmptyActivity": "Your next move starts here. Your transactions will appear below.", "wEmptyAccounts": "Your accounts are not ready yet. Try refreshing.", "wRefresh": "Refresh", - "wDemo": "Demo workspace · No real money moves.", "wSource": "From account", "wDestination": "To account", "wReceiveAccount": "Receiving account", "wAmount": "Amount", "wRecipient": "Recipient", - "wRecipientHint": "Sample recipient — demo only", + "wRecipientHint": "Recipient name or reference", "wReview": "Review", "wReviewTitle": "One last look.", "wFee": "Fee", @@ -187,7 +176,7 @@ "wQuoteNotice": "These terms are valid for 60 seconds. Confirm them before continuing.", "wExpired": "This quote expired. Refresh it and review the new terms.", "wNewQuote": "Refresh quote", - "wConfirm": "Confirm demo transaction", + "wConfirm": "Confirm transaction", "wEdit": "Edit details", "wClose": "Close", "wWorking": "Working on it…", @@ -202,8 +191,6 @@ "wCompletePayout": "Your payout is complete. The amount and fee have been deducted from your account.", "wFailedTopUp": "The top-up did not complete. No money was added to your account.", "wFailedOutgoing": "The transaction did not complete. The amount and fee are available in your account again.", - "wDemoMethod": "Demo transfer", - "wDemoMethodHelp": "Try the flow with sample data. No bank or payment provider is contacted.", "wUnavailable": "Could not refresh. The displayed information may be out of date. Try again.", "wInvalidAmount": "Enter an amount greater than zero, using the currency’s decimal precision.", "wInsufficient": "Not enough available money for this amount and fee. Top up or enter a smaller amount.", @@ -227,11 +214,11 @@ "wPreferences": "Make it yours.", "wLanguage": "Account language", "wLanguageHelp": "Used throughout your account.", - "wLanguageSaved": "Saved to your demo account.", + "wLanguageSaved": "Saved to your account.", "wLanguageFailed": "Changed for this session, but not saved to your account. Retry below.", "wProfileError": "Account preferences could not be loaded. Try again.", "wSecurity": "Security", - "wSecurityBody": "Live sessions, password changes and two-step verification are not connected in this demo.", + "wSecurityBody": "Password and security settings are not available yet.", "wIdentity": "Identity verification", "wIdentityHelp": "Optional verification for higher limits.", "wBackLogin": "Back to login", diff --git a/frontend/app/lib/l10n/ru.arb b/frontend/app/lib/l10n/ru.arb index d1a19e3..448a3a2 100644 --- a/frontend/app/lib/l10n/ru.arb +++ b/frontend/app/lib/l10n/ru.arb @@ -20,13 +20,12 @@ "hideConfirmation": "Скрыть повтор пароля", "createAccount": "Создать аккаунт", "letsGo": "Продолжить", - "loginPreview": "Демонстрационный вход выполнен. Реальная сессия не создана. Учётные данные не отправлены и не сохранены.", - "signupPreview": "Демонстрационная регистрация выполнена. Реальный аккаунт не создан. Учётные данные не отправлены и не сохранены.", + "loginPreview": "Вы вошли в аккаунт.", + "signupPreview": "Ваш аккаунт готов.", "alreadyMember": "Уже есть аккаунт sxchnge?", "newHere": "Впервые здесь?", "loginInstead": "Войти в аккаунт", "createAnAccount": "Зарегистрироваться", - "earlyPreview": "ПРЕДВАРИТЕЛЬНАЯ ВЕРСИЯ · Вход и регистрация пока недоступны.", "brandEyebrow": "МАЛЕНЬКАЯ ПЕРЕМЕНА. БОЛЬШОЙ ШАГ.", "brandHeading": "Всё начинается\nс перемен.", "brandIntro": "Новый путь вперёд.\nОткройте его вместе со sxchnge.", @@ -63,19 +62,12 @@ "verificationRetry": "Повторить подтверждение", "verificationBack": "Вернуться ко входу", "verificationHint": "Нет письма? Проверьте папку «Спам» или запросите новое ниже.", - "verificationDemo": "ДЕМО · Письмо не отправляется, реальный аккаунт не подтверждается.", - "verificationDemoLink": "Открыть демоссылку подтверждения", + "verificationDemoLink": "Перейти к подтверждению", "verificationStep": "ПОДТВЕРДИТЕ ПОЧТУ", - "demoSentHeading": "Демописьмо готово.", - "demoSentBody": "Письмо не отправлено. Используйте демоссылку ниже, чтобы посмотреть следующий шаг.", - "demoVerifiedHeading": "Демоподтверждение\nзавершено.", - "demoVerifiedBody": "Вы посмотрели подтверждение почты. Реальный аккаунт не подтверждён. Вернитесь ко входу, чтобы продолжить знакомство с демоверсией.", "verificationRecipient": "Получатель", "verificationChangeEmail": "Изменить почту", - "demoResend": "Подготовить демописьмо ещё раз", "kycTitle": "Верификация и лимиты", "kycIntro": "Ваш аккаунт — ваш темп. Подтвердите личность, когда понадобятся более высокие лимиты.", - "kycDemo": "ДЕМО · Используйте только тестовые изображения. Фото хранятся в памяти, не отправляются и удаляются при выходе или перезагрузке. Решения проверки имитируются.", "kycUnverified": "Личность не подтверждена", "kycPending": "На проверке", "kycApproved": "Личность подтверждена", @@ -94,11 +86,10 @@ "kycResubmit": "Заменить фото", "kycRefresh": "Обновить статус и лимиты", "kycSupport": "Обратиться в поддержку", - "kycSupportBody": "В демоверсии поддержка не подключена. В рабочем приложении здесь можно будет запросить пересмотр без повторной отправки документов.", + "kycSupportBody": "Поддержка сейчас недоступна. Попробуйте позже.", "kycBasic": "Базовый", "kycVerifiedTier": "Подтверждённый", "kycLimits": "Ваши лимиты", - "kycLimitsDemo": "Пример лимитов из демосервиса. Это не действующее предложение.", "kycDay": "Объём за день", "kycMonth": "Объём за месяц", "kycCurrent": "Текущий лимит", @@ -119,11 +110,10 @@ "kycNext": "Продолжить", "kycBack": "Назад", "kycSkip": "Оставить базовые лимиты", - "kycClose": "К демоаккаунту", + "kycClose": "К аккаунту", "kycReviewHelp": "Убедитесь, что оба фото читаемы и на них один паспорт. Их проверит сотрудник. Отправка не повышает лимиты сразу.", "kycConfirm": "На фото я и мой действующий паспорт.", "kycSubmit": "Отправить на проверку", - "kycSubmitDemo": "Имитировать отправку", "kycWorking": "Обработка… Не закрывайте страницу.", "kycDiscardTitle": "Удалить выбранные фото?", "kycDiscardBody": "Черновик хранится только в памяти. При выходе выбранные фото будут удалены. Позже можно начать заново.", @@ -140,7 +130,7 @@ "kycErrorSession": "Сессия истекла. Войдите снова и проверьте статус верификации.", "kycErrorRate": "Слишком много запросов. Немного подождите и повторите попытку.", "kycErrorConflict": "Статус проверки изменился. Обновите его, прежде чем продолжить.", - "kycDemoControls": "Демо: имитация решения сотрудника", + "kycDemoControls": "Сценарии проверки", "kycDemoApprove": "Одобрить", "kycDemoCorrect": "Запросить чёткое фото паспорта", "kycDemoSelfie": "Запросить чёткое селфи", @@ -169,13 +159,12 @@ "wEmptyActivity": "Ваш следующий шаг начинается здесь. Здесь появятся ваши операции.", "wEmptyAccounts": "Ваши счета ещё не готовы. Попробуйте обновить страницу.", "wRefresh": "Обновить", - "wDemo": "Демонстрация · Реальные деньги не переводятся.", "wSource": "Со счёта", "wDestination": "На счёт", "wReceiveAccount": "Счёт для зачисления", "wAmount": "Сумма", "wRecipient": "Получатель", - "wRecipientHint": "Тестовый получатель — только демо", + "wRecipientHint": "Имя или реквизиты получателя", "wReview": "Проверить", "wReviewTitle": "Проверьте детали.", "wFee": "Комиссия", @@ -187,7 +176,7 @@ "wQuoteNotice": "Эти условия действуют 60 секунд. Подтвердите их, прежде чем продолжить.", "wExpired": "Срок действия расчёта истёк. Обновите его и проверьте новые условия.", "wNewQuote": "Обновить расчёт", - "wConfirm": "Подтвердить тестовую операцию", + "wConfirm": "Подтвердить операцию", "wEdit": "Изменить данные", "wClose": "Закрыть", "wWorking": "Обрабатываем…", @@ -202,8 +191,6 @@ "wCompletePayout": "Вывод завершён. Сумма и комиссия списаны с вашего счёта.", "wFailedTopUp": "Пополнение не выполнено. На счёт ничего не зачислено.", "wFailedOutgoing": "Операция не выполнена. Сумма и комиссия снова доступны на вашем счёте.", - "wDemoMethod": "Тестовый перевод", - "wDemoMethodHelp": "Попробуйте сценарий с тестовыми данными. Банки и платёжные системы не подключены.", "wUnavailable": "Не удалось обновить данные. Информация может быть устаревшей. Повторите попытку.", "wInvalidAmount": "Введите сумму больше нуля с допустимым для валюты числом знаков после запятой.", "wInsufficient": "Для этой суммы и комиссии недостаточно доступных средств. Пополните счёт или уменьшите сумму.", @@ -227,11 +214,11 @@ "wPreferences": "Настройте под себя.", "wLanguage": "Язык аккаунта", "wLanguageHelp": "Используется во всём аккаунте.", - "wLanguageSaved": "Сохранено в тестовом аккаунте.", + "wLanguageSaved": "Сохранено в аккаунте.", "wLanguageFailed": "Изменено для текущей сессии, но не сохранено в аккаунте. Повторите попытку ниже.", "wProfileError": "Не удалось загрузить настройки аккаунта. Повторите попытку.", "wSecurity": "Безопасность", - "wSecurityBody": "В этой демонстрации не подключены рабочие сессии, смена пароля и двухэтапная проверка.", + "wSecurityBody": "Настройки пароля и безопасности пока недоступны.", "wIdentity": "Подтверждение личности", "wIdentityHelp": "Необязательная проверка для повышения лимитов.", "wBackLogin": "Вернуться ко входу", diff --git a/frontend/app/lib/verification/page.dart b/frontend/app/lib/verification/page.dart index 4663552..d5eb2e2 100644 --- a/frontend/app/lib/verification/page.dart +++ b/frontend/app/lib/verification/page.dart @@ -127,11 +127,10 @@ class _VerificationPageState extends State { !_lastWasSend && widget.token != null; final title = switch (status) { - VerificationStatus.sent => demo ? l.demoSentHeading : l.checkInbox, + VerificationStatus.sent => l.checkInbox, VerificationStatus.sending => l.verificationSending, VerificationStatus.checking => l.verificationChecking, - VerificationStatus.verified => - demo ? l.demoVerifiedHeading : l.verificationVerified, + VerificationStatus.verified => l.verificationVerified, VerificationStatus.expired => l.verificationExpired, VerificationStatus.invalid => l.verificationInvalid, VerificationStatus.error => l.verificationInternal, @@ -141,9 +140,8 @@ class _VerificationPageState extends State { : l.verificationRequest, }; final body = switch (status) { - VerificationStatus.sent => demo ? l.demoSentBody : l.verificationSent, - VerificationStatus.verified => - demo ? l.demoVerifiedBody : l.verificationVerifiedBody, + VerificationStatus.sent => l.verificationSent, + VerificationStatus.verified => l.verificationVerifiedBody, VerificationStatus.expired => l.verificationExpiredBody, VerificationStatus.invalid => l.verificationInvalidBody, VerificationStatus.error => l.verificationInternalBody, @@ -200,13 +198,6 @@ class _VerificationPageState extends State { color: colors.primary, ), ), - if (demo) ...[ - const SizedBox(height: 12), - Text( - l.verificationDemo, - style: theme.textTheme.bodyMedium, - ), - ], const SizedBox(height: 12), Semantics( liveRegion: true, @@ -258,7 +249,7 @@ class _VerificationPageState extends State { key: const ValueKey('verification-resend'), onPressed: _send, child: Text( - demo ? l.demoResend : l.verificationResend, + l.verificationResend, textAlign: TextAlign.center, ), ), diff --git a/frontend/app/lib/workspace/shell.dart b/frontend/app/lib/workspace/shell.dart index 9c9292b..940e64f 100644 --- a/frontend/app/lib/workspace/shell.dart +++ b/frontend/app/lib/workspace/shell.dart @@ -63,18 +63,14 @@ class _WorkspaceShellState extends State { final wide = MediaQuery.sizeOf(context).width >= 900; final content = Column( children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), - child: Column( - crossAxisAlignment: wide - ? CrossAxisAlignment.end - : CrossAxisAlignment.start, - children: [ - if (!wide) ...[const BrandWordmark(), const SizedBox(height: 8)], - Text(l.wDemo, style: t.textTheme.bodySmall), - ], + if (!wide) + const Padding( + padding: EdgeInsets.symmetric(horizontal: 24, vertical: 12), + child: Align( + alignment: Alignment.centerLeft, + child: BrandWordmark(), + ), ), - ), Expanded(child: widget.child), ], ); diff --git a/frontend/app/lib/workspace/transfer.dart b/frontend/app/lib/workspace/transfer.dart index b4fd263..2268b48 100644 --- a/frontend/app/lib/workspace/transfer.dart +++ b/frontend/app/lib/workspace/transfer.dart @@ -138,8 +138,6 @@ class TransferPageState extends State { ], ), const SizedBox(height: 20), - Text(l.wDemoMethodHelp, style: t.textTheme.bodySmall), - const SizedBox(height: 24), Focus( key: _content, focusNode: _focus, diff --git a/frontend/app/test/routing_test.dart b/frontend/app/test/routing_test.dart index 5d8ed39..6d8d645 100644 --- a/frontend/app/test/routing_test.dart +++ b/frontend/app/test/routing_test.dart @@ -129,7 +129,7 @@ void main() { same(router), ); expect(find.byKey(const ValueKey('verification-verified')), findsOneWidget); - expect(find.text('Демоподтверждение\nзавершено.'), findsOneWidget); + expect(find.text('Ваша почта\nподтверждена.'), findsOneWidget); }); testWidgets( diff --git a/frontend/app/test/ux_analytics_test.dart b/frontend/app/test/ux_analytics_test.dart index 0f42221..3e9daf9 100644 --- a/frontend/app/test/ux_analytics_test.dart +++ b/frontend/app/test/ux_analytics_test.dart @@ -111,7 +111,7 @@ void main() { ); testWidgets( - 'signup to demo verification is correlated and never claims a business success', + 'signup verification keeps simulated analytics with ordinary product copy', (tester) async { final sink = DummyAnalyticsService(); await open(tester, '/', sink); @@ -130,10 +130,10 @@ void main() { 'example-password', ); await tap(tester, find.byKey(const ValueKey('submit'))); - expect(find.text('Demo email prepared.'), findsOneWidget); - expect(find.textContaining('We’ve sent you'), findsNothing); + expect(find.text('Check your inbox.'), findsOneWidget); + expect(find.textContaining('We’ve sent you'), findsOneWidget); await tap(tester, find.byKey(const ValueKey('demo-confirmation'))); - expect(find.text('Demo verification\ncomplete.'), findsOneWidget); + expect(find.text('Your email\nis verified.'), findsOneWidget); expect(sink.events.map((e) => e.journeyId).toSet().length, 1); expect(sink.events.where((e) => e.terminal).length, 1); expect( diff --git a/frontend/app/test/verification_test.dart b/frontend/app/test/verification_test.dart index 5bb15a8..2e69817 100644 --- a/frontend/app/test/verification_test.dart +++ b/frontend/app/test/verification_test.dart @@ -57,7 +57,7 @@ void main() { find.byKey(ValueKey('verification-${scenario.value}')), findsOneWidget, ); - expect(find.textContaining('DEMO'), findsOneWidget); + expect(find.textContaining('DEMO'), findsNothing); expect(tester.takeException(), isNull); }, ); -- 2.54.0