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);
});
}