Build exchange workspace and shared transaction services (#28)
This commit is contained in:
1 parent
52b7135eda
commit
c496da8a55
48 files changed
+4757
-120
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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 => 'Создать новую операцию';
|
||||
}
|
||||
@@ -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,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),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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": "Создать новую операцию"
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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: '/',
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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]),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user