Build exchange workspace and shared transaction services (#28)

This commit is contained in:
Stephan D committed 2026-09-12 12:01:36 +02:00
1 parent 52b7135eda
commit c496da8a55
48 files changed
+4757 -120

No files matched your search

+2 -2
View File
@@ -311,8 +311,8 @@ class _AuthFormState extends State<AuthForm> {
],
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,
+9 -4
View File
@@ -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<AuthPage> {
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) ...[
@@ -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
@@ -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';
}
@@ -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 => 'Создать новую операцию';
}
+3 -5
View File
@@ -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),
+8 -11
View File
@@ -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<IdentityPage> {
Future<void> _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<IdentityPage> {
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<IdentityPage> {
onPressed: p.busy
? null
: () => _leave(account: true),
child: Text(l.kycClose),
child: Text(l.wLimits),
),
],
),
+88 -1
View File
@@ -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"
}
+88 -1
View File
@@ -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": "Создать новую операцию"
}
+15 -1
View File
@@ -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<SxchngeApp> {
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<SxchngeApp> {
_router.routeInformationProvider.removeListener(_routeChanged);
_router.dispose();
_journeys.dispose();
_wallet.dispose();
_locale.dispose();
super.dispose();
}
@@ -89,6 +102,7 @@ class _SxchngeAppState extends State<SxchngeApp> {
@override
Widget build(BuildContext context) => MultiProvider(
providers: [
ChangeNotifierProvider.value(value: _wallet),
Provider<JourneyRecorder>.value(value: _journeys),
Provider<AnalyticsService>.value(value: _analytics),
Provider<IdentityService>.value(value: _identity),
+25
View File
@@ -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<bool> _pendingSave = Future.value(true);
Locale get locale => _locale;
@@ -31,6 +34,22 @@ class LocaleProvider extends ChangeNotifier {
}
}
Future<void> loadAccount(WalletProvider wallet) async {
await wallet.loadProfile();
if (_disposed) return;
if (wallet.preferredLocale != null && !wallet.profileFailed) {
_locale = resolve(wallet.preferredLocale, const []);
notifyListeners();
}
}
Future<void> saveAccountLocale(WalletProvider wallet, Locale value) async {
if (_disposed || wallet.saving) return;
_locale = resolve(value.languageCode, const []);
notifyListeners();
await wallet.saveLocale(_locale.languageCode);
}
Future<bool> 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();
}
}
+84 -6
View File
@@ -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<TransferPageState>();
final exchangeKey = GlobalKey<TransferPageState>();
final payoutKey = GlobalKey<TransferPageState>();
final identityKey = GlobalKey<IdentityPageState>();
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: '/',
+2 -7
View File
@@ -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<VerificationPage> {
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<VerificationPage> {
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),
-54
View File
@@ -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<LocaleProvider>();
return PopupMenuButton<Locale>(
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),
],
),
),
);
}
}
+245
View File
@@ -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<WalletProvider>();
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),
),
],
),
),
),
),
);
}
}
+196
View File
@@ -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<ActivityPage> createState() => _ActivityPageState();
}
class _ActivityPageState extends State<ActivityPage> {
TransferKind? _kind;
OperationStatus? _status;
String _search = '';
String? _currency;
@override
Widget build(BuildContext context) {
final p = context.watch<WalletProvider>();
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 ?? <CurrencyAccount>[])
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<WalletProvider>();
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),
],
),
),
),
);
}
}
+52
View File
@@ -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,
};
+80
View File
@@ -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<LimitsPage> createState() => _LimitsPageState();
}
class _LimitsPageState extends State<LimitsPage> {
late final _provider = WorkspaceLimitsProvider(
context.read<LimitsService>(),
context.read<WalletProvider>(),
);
@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<LimitsProvider>.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),
),
],
),
),
),
),
);
}
}
+152
View File
@@ -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<OperationPage> createState() => _OperationPageState();
}
class _OperationPageState extends State<OperationPage> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) context.read<WalletProvider>().refresh();
});
}
@override
Widget build(BuildContext context) {
final p = context.watch<WalletProvider>();
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),
),
],
],
),
),
),
);
}
}
+132
View File
@@ -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<WalletProvider>();
final locale = context.watch<LocaleProvider>();
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<String>(
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),
),
],
),
),
),
);
}
}
+134
View File
@@ -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<WorkspaceShell> createState() => _WorkspaceShellState();
}
class _WorkspaceShellState extends State<WorkspaceShell> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final wallet = context.read<WalletProvider>();
if (wallet.data == null) wallet.refresh();
context.read<LocaleProvider>().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]),
],
),
);
}
}
+84
View File
@@ -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<WalletProvider>();
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,
),
],
);
}
+310
View File
@@ -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<TransferPage> createState() => TransferPageState();
}
class TransferPageState extends State<TransferPage> {
late final _provider = TransferProvider(
context.read<WalletProvider>(),
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<bool> 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<bool>(
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<WalletProvider>();
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<String>(
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<String>(
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),
),
],
],
],
),
),
],
),
),
),
);
},
);
}
+7
View File
@@ -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:
+2
View File
@@ -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:
+1 -1
View File
@@ -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,
+4 -2
View File
@@ -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
+1 -9
View File
@@ -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<Locale>),
),
);
await locale.setLocale(const Locale('ru'));
await tester.pumpAndSettle();
expect(find.text('Введите корректный адрес почты.'), findsOneWidget);
expect(find.text('Алексей'), findsOneWidget);
+6 -9
View File
@@ -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<LocaleProvider>().setLocale(const Locale('ru'));
await tester.pumpAndSettle();
await tester.tap(
find.ancestor(
of: find.text('Русский'),
matching: find.byType(CheckedPopupMenuItem<Locale>),
),
);
await tester.pumpAndSettle();
expect(find.text('RU'), findsOneWidget);
expect(find.byKey(const ValueKey('locale-selector')), findsNothing);
expect(tester.takeException(), isNull);
});
}
+220
View File
@@ -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<void> tap(WidgetTester t, Finder f) async {
await t.ensureVisible(f);
await t.tap(f);
await t.pumpAndSettle();
}
Future<DummyWalletService> 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<void>.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);
});
}
@@ -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();
}
@@ -0,0 +1,2 @@
/// Shared limit transport contract for service adapters.
export 'src/transport/contracts.dart' show LimitsDTO, LimitDTO, LimitsMapper;
@@ -0,0 +1,3 @@
.dart_tool/
build/
pubspec.lock
@@ -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.
@@ -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<CurrencyAccount> accounts,
required List<WalletOperation> operations,
}) : accounts = List.unmodifiable(accounts),
operations = List.unmodifiable(operations);
final List<CurrencyAccount> accounts;
final List<WalletOperation> operations;
}
class AccountProfile {
const AccountProfile(this.locale);
final String locale;
}
@@ -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();
}
}
@@ -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<void> 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<void> 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<void> 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();
}
}
@@ -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 = <String, (JourneyRecorder, JourneyOperation)>{};
CurrencyAccount? account(String? id) {
for (final a in data?.accounts ?? <CurrencyAccount>[]) {
if (a.id == id) return a;
}
return null;
}
WalletOperation? operation(String id) {
for (final o in data?.operations ?? <WalletOperation>[]) {
if (o.id == id) return o;
}
return null;
}
bool get hasPending =>
data?.operations.any((o) => o.status == OperationStatus.pending) ?? false;
Future<void> 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<void> 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<bool> 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();
}
}
@@ -0,0 +1,13 @@
import '../models/wallet.dart';
abstract interface class WalletService {
bool get isSimulated;
Future<WalletSnapshot> fetch();
Future<Quote> quote(TransferRequest request);
/// Same key must reconcile uncertain submissions without duplicate execution.
Future<WalletOperation> submit(String quoteId, String idempotencyKey);
Future<WalletOperation?> findSubmission(String idempotencyKey);
Future<AccountProfile> profile();
Future<AccountProfile> saveLocale(String locale);
}
@@ -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<String, QuoteDTO> _quotes = {};
final _usedQuotes = <String>{};
final Map<String, OperationDTO> _submissions = {};
String _locale;
int _serial = 0;
WalletError? nextError;
bool failNextOperation = false;
bool loseNextResponse = false;
@override
bool get isSimulated => true;
Future<void> _wait() async {
await Future<void>.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<bool> _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<WalletSnapshot> 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> 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<WalletOperation> 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<WalletOperation?> findSubmission(String key) async {
await _wait();
_settle();
final o = _submissions[key];
return o == null ? null : WalletMapper.operation(o);
}
@override
Future<AccountProfile> profile() async {
await _wait();
return WalletMapper.profile(_locale);
}
@override
Future<AccountProfile> 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<Limits> fetchLimits() async {
await _wait();
final verified = await _verified();
_settle();
final rows = <LimitDTO>[];
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<Limits> fetch() => wallet.fetchLimits();
}
@@ -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);
}
@@ -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';
@@ -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
@@ -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<WalletOperation> 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<WalletException>()),
);
},
);
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<WalletException>().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<WalletException>().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<WalletException>().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<void>.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>();
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<void>.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<void>.delayed(Duration.zero);
expect(
analytics.events.any((e) => e.signal == JourneySignal.abandoned),
false,
);
},
);
}