diff --git a/api/server/interface/api/sresponse/login_pending.go b/api/server/interface/api/sresponse/login_pending.go index d77cebc1..1d045e39 100644 --- a/api/server/interface/api/sresponse/login_pending.go +++ b/api/server/interface/api/sresponse/login_pending.go @@ -11,10 +11,10 @@ import ( type pendingLoginResponse struct { Account accountResponse `json:"account"` PendingToken TokenData `json:"pendingToken"` - Destination string `json:"destination"` + Target string `json:"target"` } -func LoginPending(logger mlogger.Logger, account *model.Account, pendingToken *TokenData, destination string) http.HandlerFunc { +func LoginPending(logger mlogger.Logger, account *model.Account, pendingToken *TokenData, target string) http.HandlerFunc { return response.Accepted( logger, &pendingLoginResponse{ @@ -23,7 +23,7 @@ func LoginPending(logger mlogger.Logger, account *model.Account, pendingToken *T authResponse: authResponse{}, }, PendingToken: *pendingToken, - Destination: destination, + Target: target, }, ) } diff --git a/frontend/pshared/lib/api/responses/login_pending.dart b/frontend/pshared/lib/api/responses/login_pending.dart index a4660220..99e08874 100644 --- a/frontend/pshared/lib/api/responses/login_pending.dart +++ b/frontend/pshared/lib/api/responses/login_pending.dart @@ -10,12 +10,12 @@ part 'login_pending.g.dart'; class PendingLoginResponse { final AccountResponse account; final TokenData pendingToken; - final String destination; + final String target; const PendingLoginResponse({ required this.account, required this.pendingToken, - required this.destination, + required this.target, }); factory PendingLoginResponse.fromJson(Map json) => _$PendingLoginResponseFromJson(json); diff --git a/frontend/pshared/lib/api/responses/payment/payments.dart b/frontend/pshared/lib/api/responses/payment/payments.dart index 27eba2dc..f7de3964 100644 --- a/frontend/pshared/lib/api/responses/payment/payments.dart +++ b/frontend/pshared/lib/api/responses/payment/payments.dart @@ -9,6 +9,7 @@ part 'payments.g.dart'; @JsonSerializable(explicitToJson: true) class PaymentsResponse extends CursorPageResponse { + @JsonKey(defaultValue: []) final List payments; const PaymentsResponse({ diff --git a/frontend/pshared/lib/models/auth/pending_login.dart b/frontend/pshared/lib/models/auth/pending_login.dart index 22f09ab1..41a62193 100644 --- a/frontend/pshared/lib/models/auth/pending_login.dart +++ b/frontend/pshared/lib/models/auth/pending_login.dart @@ -8,7 +8,7 @@ import 'package:pshared/models/session_identifier.dart'; class PendingLogin { final Account account; final TokenData pendingToken; - final String destination; + final String target; final SessionIdentifier session; final int? ttlSeconds; @@ -19,7 +19,7 @@ class PendingLogin { const PendingLogin({ required this.account, required this.pendingToken, - required this.destination, + required this.target, this.ttlSeconds, required this.session, this.cooldownSeconds, @@ -33,14 +33,14 @@ class PendingLogin { }) => PendingLogin( account: response.account.account.toDomain(), pendingToken: response.pendingToken, - destination: response.destination, + target: response.target, session: session, ); PendingLogin copyWith({ Account? account, TokenData? pendingToken, - String? destination, + String? target, int? ttlSeconds, SessionIdentifier? session, int? cooldownSeconds, @@ -51,7 +51,7 @@ class PendingLogin { return PendingLogin( account: account ?? this.account, pendingToken: pendingToken ?? this.pendingToken, - destination: destination ?? this.destination, + target: target ?? this.target, ttlSeconds: ttlSeconds ?? this.cooldownSeconds, session: session ?? this.session, cooldownSeconds: clearCooldown ? null : cooldownSeconds ?? this.cooldownSeconds, diff --git a/frontend/pshared/lib/provider/account.dart b/frontend/pshared/lib/provider/account.dart index ce27fc8b..e2c95853 100644 --- a/frontend/pshared/lib/provider/account.dart +++ b/frontend/pshared/lib/provider/account.dart @@ -120,12 +120,12 @@ class AccountProvider extends ChangeNotifier { VerificationResponse confirmation, ) { final ttlSeconds = confirmation.ttlSeconds != 0 ? confirmation.ttlSeconds : pending.ttlSeconds; - final destination = confirmation.target.isNotEmpty ? confirmation.target : pending.destination; + final target = confirmation.target.isNotEmpty ? confirmation.target : pending.target; final cooldownSeconds = confirmation.cooldownSeconds; return pending.copyWith( ttlSeconds: ttlSeconds, - destination: destination, + target: target, cooldownSeconds: cooldownSeconds > 0 ? cooldownSeconds : null, cooldownUntil: cooldownSeconds > 0 ? DateTime.now().add(confirmation.cooldownDuration) : null, clearCooldown: cooldownSeconds <= 0, diff --git a/frontend/pshared/lib/provider/email_verification.dart b/frontend/pshared/lib/provider/email_verification.dart index 067aaf89..9317c458 100644 --- a/frontend/pshared/lib/provider/email_verification.dart +++ b/frontend/pshared/lib/provider/email_verification.dart @@ -13,9 +13,9 @@ class EmailVerificationProvider extends ChangeNotifier { bool get isLoading => _resource.isLoading; bool get isSuccess => _resource.data == true; Exception? get error => _resource.error; - int? get errorCode => _resource.error is ErrorResponse - ? (_resource.error as ErrorResponse).code - : null; + ErrorResponse? get errorResponse => + _resource.error is ErrorResponse ? _resource.error as ErrorResponse : null; + int? get errorCode => errorResponse?.code; bool get canResendVerification => errorCode == 400 || errorCode == 410 || errorCode == 500; @@ -38,10 +38,6 @@ class EmailVerificationProvider extends ChangeNotifier { await AccountService.verifyEmail(trimmed); _setResource(Resource(data: true, isLoading: false)); } catch (e) { - if (e is ErrorResponse && e.code == 404) { - _setResource(Resource(data: true, isLoading: false)); - return; - } _setResource( Resource(data: null, isLoading: false, error: toException(e)), ); diff --git a/frontend/pshared/lib/provider/organizations.dart b/frontend/pshared/lib/provider/organizations.dart index 1c8bd816..ddaa45dc 100644 --- a/frontend/pshared/lib/provider/organizations.dart +++ b/frontend/pshared/lib/provider/organizations.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:collection/collection.dart'; +import 'package:share_plus/share_plus.dart'; + import 'package:pshared/config/constants.dart'; import 'package:pshared/models/organization/organization.dart'; import 'package:pshared/provider/resource.dart'; @@ -88,4 +90,55 @@ class OrganizationsProvider extends ChangeNotifier { // Best-effort cleanup of stored selection to avoid using stale org on next login. await SecureStorageService.delete(Constants.currentOrgKey); } + + Future uploadLogo(XFile logoFile) async { + if (!isOrganizationSet) { + throw StateError('Organization is not set'); + } + + _setResource(_resource.copyWith(isLoading: true, error: null)); + try { + final updated = await OrganizationService.uploadLogoAndUpdate(current, logoFile); + final updatedList = organizations + .map((org) => org.id == updated.id ? updated : org) + .toList(growable: false); + _setResource(Resource(data: updatedList, isLoading: false)); + _currentOrg = updated.id; + return updated; + } catch (e) { + _setResource(_resource.copyWith(isLoading: false, error: toException(e))); + rethrow; + } + } + + Future updateCurrent({ + String? name, + String? description, + String? timeZone, + String? logoUrl, + }) async { + if (!isOrganizationSet) { + throw StateError('Organization is not set'); + } + + _setResource(_resource.copyWith(isLoading: true, error: null)); + try { + final updated = await OrganizationService.updateSettings( + current, + name: name, + description: description, + timeZone: timeZone, + logoUrl: logoUrl, + ); + final updatedList = organizations + .map((org) => org.id == updated.id ? updated : org) + .toList(growable: false); + _setResource(Resource(data: updatedList, isLoading: false)); + _currentOrg = updated.id; + return updated; + } catch (e) { + _setResource(_resource.copyWith(isLoading: false, error: toException(e))); + rethrow; + } + } } diff --git a/frontend/pshared/lib/provider/payment/multiple/provider.dart b/frontend/pshared/lib/provider/payment/multiple/provider.dart index b37c98d7..9c0cd15c 100644 --- a/frontend/pshared/lib/provider/payment/multiple/provider.dart +++ b/frontend/pshared/lib/provider/payment/multiple/provider.dart @@ -12,14 +12,13 @@ class MultiPaymentProvider extends ChangeNotifier { late OrganizationsProvider _organization; late MultiQuotationProvider _quotation; - Resource> _payments = Resource(data: []); - bool _isLoaded = false; + Resource> _payments = Resource(data: null); List get payments => _payments.data ?? []; bool get isLoading => _payments.isLoading; Exception? get error => _payments.error; bool get isReady => - _isLoaded && !_payments.isLoading && _payments.error == null; + _payments.data != null && !_payments.isLoading && _payments.error == null; void update( OrganizationsProvider organization, @@ -56,7 +55,6 @@ class MultiPaymentProvider extends ChangeNotifier { metadata: metadata, ); - _isLoaded = true; _setResource( _payments.copyWith(data: response, isLoading: false, error: null), ); @@ -70,8 +68,7 @@ class MultiPaymentProvider extends ChangeNotifier { } void reset() { - _isLoaded = false; - _setResource(Resource(data: [])); + _setResource(Resource(data: null)); } void _setResource(Resource> payments) { diff --git a/frontend/pshared/lib/provider/payment/multiple/quotation.dart b/frontend/pshared/lib/provider/payment/multiple/quotation.dart index 71ea87bf..53ed3880 100644 --- a/frontend/pshared/lib/provider/payment/multiple/quotation.dart +++ b/frontend/pshared/lib/provider/payment/multiple/quotation.dart @@ -17,7 +17,6 @@ class MultiQuotationProvider extends ChangeNotifier { String? _loadedOrganizationRef; Resource _quotation = Resource(data: null); - bool _isLoaded = false; List? _lastIntents; bool _lastPreviewOnly = false; @@ -29,7 +28,7 @@ class MultiQuotationProvider extends ChangeNotifier { Exception? get error => _quotation.error; bool get canRefresh => _lastIntents != null && _lastIntents!.isNotEmpty; bool get isReady => - _isLoaded && !_quotation.isLoading && _quotation.error == null; + quotation != null && !_quotation.isLoading && _quotation.error == null; DateTime? get quoteExpiresAt { final quotes = quotation?.quotes; @@ -94,7 +93,6 @@ class MultiQuotationProvider extends ChangeNotifier { ), ); - _isLoaded = true; _setResource( _quotation.copyWith(data: response, isLoading: false, error: null), ); @@ -123,7 +121,6 @@ class MultiQuotationProvider extends ChangeNotifier { } void reset() { - _isLoaded = false; _lastIntents = null; _lastPreviewOnly = false; _lastMetadata = null; @@ -135,4 +132,9 @@ class MultiQuotationProvider extends ChangeNotifier { _quotation = quotation; notifyListeners(); } + + @override + void dispose() { + super.dispose(); + } } diff --git a/frontend/pshared/lib/provider/payment/quotation/intent_builder.dart b/frontend/pshared/lib/provider/payment/quotation/intent_builder.dart index c03b9b94..70b71e10 100644 --- a/frontend/pshared/lib/provider/payment/quotation/intent_builder.dart +++ b/frontend/pshared/lib/provider/payment/quotation/intent_builder.dart @@ -1,10 +1,7 @@ import 'package:pshared/controllers/balance_mask/wallets.dart'; import 'package:pshared/models/payment/asset.dart'; import 'package:pshared/models/payment/chain_network.dart'; -import 'package:pshared/models/payment/currency_pair.dart'; import 'package:pshared/models/payment/customer.dart'; -import 'package:pshared/models/payment/fx/intent.dart'; -import 'package:pshared/models/payment/fx/side.dart'; import 'package:pshared/models/payment/kind.dart'; import 'package:pshared/models/payment/methods/card.dart'; import 'package:pshared/models/payment/methods/crypto_address.dart'; @@ -21,6 +18,7 @@ import 'package:pshared/provider/payment/amount.dart'; import 'package:pshared/provider/payment/flow.dart'; import 'package:pshared/provider/recipient/provider.dart'; import 'package:pshared/utils/currency.dart'; +import 'package:pshared/utils/payment/fx_helpers.dart'; class QuotationIntentBuilder { @@ -40,22 +38,19 @@ class QuotationIntentBuilder { method: selectedMethod, data: paymentData, ); + final sourceCurrency = currencyCodeToString(selectedWallet.currency); final amount = Money( amount: payment.amount.toString(), // TODO: adapt to possible other sources - currency: currencyCodeToString(selectedWallet.currency), + currency: sourceCurrency, ); final isCryptoToCrypto = paymentData is CryptoAddressPaymentMethod && (paymentData.asset?.tokenSymbol ?? '').trim().toUpperCase() == amount.currency; - final fxIntent = isCryptoToCrypto - ? null - : FxIntent( - pair: CurrencyPair( - base: currencyCodeToString(selectedWallet.currency), - quote: 'RUB', // TODO: exentd target currencies - ), - side: FxSide.sellBaseBuyQuote, - ); + final fxIntent = FxIntentHelper.buildSellBaseBuyQuote( + baseCurrency: sourceCurrency, + quoteCurrency: 'RUB', // TODO: exentd target currencies + enabled: !isCryptoToCrypto, + ); return PaymentIntent( kind: PaymentKind.payout, amount: amount, @@ -69,35 +64,14 @@ class QuotationIntentBuilder { ), fx: fxIntent, settlementMode: payment.payerCoversFee ? SettlementMode.fixReceived : SettlementMode.fixSource, - settlementCurrency: _resolveSettlementCurrency(amount: amount, fx: fxIntent), + settlementCurrency: FxIntentHelper.resolveSettlementCurrency( + amount: amount, + fx: fxIntent, + ), customer: customer, ); } - String _resolveSettlementCurrency({ - required Money amount, - required FxIntent? fx, - }) { - final pair = fx?.pair; - if (pair != null) { - switch (fx?.side ?? FxSide.unspecified) { - case FxSide.buyBaseSellQuote: - if (pair.base.isNotEmpty) return pair.base; - break; - case FxSide.sellBaseBuyQuote: - if (pair.quote.isNotEmpty) return pair.quote; - break; - case FxSide.unspecified: - break; - } - if (amount.currency == pair.base && pair.quote.isNotEmpty) return pair.quote; - if (amount.currency == pair.quote && pair.base.isNotEmpty) return pair.base; - if (pair.quote.isNotEmpty) return pair.quote; - if (pair.base.isNotEmpty) return pair.base; - } - return amount.currency; - } - Customer? _buildCustomer({ required Recipient? recipient, required PaymentMethod? method, diff --git a/frontend/pshared/lib/service/verification.dart b/frontend/pshared/lib/service/verification.dart index ec01ae3b..99cd3f0a 100644 --- a/frontend/pshared/lib/service/verification.dart +++ b/frontend/pshared/lib/service/verification.dart @@ -32,13 +32,13 @@ class VerificationService { return VerificationResponse.fromJson(response); } - static Future resendLoginCode(PendingLogin pending, {String? destination}) async { + static Future resendLoginCode(PendingLogin pending, {String? target}) async { _logger.fine('Resending login confirmation code'); final response = await getPOSTResponse( _objectType, '/resend', LoginVerificationRequest( - target: destination, + target: target, idempotencyKey: pending.idempotencyKey ?? Uuid().v4(), ).toJson(), authToken: pending.pendingToken.token, diff --git a/frontend/pshared/lib/utils/payment/fx_helpers.dart b/frontend/pshared/lib/utils/payment/fx_helpers.dart new file mode 100644 index 00000000..df8c3aad --- /dev/null +++ b/frontend/pshared/lib/utils/payment/fx_helpers.dart @@ -0,0 +1,47 @@ +import 'package:pshared/models/payment/currency_pair.dart'; +import 'package:pshared/models/payment/fx/intent.dart'; +import 'package:pshared/models/payment/fx/side.dart'; +import 'package:pshared/models/money.dart'; + + +class FxIntentHelper { + static FxIntent? buildSellBaseBuyQuote({ + required String baseCurrency, + required String quoteCurrency, + bool enabled = true, + }) { + if (!enabled) return null; + final base = baseCurrency.trim(); + final quote = quoteCurrency.trim(); + if (base.isEmpty || quote.isEmpty) return null; + if (base.toUpperCase() == quote.toUpperCase()) return null; + return FxIntent( + pair: CurrencyPair(base: base, quote: quote), + side: FxSide.sellBaseBuyQuote, + ); + } + + static String resolveSettlementCurrency({ + required Money amount, + FxIntent? fx, + }) { + final pair = fx?.pair; + if (pair != null) { + switch (fx?.side ?? FxSide.unspecified) { + case FxSide.buyBaseSellQuote: + if (pair.base.isNotEmpty) return pair.base; + break; + case FxSide.sellBaseBuyQuote: + if (pair.quote.isNotEmpty) return pair.quote; + break; + case FxSide.unspecified: + break; + } + if (amount.currency == pair.base && pair.quote.isNotEmpty) return pair.quote; + if (amount.currency == pair.quote && pair.base.isNotEmpty) return pair.base; + if (pair.quote.isNotEmpty) return pair.quote; + if (pair.base.isNotEmpty) return pair.base; + } + return amount.currency; + } +} diff --git a/frontend/pshared/lib/utils/snackbar.dart b/frontend/pshared/lib/utils/snackbar.dart index ce2dd3bd..4fbe2caa 100644 --- a/frontend/pshared/lib/utils/snackbar.dart +++ b/frontend/pshared/lib/utils/snackbar.dart @@ -3,12 +3,17 @@ import 'dart:async'; import 'package:flutter/material.dart'; -ScaffoldFeatureController notifyUserX(ScaffoldMessengerState sm, String message, { int delaySeconds = 3 }) -{ +ScaffoldFeatureController notifyUserX( + ScaffoldMessengerState sm, + String message, { + int delaySeconds = 3, +}) { + final durationSeconds = _normalizeDelaySeconds(delaySeconds); + sm.clearSnackBars(); return sm.showSnackBar( SnackBar( content: Text(message), - duration: Duration(seconds: delaySeconds), + duration: Duration(seconds: durationSeconds), ), ); } @@ -18,8 +23,10 @@ ScaffoldFeatureController notifyUser(BuildContex } Future> postNotifyUser( - BuildContext context, String message, {int delaySeconds = 3}) { - + BuildContext context, + String message, { + int delaySeconds = 3, +}) { final completer = Completer>(); WidgetsBinding.instance.addPostFrameCallback((_) { @@ -29,3 +36,6 @@ Future> postNotifyUser return completer.future; } + +int _normalizeDelaySeconds(int delaySeconds) => + delaySeconds <= 0 ? 3 : delaySeconds; diff --git a/frontend/pweb/lib/app/router/payout_shell.dart b/frontend/pweb/lib/app/router/payout_shell.dart index d09d489c..282a6440 100644 --- a/frontend/pweb/lib/app/router/payout_shell.dart +++ b/frontend/pweb/lib/app/router/payout_shell.dart @@ -12,6 +12,7 @@ import 'package:pshared/provider/payment/amount.dart'; import 'package:pshared/provider/payment/flow.dart'; import 'package:pshared/provider/payment/multiple/provider.dart'; import 'package:pshared/provider/payment/multiple/quotation.dart'; +import 'package:pshared/provider/payment/payments.dart'; import 'package:pshared/provider/payment/provider.dart'; import 'package:pshared/provider/payment/quotation/quotation.dart'; import 'package:pshared/provider/recipient/provider.dart'; @@ -22,6 +23,8 @@ import 'package:pweb/app/router/pages.dart'; import 'package:pweb/app/router/payout_routes.dart'; import 'package:pweb/controllers/multiple_payouts.dart'; import 'package:pweb/controllers/payment_page.dart'; +import 'package:pweb/providers/multiple_payouts.dart'; +import 'package:pweb/controllers/multi_quotation.dart'; import 'package:pweb/providers/quotation/quotation.dart'; import 'package:pshared/models/payment/wallet.dart'; import 'package:pweb/pages/address_book/form/page.dart'; @@ -34,7 +37,7 @@ import 'package:pweb/pages/report/page.dart'; import 'package:pweb/pages/settings/profile/page.dart'; import 'package:pweb/pages/wallet_top_up/page.dart'; import 'package:pweb/widgets/dialogs/confirmation_dialog.dart'; -import 'package:pweb/widgets/error/snackbar.dart'; +import 'package:pweb/utils/error/snackbar.dart'; import 'package:pweb/widgets/sidebar/destinations.dart'; import 'package:pweb/widgets/sidebar/page.dart'; import 'package:pweb/utils/payment/availability.dart'; @@ -42,6 +45,7 @@ import 'package:pweb/services/payments/csv_input.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; + RouteBase payoutShellRoute() => ShellRoute( builder: (context, state, child) => MultiProvider( providers: [ @@ -136,6 +140,13 @@ RouteBase payoutShellRoute() => ShellRoute( update: (context, organization, provider) => provider!..update(organization), ), + ChangeNotifierProxyProvider< + MultiQuotationProvider, + MultiQuotationController + >( + create: (_) => MultiQuotationController(), + update: (_, quotation, controller) => controller!..update(quotation), + ), ChangeNotifierProxyProvider2< OrganizationsProvider, MultiQuotationProvider, @@ -146,15 +157,24 @@ RouteBase payoutShellRoute() => ShellRoute( provider!..update(organization, quotation), ), ChangeNotifierProxyProvider3< - WalletsController, MultiQuotationProvider, MultiPaymentProvider, + PaymentsProvider, + MultiplePayoutsProvider + >( + create: (_) => MultiplePayoutsProvider(), + update: (context, quotation, payment, payments, provider) => + provider!..update(quotation, payment, payments), + ), + ChangeNotifierProxyProvider2< + MultiplePayoutsProvider, + WalletsController, MultiplePayoutsController >( create: (_) => MultiplePayoutsController(csvInput: WebCsvInputService()), - update: (context, wallets, quotation, payment, provider) => - provider!..update(wallets, quotation, payment), + update: (context, provider, wallets, controller) => + controller!..update(provider, wallets), ), ], child: PageSelector(child: child, routerState: state), diff --git a/frontend/pweb/lib/controllers/multi_quotation.dart b/frontend/pweb/lib/controllers/multi_quotation.dart new file mode 100644 index 00000000..ec37b242 --- /dev/null +++ b/frontend/pweb/lib/controllers/multi_quotation.dart @@ -0,0 +1,70 @@ +import 'package:flutter/foundation.dart'; + +import 'package:pshared/provider/payment/multiple/quotation.dart'; + +import 'package:pweb/providers/quotation/auto_refresh.dart'; + + +class MultiQuotationController extends ChangeNotifier { + static const Duration _autoRefreshLead = Duration(seconds: 5); + + MultiQuotationProvider? _quotation; + final QuotationAutoRefreshController _autoRefreshController = + QuotationAutoRefreshController(); + + void update(MultiQuotationProvider quotation) { + if (identical(_quotation, quotation)) return; + _quotation?.removeListener(_handleQuotationChanged); + _quotation = quotation; + _quotation?.addListener(_handleQuotationChanged); + _handleQuotationChanged(); + } + + bool get isLoading => _quotation?.isLoading ?? false; + Exception? get error => _quotation?.error; + bool get canRefresh => _quotation?.canRefresh ?? false; + bool get isReady => _quotation?.isReady ?? false; + + DateTime? get quoteExpiresAt => _quotation?.quoteExpiresAt; + + void refreshQuotation() { + _quotation?.refreshQuotation(); + } + + void _handleQuotationChanged() { + _syncAutoRefresh(); + notifyListeners(); + } + + void _syncAutoRefresh() { + final quotation = _quotation; + if (quotation == null) { + _autoRefreshController.reset(); + return; + } + + final expiresAt = quoteExpiresAt; + final scheduledAt = expiresAt == null + ? null + : expiresAt.subtract(_autoRefreshLead); + + _autoRefreshController.setEnabled(true); + _autoRefreshController.sync( + isLoading: quotation.isLoading, + canRefresh: quotation.canRefresh, + expiresAt: scheduledAt, + onRefresh: _refreshQuotation, + ); + } + + Future _refreshQuotation() async { + await _quotation?.refreshQuotation(); + } + + @override + void dispose() { + _quotation?.removeListener(_handleQuotationChanged); + _autoRefreshController.dispose(); + super.dispose(); + } +} diff --git a/frontend/pweb/lib/controllers/multiple_payouts.dart b/frontend/pweb/lib/controllers/multiple_payouts.dart index 191e24fe..594f5091 100644 --- a/frontend/pweb/lib/controllers/multiple_payouts.dart +++ b/frontend/pweb/lib/controllers/multiple_payouts.dart @@ -3,242 +3,133 @@ import 'package:flutter/foundation.dart'; import 'package:pshared/controllers/balance_mask/wallets.dart'; import 'package:pshared/models/money.dart'; import 'package:pshared/models/payment/payment.dart'; -import 'package:pshared/provider/payment/multiple/provider.dart'; -import 'package:pshared/provider/payment/multiple/quotation.dart'; -import 'package:pshared/utils/currency.dart'; +import 'package:pshared/models/payment/quote/status_type.dart'; +import 'package:pshared/models/payment/wallet.dart'; import 'package:pweb/models/multiple_payouts/csv_row.dart'; import 'package:pweb/models/multiple_payouts/state.dart'; +import 'package:pweb/providers/multiple_payouts.dart'; import 'package:pweb/services/payments/csv_input.dart'; -import 'package:pweb/utils/payment/multiple_csv_parser.dart'; -import 'package:pweb/utils/payment/multiple_intent_builder.dart'; class MultiplePayoutsController extends ChangeNotifier { final CsvInputService _csvInput; - final MultipleCsvParser _csvParser; - final MultipleIntentBuilder _intentBuilder; - + MultiplePayoutsProvider? _provider; WalletsController? _wallets; - MultiQuotationProvider? _quotation; - MultiPaymentProvider? _payment; - - MultiplePayoutsState _state = MultiplePayoutsState.idle; - String? _selectedFileName; - List _rows = const []; - int _sentCount = 0; - Exception? _error; + _PickState _pickState = _PickState.idle; MultiplePayoutsController({ required CsvInputService csvInput, - MultipleCsvParser? csvParser, - MultipleIntentBuilder? intentBuilder, - }) : _csvInput = csvInput, - _csvParser = csvParser ?? MultipleCsvParser(), - _intentBuilder = intentBuilder ?? MultipleIntentBuilder(); + }) : _csvInput = csvInput; - void update( - WalletsController wallets, - MultiQuotationProvider quotation, - MultiPaymentProvider payment, - ) { - _wallets = wallets; - _quotation = quotation; - _payment = payment; - } - - MultiplePayoutsState get state => _state; - String? get selectedFileName => _selectedFileName; - List get rows => List.unmodifiable(_rows); - int get sentCount => _sentCount; - Exception? get error => _error; - - bool get isQuoting => _state == MultiplePayoutsState.quoting; - bool get isSending => _state == MultiplePayoutsState.sending; - bool get isBusy => isQuoting || isSending; - - bool get canSend { - if (isBusy || _rows.isEmpty) return false; - final quoteRef = _quotation?.quotation?.quoteRef; - return quoteRef != null && quoteRef.isNotEmpty; - } - - Money? get aggregateDebitAmount { - if (_rows.isEmpty) return null; - return _moneyForSourceCurrency( - _quotation?.quotation?.aggregate?.debitAmounts, - ); - } - - Money? get requestedSentAmount { - if (_rows.isEmpty) return null; - const currency = 'RUB'; - - double total = 0; - for (final row in _rows) { - final value = double.tryParse(row.amount); - if (value == null) return null; - total += value; + void update(MultiplePayoutsProvider provider, WalletsController wallets) { + var shouldNotify = false; + if (!identical(_provider, provider)) { + _provider?.removeListener(_onProviderChanged); + _provider = provider; + _provider?.addListener(_onProviderChanged); + shouldNotify = true; + } + if (!identical(_wallets, wallets)) { + _wallets?.removeListener(_onWalletsChanged); + _wallets = wallets; + _wallets?.addListener(_onWalletsChanged); + shouldNotify = true; + } + if (shouldNotify) { + notifyListeners(); } - return Money(amount: amountToString(total), currency: currency); } - Money? get aggregateSettlementAmount { - if (_rows.isEmpty) return null; - return _moneyForSourceCurrency( - _quotation?.quotation?.aggregate?.expectedSettlementAmounts, - ); - } + MultiplePayoutsState get state => + _provider?.state ?? MultiplePayoutsState.idle; + String? get selectedFileName => _provider?.selectedFileName; + List get rows => _provider?.rows ?? const []; + int get sentCount => _provider?.sentCount ?? 0; + Exception? get error => _provider?.error; - Money? get aggregateFeeAmount { - if (_rows.isEmpty) return null; - return _moneyForSourceCurrency( - _quotation?.quotation?.aggregate?.expectedFeeTotals, - ); - } + bool get isQuoting => _provider?.isQuoting ?? false; + bool get isSending => _provider?.isSending ?? false; + bool get isBusy => _provider?.isBusy ?? false; - double? get aggregateFeePercent { - final debit = aggregateDebitAmount; - final fee = aggregateFeeAmount; - if (debit == null || fee == null) return null; + bool get quoteIsLoading => _provider?.quoteIsLoading ?? false; + QuoteStatusType get quoteStatusType => + _provider?.quoteStatusType ?? QuoteStatusType.missing; + Duration? get quoteTimeLeft => _provider?.quoteTimeLeft; - final debitValue = double.tryParse(debit.amount); - final feeValue = double.tryParse(fee.amount); - if (debit.currency.toUpperCase() != fee.currency.toUpperCase()) return null; - if (debitValue == null || feeValue == null || debitValue <= 0) return null; - return (feeValue / debitValue) * 100; - } + bool get canSend => _provider?.canSend ?? false; + Money? get aggregateDebitAmount => + _provider?.aggregateDebitAmountFor(_selectedWallet); + Money? get requestedSentAmount => _provider?.requestedSentAmount; + Money? get aggregateSettlementAmount => + _provider?.aggregateSettlementAmountFor(_selectedWallet); + Money? get aggregateFeeAmount => + _provider?.aggregateFeeAmountFor(_selectedWallet); + double? get aggregateFeePercent => + _provider?.aggregateFeePercentFor(_selectedWallet); Future pickAndQuote() async { - if (isBusy) return; - - final wallets = _wallets; - final quotation = _quotation; - if (wallets == null || quotation == null) { - _setErrorObject( - StateError('Multiple payouts dependencies are not ready'), - ); - return; - } + if (_pickState == _PickState.picking) return; + final provider = _provider; + if (provider == null) return; + _pickState = _PickState.picking; try { - _setState(MultiplePayoutsState.quoting); - _error = null; - _sentCount = 0; - final picked = await _csvInput.pickCsv(); - if (picked == null) { + if (picked == null) return; + final wallet = _selectedWallet; + if (wallet == null) { + provider.setError(StateError('Select source wallet first')); return; } - - final rows = _csvParser.parseRows(picked.content); - final intents = _intentBuilder.buildIntents(wallets, rows); - - _selectedFileName = picked.name; - _rows = rows; - - await quotation.quotePayments( - intents, - metadata: { - 'upload_filename': picked.name, - 'upload_rows': rows.length.toString(), - ...?_uploadAmountMetadata(), - }, + await provider.quoteFromCsv( + fileName: picked.name, + content: picked.content, + sourceWallet: wallet, ); - - if (quotation.error != null) { - _setErrorObject(quotation.error!); - } } catch (e) { - _setErrorObject(e); + provider.setError(e); } finally { - _setState(MultiplePayoutsState.idle); + _pickState = _PickState.idle; } } Future> send() async { - if (isBusy) return const []; + return _provider?.send() ?? const []; + } - final payment = _payment; - if (payment == null) { - _setErrorObject( - StateError('Multiple payouts payment provider is not ready'), - ); - return const []; - } - if (!canSend) { - _setErrorObject( - StateError('Upload CSV and wait for quote before sending'), - ); - return const []; - } - - try { - _setState(MultiplePayoutsState.sending); - _error = null; - - final result = await payment.pay( - metadata: { - ...?_selectedFileName == null - ? null - : {'upload_filename': _selectedFileName!}, - 'upload_rows': _rows.length.toString(), - ...?_uploadAmountMetadata(), - }, - ); - - _sentCount = result.length; - return result; - } catch (e) { - _setErrorObject(e); - return const []; - } finally { - _setState(MultiplePayoutsState.idle); + Future sendAndStorePayments() async { + final payments = + await _provider?.sendAndStorePayments() ?? const []; + final hasError = _provider?.error != null; + if (hasError || payments.isEmpty) { + return MultiplePayoutSendOutcome.failure; } + return MultiplePayoutSendOutcome.success; } void removeUploadedFile() { - if (isBusy) return; + _provider?.removeUploadedFile(); + } - _selectedFileName = null; - _rows = const []; - _sentCount = 0; - _error = null; + void _onProviderChanged() { notifyListeners(); } - void _setState(MultiplePayoutsState value) { - _state = value; + void _onWalletsChanged() { notifyListeners(); } - void _setErrorObject(Object error) { - _error = error is Exception ? error : Exception(error.toString()); - notifyListeners(); - } + Wallet? get _selectedWallet => _wallets?.selectedWallet; - Map? _uploadAmountMetadata() { - final sentAmount = requestedSentAmount; - if (sentAmount == null) return null; - return { - 'upload_amount': sentAmount.amount, - 'upload_currency': sentAmount.currency, - }; - } - - Money? _moneyForSourceCurrency(List? values) { - if (values == null || values.isEmpty) return null; - - final selectedWallet = _wallets?.selectedWallet; - if (selectedWallet != null) { - final sourceCurrency = currencyCodeToString(selectedWallet.currency); - for (final value in values) { - if (value.currency.toUpperCase() == sourceCurrency.toUpperCase()) { - return value; - } - } - } - - return values.first; + @override + void dispose() { + _provider?.removeListener(_onProviderChanged); + _wallets?.removeListener(_onWalletsChanged); + super.dispose(); } } + +enum _PickState { idle, picking } + +enum MultiplePayoutSendOutcome { success, failure } diff --git a/frontend/pweb/lib/controllers/upload_history_table.dart b/frontend/pweb/lib/controllers/upload_history_table.dart new file mode 100644 index 00000000..80fa714b --- /dev/null +++ b/frontend/pweb/lib/controllers/upload_history_table.dart @@ -0,0 +1,20 @@ +import 'package:pshared/models/payment/payment.dart'; + + +class UploadHistoryTableController { + const UploadHistoryTableController(); + + String amountText(Payment payment) { + final receivedAmount = payment.lastQuote?.expectedSettlementAmount; + if (receivedAmount != null) { + return '${receivedAmount.amount} ${receivedAmount.currency}'; + } + + final fallbackAmount = payment.lastQuote?.debitAmount; + if (fallbackAmount != null) { + return '${fallbackAmount.amount} ${fallbackAmount.currency}'; + } + + return '-'; + } +} diff --git a/frontend/pweb/lib/l10n/en.arb b/frontend/pweb/lib/l10n/en.arb index 5e23591a..e0f7245f 100644 --- a/frontend/pweb/lib/l10n/en.arb +++ b/frontend/pweb/lib/l10n/en.arb @@ -71,6 +71,14 @@ "errorAccountExists": "Account with this login already exists", "errorInternalError": "An internal error occurred. We're aware of the issue and working to resolve it. Please try again later", "errorVerificationTokenNotFound": "Account for verification not found. Sign up again", + "errorInvalidTarget": "Invalid verification target. Please try again.", + "errorPendingTokenRequired": "Additional verification required. Please retry the login flow.", + "errorMissingDestination": "Please provide a destination for verification.", + "errorMissingCode": "Please enter the verification code.", + "errorMissingSession": "Session information is missing. Please retry the login flow.", + "errorTokenExpired": "Verification code has expired. Please request a new one.", + "errorCodeAttemptsExceeded": "Too many incorrect attempts. Please request a new code.", + "errorTooManyRequests": "Too many requests. Please wait a bit and try again.", "created": "Created", "edited": "Edited", "errorDataConflict": "This action conflicts with existing data. Check for duplicates or conflicting values and try again.", @@ -503,12 +511,19 @@ }, "tokenColumn": "Token (required)", "currency": "Currency", - "amount": "Amount", + "amount": "Amount ₽", "comment": "Comment", "uploadCSV": "Upload your CSV", "upload": "Upload", "hintUpload": "Supported format: .CSV · Max size 1 MB", "uploadHistory": "Upload History", + "viewWholeHistory": "View Whole History", + "paymentStatusSuccessful": "Payment Successful", + "paymentStatusProcessing": "Processing", + "paymentStatusReserved": "Funds Reserved", + "paymentStatusFailed": "Payment Failed", + "paymentStatusCancelled": "Payment Cancelled", + "paymentStatusPending": "Pending", "payout": "Payout", "sendTo": "Send Payout To", "send": "Send Payout", diff --git a/frontend/pweb/lib/l10n/ru.arb b/frontend/pweb/lib/l10n/ru.arb index be6590a2..051b1151 100644 --- a/frontend/pweb/lib/l10n/ru.arb +++ b/frontend/pweb/lib/l10n/ru.arb @@ -71,6 +71,14 @@ "errorAccountExists": "Аккаунт с таким логином уже существует", "errorInternalError": "Произошла внутренняя ошибка. Мы в курсе проблемы и работаем над ее решением. Пожалуйста, попробуйте позже", "errorVerificationTokenNotFound": "Аккаунт для верификации не найден. Зарегистрируйтесь снова", + "errorInvalidTarget": "Неверная цель верификации. Попробуйте еще раз.", + "errorPendingTokenRequired": "Требуется дополнительная проверка. Повторите вход.", + "errorMissingDestination": "Укажите адрес для верификации.", + "errorMissingCode": "Введите код подтверждения.", + "errorMissingSession": "Отсутствуют данные сессии. Повторите вход.", + "errorTokenExpired": "Срок действия кода истек. Запросите новый.", + "errorCodeAttemptsExceeded": "Слишком много неверных попыток. Запросите новый код.", + "errorTooManyRequests": "Слишком много запросов. Подождите и попробуйте снова.", "created": "Создано", "edited": "Изменено", "errorDataConflict": "Действие конфликтует с уже существующими данными. Проверьте дубликаты или противоречащие значения и попробуйте снова.", @@ -503,12 +511,19 @@ }, "tokenColumn": "Токен (обязательно)", "currency": "Валюта", - "amount": "Сумма", + "amount": "Сумма ₽", "comment": "Комментарий", "uploadCSV": "Загрузите ваш CSV", "upload": "Загрузить", "hintUpload": "Поддерживаемый формат: .CSV · Макс. размер 1 МБ", "uploadHistory": "История загрузок", + "viewWholeHistory": "Смотреть всю историю", + "paymentStatusSuccessful": "Платеж успешен", + "paymentStatusProcessing": "В обработке", + "paymentStatusReserved": "Средства зарезервированы", + "paymentStatusFailed": "Платеж неуспешен", + "paymentStatusCancelled": "Платеж отменен", + "paymentStatusPending": "В ожидании", "payout": "Выплата", "sendTo": "Отправить выплату", "send": "Отправить выплату", diff --git a/frontend/pweb/lib/models/dashboard_payment_mode.dart b/frontend/pweb/lib/models/dashboard_payment_mode.dart new file mode 100644 index 00000000..fa93a76e --- /dev/null +++ b/frontend/pweb/lib/models/dashboard_payment_mode.dart @@ -0,0 +1 @@ +enum DashboardPayoutMode { single, multiple } diff --git a/frontend/pweb/lib/models/summary_values.dart b/frontend/pweb/lib/models/summary_values.dart new file mode 100644 index 00000000..b82723b9 --- /dev/null +++ b/frontend/pweb/lib/models/summary_values.dart @@ -0,0 +1,13 @@ +class PaymentSummaryValues { + final String sentAmount; + final String fee; + final String recipientReceives; + final String total; + + const PaymentSummaryValues({ + required this.sentAmount, + required this.fee, + required this.recipientReceives, + required this.total, + }); +} diff --git a/frontend/pweb/lib/pages/2fa/prompt.dart b/frontend/pweb/lib/pages/2fa/prompt.dart index 11abc6d4..b0acf931 100644 --- a/frontend/pweb/lib/pages/2fa/prompt.dart +++ b/frontend/pweb/lib/pages/2fa/prompt.dart @@ -13,7 +13,7 @@ class TwoFactorPromptText extends StatelessWidget { @override Widget build(BuildContext context) => Text( AppLocalizations.of(context)!.twoFactorPrompt( - context.watch().pendingLogin?.destination ?? '', + context.watch().pendingLogin?.target ?? '', ), style: Theme.of(context).textTheme.bodyLarge, textAlign: TextAlign.center, diff --git a/frontend/pweb/lib/pages/dashboard/dashboard.dart b/frontend/pweb/lib/pages/dashboard/dashboard.dart index 17a25260..6da9a047 100644 --- a/frontend/pweb/lib/pages/dashboard/dashboard.dart +++ b/frontend/pweb/lib/pages/dashboard/dashboard.dart @@ -6,10 +6,11 @@ import 'package:pshared/models/payment/type.dart'; import 'package:pshared/models/recipient/recipient.dart'; import 'package:pshared/models/payment/wallet.dart'; +import 'package:pweb/models/dashboard_payment_mode.dart'; import 'package:pweb/pages/dashboard/buttons/balance/balance.dart'; import 'package:pweb/pages/dashboard/buttons/balance/controller.dart'; import 'package:pweb/pages/dashboard/buttons/buttons.dart'; -import 'package:pweb/pages/dashboard/payouts/multiple/title.dart'; +import 'package:pweb/pages/dashboard/payouts/multiple/widgets/title.dart'; import 'package:pweb/pages/dashboard/payouts/multiple/widget.dart'; import 'package:pweb/pages/dashboard/payouts/single/widget.dart'; import 'package:pweb/pages/loader.dart'; @@ -42,19 +43,19 @@ class DashboardPage extends StatefulWidget { } class _DashboardPageState extends State { - bool _showContainerSingle = true; - bool _showContainerMultiple = false; + DashboardPayoutMode _payoutMode = DashboardPayoutMode.single; - void _setActive(bool single) { + void _setActive(DashboardPayoutMode mode) { setState(() { - _showContainerSingle = single; - _showContainerMultiple = !single; + _payoutMode = mode; }); } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; + final showSingle = _payoutMode == DashboardPayoutMode.single; + final showMultiple = _payoutMode == DashboardPayoutMode.multiple; return PageViewLoader( child: SafeArea( child: SingleChildScrollView( @@ -66,8 +67,8 @@ class _DashboardPageState extends State { Expanded( flex: 0, child: TransactionRefButton( - onTap: () => _setActive(true), - isActive: _showContainerSingle, + onTap: () => _setActive(DashboardPayoutMode.single), + isActive: showSingle, label: l10n.sendSingle, icon: Icons.person_add, ), @@ -76,8 +77,8 @@ class _DashboardPageState extends State { Expanded( flex: 0, child: TransactionRefButton( - onTap: () => _setActive(false), - isActive: _showContainerMultiple, + onTap: () => _setActive(DashboardPayoutMode.multiple), + isActive: showMultiple, label: l10n.sendMultiple, icon: Icons.group_add, ), @@ -93,14 +94,14 @@ class _DashboardPageState extends State { ), ), const SizedBox(height: AppSpacing.small), - if (_showContainerMultiple) TitleMultiplePayout(), + if (showMultiple) TitleMultiplePayout(), const SizedBox(height: AppSpacing.medium), - if (_showContainerSingle) + if (showSingle) SinglePayoutForm( onRecipientSelected: widget.onRecipientSelected, onGoToPayment: widget.onGoToPaymentWithoutRecipient, ), - if (_showContainerMultiple) MultiplePayoutForm(), + if (showMultiple) MultiplePayoutForm(), ], ), ), diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/csv.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/csv.dart deleted file mode 100644 index a5635201..00000000 --- a/frontend/pweb/lib/pages/dashboard/payouts/multiple/csv.dart +++ /dev/null @@ -1,101 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:provider/provider.dart'; - -import 'package:pshared/controllers/balance_mask/wallets.dart'; - -import 'package:pweb/controllers/multiple_payouts.dart'; -import 'package:pweb/pages/dashboard/payouts/multiple/source_quote_panel.dart'; -import 'package:pweb/pages/dashboard/payouts/multiple/upload_panel.dart'; - -import 'package:pweb/generated/i18n/app_localizations.dart'; - - -class UploadCSVSection extends StatelessWidget { - const UploadCSVSection({super.key}); - - static const double _verticalSpacing = 10; - static const double _iconTextSpacing = 5; - - @override - Widget build(BuildContext context) { - final controller = context.watch(); - final walletsController = context.watch(); - final theme = Theme.of(context); - final l10n = AppLocalizations.of(context)!; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Icon(Icons.upload), - const SizedBox(width: _iconTextSpacing), - Text( - l10n.uploadCSV, - style: theme.textTheme.bodyLarge?.copyWith( - fontSize: 18, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - const SizedBox(height: _verticalSpacing), - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - border: Border.all(color: theme.colorScheme.outline), - borderRadius: BorderRadius.circular(8), - ), - child: LayoutBuilder( - builder: (context, constraints) { - final useHorizontal = constraints.maxWidth >= 760; - if (!useHorizontal) { - return Column( - children: [ - UploadPanel( - controller: controller, - theme: theme, - l10n: l10n, - ), - const SizedBox(height: 12), - SourceQuotePanel( - controller: controller, - walletsController: walletsController, - theme: theme, - l10n: l10n, - ), - ], - ); - } - - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - flex: 6, - child: UploadPanel( - controller: controller, - theme: theme, - l10n: l10n, - ), - ), - const SizedBox(width: 12), - Expanded( - flex: 5, - child: SourceQuotePanel( - controller: controller, - walletsController: walletsController, - theme: theme, - l10n: l10n, - ), - ), - ], - ); - }, - ), - ), - ], - ); - } -} diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/form.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/form.dart deleted file mode 100644 index fa333a2c..00000000 --- a/frontend/pweb/lib/pages/dashboard/payouts/multiple/form.dart +++ /dev/null @@ -1,17 +0,0 @@ -class MultiplePayoutRow { - final String pan; - final String firstName; - final String lastName; - final int expMonth; - final int expYear; - final String amount; - - const MultiplePayoutRow({ - required this.pan, - required this.firstName, - required this.lastName, - required this.expMonth, - required this.expYear, - required this.amount, - }); -} diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/history.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/history.dart deleted file mode 100644 index 6db4994e..00000000 --- a/frontend/pweb/lib/pages/dashboard/payouts/multiple/history.dart +++ /dev/null @@ -1,121 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:intl/intl.dart'; - -import 'package:provider/provider.dart'; - -import 'package:pshared/provider/payment/payments.dart'; - -import 'package:pweb/generated/i18n/app_localizations.dart'; - - -class UploadHistorySection extends StatelessWidget { - const UploadHistorySection({super.key}); - - static const double _smallBox = 5; - static const double _radius = 6; - - @override - Widget build(BuildContext context) { - final provider = context.watch(); - final theme = Theme.of(context); - final l10 = AppLocalizations.of(context)!; - final dateFormat = DateFormat.yMMMd().add_Hm(); - - if (provider.isLoading) { - return const Center(child: CircularProgressIndicator()); - } - if (provider.error != null) { - return Text( - l10.notificationError(provider.error ?? l10.noErrorInformation), - ); - } - final items = List.of(provider.payments); - items.sort((a, b) { - final left = a.createdAt; - final right = b.createdAt; - if (left == null && right == null) return 0; - if (left == null) return 1; - if (right == null) return -1; - return right.compareTo(left); - }); - - return Column( - children: [ - Row( - children: [ - const Icon(Icons.history), - const SizedBox(width: _smallBox), - Text(l10.uploadHistory, style: theme.textTheme.bodyLarge), - ], - ), - const SizedBox(height: 8), - if (items.isEmpty) - Align( - alignment: Alignment.centerLeft, - child: Text( - l10.walletHistoryEmpty, - style: theme.textTheme.bodyMedium, - ), - ) - else - DataTable( - columns: [ - DataColumn(label: Text(l10.fileNameColumn)), - DataColumn(label: Text(l10.rowsColumn)), - DataColumn(label: Text(l10.dateColumn)), - DataColumn(label: Text(l10.amountColumn)), - DataColumn(label: Text(l10.statusColumn)), - ], - rows: items.map((payment) { - final metadata = payment.metadata; - final state = payment.state ?? '-'; - final statusColor = - payment.isFailure ? Colors.red : Colors.green; - final fileName = metadata?['upload_filename']; - final fileNameText = - (fileName == null || fileName.isEmpty) ? '-' : fileName; - final rows = metadata?['upload_rows']; - final rowsText = (rows == null || rows.isEmpty) ? '-' : rows; - final createdAt = payment.createdAt; - final dateText = createdAt == null - ? '-' - : dateFormat.format(createdAt.toLocal()); - final amountValue = metadata?['upload_amount']; - final amountCurrency = metadata?['upload_currency']; - final fallbackAmount = payment.lastQuote?.debitAmount; - final amountText = (amountValue == null || amountValue.isEmpty) - ? (fallbackAmount == null - ? '-' - : '${fallbackAmount.amount} ${fallbackAmount.currency}') - : (amountCurrency == null || amountCurrency.isEmpty - ? amountValue - : '$amountValue $amountCurrency'); - - return DataRow( - cells: [ - DataCell(Text(fileNameText)), - DataCell(Text(rowsText)), - DataCell(Text(dateText)), - DataCell(Text(amountText)), - DataCell( - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - decoration: BoxDecoration( - color: statusColor.withAlpha(20), - borderRadius: BorderRadius.circular(_radius), - ), - child: Text(state, style: TextStyle(color: statusColor)), - ), - ), - ], - ); - }).toList(), - ), - ], - ); - } -} diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/source_quote/header.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/source_quote/header.dart new file mode 100644 index 00000000..d6ebe0ba --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/source_quote/header.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class SourceQuotePanelHeader extends StatelessWidget { + const SourceQuotePanelHeader({ + super.key, + required this.theme, + required this.l10n, + }); + + final ThemeData theme; + final AppLocalizations l10n; + + @override + Widget build(BuildContext context) { + return Text( + l10n.sourceOfFunds, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ); + } +} \ No newline at end of file diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/source_quote/helpers.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/source_quote/helpers.dart new file mode 100644 index 00000000..84e455c8 --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/source_quote/helpers.dart @@ -0,0 +1,38 @@ +import 'package:pshared/models/asset.dart'; +import 'package:pshared/models/money.dart'; +import 'package:pshared/utils/currency.dart'; + +import 'package:pweb/controllers/multiple_payouts.dart'; + + +String moneyLabel(Money? money) { + if (money == null) return 'N/A'; + final amount = double.tryParse(money.amount); + if (amount == null) return '${money.amount} ${money.currency}'; + try { + return assetToString( + Asset( + currency: currencyStringToCode(money.currency), + amount: amount, + ), + ); + } catch (_) { + return '${money.amount} ${money.currency}'; + } +} + +String sentAmountLabel(MultiplePayoutsController controller) { + final requested = controller.requestedSentAmount; + final sourceDebit = controller.aggregateDebitAmount; + + if (requested == null && sourceDebit == null) return 'N/A'; + if (sourceDebit != null) return moneyLabel(sourceDebit); + return moneyLabel(requested); +} + +String feeLabel(MultiplePayoutsController controller) { + final feeLabelText = moneyLabel(controller.aggregateFeeAmount); + final percent = controller.aggregateFeePercent; + if (percent == null) return feeLabelText; + return '$feeLabelText (${percent.toStringAsFixed(2)}%)'; +} diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/source_quote/selector.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/source_quote/selector.dart new file mode 100644 index 00000000..0f89dd13 --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/source_quote/selector.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/controllers/balance_mask/wallets.dart'; +import 'package:pshared/utils/currency.dart'; + +import 'package:pweb/controllers/multiple_payouts.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class SourceWalletSelector extends StatelessWidget { + const SourceWalletSelector({ + super.key, + required this.controller, + required this.walletsController, + required this.theme, + required this.l10n, + }); + + final MultiplePayoutsController controller; + final WalletsController walletsController; + final ThemeData theme; + final AppLocalizations l10n; + + @override + Widget build(BuildContext context) { + final wallets = walletsController.wallets; + final selectedWalletRef = walletsController.selectedWalletRef; + + if (wallets.isEmpty) { + return Text(l10n.noWalletsAvailable, style: theme.textTheme.bodySmall); + } + + return DropdownButtonFormField( + initialValue: selectedWalletRef, + isExpanded: true, + decoration: InputDecoration( + labelText: l10n.whereGetMoney, + border: const OutlineInputBorder(), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + ), + items: wallets + .map( + (wallet) => DropdownMenuItem( + value: wallet.id, + child: Text( + '${wallet.name} - ${amountToString(wallet.balance)} ${currencyCodeToString(wallet.currency)}', + overflow: TextOverflow.ellipsis, + ), + ), + ) + .toList(growable: false), + onChanged: controller.isBusy + ? null + : (value) { + if (value == null) return; + walletsController.selectWalletByRef(value); + }, + ); + } +} diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/source_quote/summary.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/source_quote/summary.dart new file mode 100644 index 00000000..478f7c5a --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/source_quote/summary.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/controllers/multiple_payouts.dart'; +import 'package:pweb/models/summary_values.dart'; +import 'package:pweb/pages/dashboard/payouts/multiple/panels/source_quote/helpers.dart'; +import 'package:pweb/pages/dashboard/payouts/summary/widget.dart'; + + +class SourceQuoteSummary extends StatelessWidget { + const SourceQuoteSummary({ + super.key, + required this.controller, + required this.spacing, + }); + + final MultiplePayoutsController controller; + final double spacing; + + @override + Widget build(BuildContext context) { + return PaymentSummary( + spacing: spacing, + values: PaymentSummaryValues( + sentAmount: sentAmountLabel(controller), + fee: feeLabel(controller), + recipientReceives: moneyLabel(controller.aggregateSettlementAmount), + total: moneyLabel(controller.aggregateDebitAmount), + ), + ); + } +} diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/source_quote/widget.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/source_quote/widget.dart new file mode 100644 index 00000000..f3141ec9 --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/source_quote/widget.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/controllers/balance_mask/wallets.dart'; + +import 'package:pweb/controllers/multiple_payouts.dart'; +import 'package:pweb/pages/dashboard/payouts/multiple/panels/source_quote/header.dart'; +import 'package:pweb/pages/dashboard/payouts/multiple/panels/source_quote/summary.dart'; +import 'package:pweb/pages/dashboard/payouts/multiple/panels/source_quote/selector.dart'; +import 'package:pweb/pages/dashboard/payouts/multiple/widgets/quote_status.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + + + +class SourceQuotePanel extends StatelessWidget { + const SourceQuotePanel({ + super.key, + required this.controller, + required this.walletsController, + required this.theme, + required this.l10n, + }); + + final MultiplePayoutsController controller; + final WalletsController walletsController; + final ThemeData theme; + final AppLocalizations l10n; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.colorScheme.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: theme.colorScheme.outlineVariant), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SourceQuotePanelHeader(theme: theme, l10n: l10n), + const SizedBox(height: 8), + SourceWalletSelector( + controller: controller, + walletsController: walletsController, + theme: theme, + l10n: l10n, + ), + const SizedBox(height: 12), + const Divider(height: 1), + const SizedBox(height: 12), + SourceQuoteSummary(controller: controller, spacing: 12), + const SizedBox(height: 12), + MultipleQuoteStatusCard(controller: controller), + ], + ), + ); + } + +} diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/upload_panel/actions.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/upload_panel/actions.dart new file mode 100644 index 00000000..9911d6a2 --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/upload_panel/actions.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/controllers/multiple_payouts.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class UploadPanelActions extends StatelessWidget { + const UploadPanelActions({ + super.key, + required this.controller, + required this.l10n, + required this.onSend, + }); + + final MultiplePayoutsController controller; + final AppLocalizations l10n; + final VoidCallback onSend; + + static const double _buttonVerticalPadding = 12; + static const double _buttonHorizontalPadding = 24; + + @override + Widget build(BuildContext context) { + final hasFile = controller.selectedFileName != null; + + return Wrap( + spacing: 8, + runSpacing: 8, + alignment: WrapAlignment.center, + children: [ + OutlinedButton( + onPressed: !hasFile || controller.isBusy + ? null + : () => controller.removeUploadedFile(), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: _buttonHorizontalPadding, + vertical: _buttonVerticalPadding, + ), + ), + child: Text(l10n.cancel), + ), + ElevatedButton( + onPressed: controller.canSend ? onSend : null, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: _buttonHorizontalPadding, + vertical: _buttonVerticalPadding, + ), + ), + child: Text(l10n.send), + ), + ], + ); + } +} diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/upload_panel/drop_zone.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/upload_panel/drop_zone.dart new file mode 100644 index 00000000..5bb0629f --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/upload_panel/drop_zone.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/controllers/multiple_payouts.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class UploadDropZone extends StatelessWidget { + const UploadDropZone({ + super.key, + required this.controller, + required this.theme, + required this.l10n, + }); + + final MultiplePayoutsController controller; + final ThemeData theme; + final AppLocalizations l10n; + + static const double _panelRadius = 12; + + @override + Widget build(BuildContext context) { + final hasFile = controller.selectedFileName != null; + + return InkWell( + onTap: controller.isBusy + ? null + : () => controller.pickAndQuote(), + borderRadius: BorderRadius.circular(_panelRadius), + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 16), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest.withValues( + alpha: 0.5, + ), + border: Border.all(color: theme.colorScheme.outlineVariant), + borderRadius: BorderRadius.circular(_panelRadius), + ), + child: Column( + children: [ + Icon( + Icons.upload_file, + size: 34, + color: theme.colorScheme.primary, + ), + const SizedBox(height: 8), + Text( + hasFile ? controller.selectedFileName! : l10n.uploadCSV, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + ), + if (!hasFile) ...[ + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 8, + ), + decoration: BoxDecoration( + color: theme.colorScheme.primary.withValues(alpha: 0.12), + border: Border.all( + color: theme.colorScheme.primary.withValues(alpha: 0.5), + ), + borderRadius: BorderRadius.circular(999), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.touch_app, + size: 16, + color: theme.colorScheme.primary, + ), + const SizedBox(width: 6), + Text( + l10n.upload, + style: theme.textTheme.labelLarge?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ], + const SizedBox(height: 6), + Text( + l10n.hintUpload, + style: theme.textTheme.bodySmall, + textAlign: TextAlign.center, + ), + if (hasFile) ...[ + const SizedBox(height: 6), + Text( + '${l10n.payout}: ${controller.rows.length}', + style: theme.textTheme.labelMedium?.copyWith( + color: theme.colorScheme.primary, + ), + ), + ], + ], + ), + ), + ); + } +} diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/upload_panel/helpers.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/upload_panel/helpers.dart new file mode 100644 index 00000000..bb618b5b --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/upload_panel/helpers.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/controllers/multiple_payouts.dart'; +import 'package:pweb/widgets/dialogs/payment_status_dialog.dart'; + + +Future handleUploadSend( + BuildContext context, + MultiplePayoutsController controller, +) async { + final outcome = await controller.sendAndStorePayments(); + + if (!context.mounted) return; + + await showPaymentStatusDialog( + context, + isSuccess: outcome == MultiplePayoutSendOutcome.success, + ); + + controller.removeUploadedFile(); +} diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/upload_panel/progress.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/upload_panel/progress.dart new file mode 100644 index 00000000..10c9deac --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/upload_panel/progress.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; + + +class UploadQuoteProgress extends StatelessWidget { + const UploadQuoteProgress({ + super.key, + required this.isQuoting, + required this.theme, + }); + + final bool isQuoting; + final ThemeData theme; + + @override + Widget build(BuildContext context) { + if (!isQuoting) return const SizedBox.shrink(); + + return Column( + children: [ + const SizedBox(height: 10), + ClipRRect( + borderRadius: BorderRadius.circular(999), + child: LinearProgressIndicator( + minHeight: 5, + color: theme.colorScheme.primary, + backgroundColor: theme.colorScheme.surfaceContainerHighest, + ), + ), + ], + ); + } +} diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/upload_panel/status.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/upload_panel/status.dart new file mode 100644 index 00000000..864df391 --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/upload_panel/status.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/controllers/multiple_payouts.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class UploadPanelStatus extends StatelessWidget { + const UploadPanelStatus({ + super.key, + required this.controller, + required this.theme, + required this.l10n, + }); + + final MultiplePayoutsController controller; + final ThemeData theme; + final AppLocalizations l10n; + + @override + Widget build(BuildContext context) { + if (controller.sentCount <= 0 && controller.error == null) { + return const SizedBox.shrink(); + } + + return Column( + children: [ + if (controller.sentCount > 0) ...[ + const SizedBox(height: 8), + Text( + '${l10n.payout}: ${controller.sentCount}', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.primary, + ), + ), + ], + if (controller.error != null) ...[ + const SizedBox(height: 8), + Text( + controller.error.toString(), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.error, + ), + ), + ], + ], + ); + } +} diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/upload_panel/widget.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/upload_panel/widget.dart new file mode 100644 index 00000000..380c2e9a --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/upload_panel/widget.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/controllers/multiple_payouts.dart'; + +import 'package:pweb/pages/dashboard/payouts/multiple/panels/upload_panel/drop_zone.dart'; +import 'package:pweb/pages/dashboard/payouts/multiple/panels/upload_panel/actions.dart'; +import 'package:pweb/pages/dashboard/payouts/multiple/panels/upload_panel/helpers.dart'; +import 'package:pweb/pages/dashboard/payouts/multiple/panels/upload_panel/status.dart'; +import 'package:pweb/pages/dashboard/payouts/multiple/panels/upload_panel/progress.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class UploadPanel extends StatelessWidget { + const UploadPanel({ + super.key, + required this.controller, + required this.theme, + required this.l10n, + }); + + final MultiplePayoutsController controller; + final ThemeData theme; + final AppLocalizations l10n; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + UploadDropZone(controller: controller, theme: theme, l10n: l10n), + UploadQuoteProgress(isQuoting: controller.isQuoting, theme: theme), + const SizedBox(height: 12), + UploadPanelActions( + controller: controller, + l10n: l10n, + onSend: () => handleUploadSend(context, controller), + ), + UploadPanelStatus(controller: controller, theme: theme, l10n: l10n), + ], + ); + } +} diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/sample.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/sample.dart deleted file mode 100644 index 877285f7..00000000 --- a/frontend/pweb/lib/pages/dashboard/payouts/multiple/sample.dart +++ /dev/null @@ -1,125 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter/material.dart'; - -import 'package:pshared/models/file/downloaded_file.dart'; - -import 'package:pweb/pages/dashboard/payouts/multiple/form.dart'; -import 'package:pweb/utils/download.dart'; - -import 'package:pweb/generated/i18n/app_localizations.dart'; - - -class FileFormatSampleSection extends StatelessWidget { - const FileFormatSampleSection({super.key}); - - static final List sampleRows = [ - MultiplePayoutRow( - pan: "9022****11", - firstName: "Alex", - lastName: "Ivanov", - expMonth: 12, - expYear: 27, - amount: "500", - ), - MultiplePayoutRow( - pan: "9022****12", - firstName: "Maria", - lastName: "Sokolova", - expMonth: 7, - expYear: 26, - amount: "100", - ), - MultiplePayoutRow( - pan: "9022****13", - firstName: "Dmitry", - lastName: "Smirnov", - expMonth: 3, - expYear: 28, - amount: "120", - ), - ]; - - static const String _sampleFileName = 'sample.csv'; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final l10n = AppLocalizations.of(context)!; - - final titleStyle = theme.textTheme.bodyLarge?.copyWith( - fontSize: 18, - fontWeight: FontWeight.w600, - ); - - final linkStyle = theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.primary, - ); - - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - const Icon(Icons.filter_list), - const SizedBox(width: 5), - Text(l10n.exampleTitle, style: titleStyle), - ], - ), - const SizedBox(height: 12), - _buildDataTable(l10n), - const SizedBox(height: 10), - TextButton( - onPressed: _downloadSampleCsv, - style: TextButton.styleFrom(padding: EdgeInsets.zero), - child: Text(l10n.downloadSampleCSV, style: linkStyle), - ), - ], - ); - } - - Widget _buildDataTable(AppLocalizations l10n) { - return DataTable( - columnSpacing: 20, - columns: [ - DataColumn(label: Text(l10n.cardNumberColumn)), - DataColumn(label: Text(l10n.firstName)), - DataColumn(label: Text(l10n.lastName)), - DataColumn(label: Text(l10n.expiryDate)), - DataColumn(label: Text(l10n.amount)), - ], - rows: sampleRows.map((row) { - return DataRow( - cells: [ - DataCell(Text(row.pan)), - DataCell(Text(row.firstName)), - DataCell(Text(row.lastName)), - DataCell( - Text('${row.expMonth.toString().padLeft(2, '0')}/${row.expYear}'), - ), - DataCell(Text(row.amount)), - ], - ); - }).toList(), - ); - } - - Future _downloadSampleCsv() async { - final rows = [ - 'pan,first_name,last_name,exp_month,exp_year,amount', - ...sampleRows.map( - (row) => - '${row.pan},${row.firstName},${row.lastName},${row.expMonth},${row.expYear},${row.amount}', - ), - ]; - final content = rows.join('\n'); - - await downloadFile( - DownloadedFile( - bytes: utf8.encode(content), - filename: _sampleFileName, - mimeType: 'text/csv;charset=utf-8', - ), - ); - } -} diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/history/header.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/history/header.dart new file mode 100644 index 00000000..4ccfe2db --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/history/header.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class UploadHistoryHeader extends StatelessWidget { + const UploadHistoryHeader({ + super.key, + required this.theme, + required this.l10n, + }); + + final ThemeData theme; + final AppLocalizations l10n; + + static const double _smallBox = 5; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + const Icon(Icons.history), + const SizedBox(width: _smallBox), + Text(l10n.uploadHistory, style: theme.textTheme.bodyLarge), + ], + ); + } +} diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/history/helpers.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/history/helpers.dart new file mode 100644 index 00000000..98ccde5a --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/history/helpers.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class StatusView { + final String label; + final Color color; + + const StatusView(this.label, this.color); +} + +StatusView statusView(AppLocalizations l10n, String? raw) { + final trimmed = (raw ?? '').trim(); + final upper = trimmed.toUpperCase(); + final normalized = upper.startsWith('PAYMENT_STATE_') + ? upper.substring('PAYMENT_STATE_'.length) + : upper; + + switch (normalized) { + case 'SETTLED': + return StatusView(l10n.paymentStatusPending, Colors.yellow); + case 'SUCCESS': + return StatusView(l10n.paymentStatusSuccessful, Colors.green); + case 'FUNDS_RESERVED': + return StatusView(l10n.paymentStatusReserved, Colors.blue); + case 'ACCEPTED': + return StatusView(l10n.paymentStatusProcessing, Colors.yellow); + case 'SUBMITTED': + return StatusView(l10n.paymentStatusProcessing, Colors.blue); + case 'FAILED': + return StatusView(l10n.paymentStatusFailed, Colors.red); + case 'CANCELLED': + return StatusView(l10n.paymentStatusCancelled, Colors.grey); + case 'UNSPECIFIED': + case '': + default: + return StatusView(l10n.paymentStatusPending, Colors.grey); + } +} diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/history/status_badge.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/history/status_badge.dart new file mode 100644 index 00000000..9b7bd341 --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/history/status_badge.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/pages/dashboard/payouts/multiple/sections/history/helpers.dart'; + + +class HistoryStatusBadge extends StatelessWidget { + const HistoryStatusBadge({ + super.key, + required this.statusView, + }); + + final StatusView statusView; + + static const double _radius = 6; + static const double _statusBgOpacity = 0.12; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: statusView.color.withValues(alpha: _statusBgOpacity), + borderRadius: BorderRadius.circular(_radius), + ), + child: Text( + statusView.label, + style: TextStyle( + color: statusView.color, + fontWeight: FontWeight.w600, + ), + ), + ); + } +} diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/history/table.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/history/table.dart new file mode 100644 index 00000000..c2572751 --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/history/table.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; + +import 'package:intl/intl.dart'; + +import 'package:pshared/models/payment/payment.dart'; + +import 'package:pweb/controllers/upload_history_table.dart'; +import 'package:pweb/pages/dashboard/payouts/multiple/sections/history/helpers.dart'; +import 'package:pweb/pages/dashboard/payouts/multiple/sections/history/status_badge.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class UploadHistoryTable extends StatelessWidget { + const UploadHistoryTable({ + super.key, + required this.items, + required this.dateFormat, + required this.l10n, + }); + + final List items; + final DateFormat dateFormat; + final AppLocalizations l10n; + + static const int _maxVisibleItems = 10; + static const UploadHistoryTableController _controller = + UploadHistoryTableController(); + + @override + Widget build(BuildContext context) { + final visibleItems = items.take(_maxVisibleItems).toList(growable: false); + + return DataTable( + columns: [ + DataColumn(label: Text(l10n.fileNameColumn)), + DataColumn(label: Text(l10n.rowsColumn)), + DataColumn(label: Text(l10n.dateColumn)), + DataColumn(label: Text(l10n.amountColumn)), + DataColumn(label: Text(l10n.statusColumn)), + ], + rows: visibleItems.map((payment) { + final metadata = payment.metadata; + final status = statusView(l10n, payment.state); + final fileName = metadata?['upload_filename']; + final fileNameText = + (fileName == null || fileName.isEmpty) ? '-' : fileName; + final rows = metadata?['upload_rows']; + final rowsText = (rows == null || rows.isEmpty) ? '-' : rows; + final createdAt = payment.createdAt; + final dateText = createdAt == null + ? '-' + : dateFormat.format(createdAt.toLocal()); + final amountText = _controller.amountText(payment); + + return DataRow( + cells: [ + DataCell(Text(fileNameText)), + DataCell(Text(rowsText)), + DataCell(Text(dateText)), + DataCell(Text(amountText)), + DataCell(HistoryStatusBadge(statusView: status)), + ], + ); + }).toList(), + ); + } +} diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/history/widget.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/history/widget.dart new file mode 100644 index 00000000..010908f0 --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/history/widget.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; + +import 'package:intl/intl.dart'; + +import 'package:provider/provider.dart'; + +import 'package:pshared/provider/payment/payments.dart'; + +import 'package:pweb/pages/dashboard/payouts/multiple/sections/history/header.dart'; +import 'package:pweb/pages/dashboard/payouts/multiple/sections/history/table.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class UploadHistorySection extends StatelessWidget { + const UploadHistorySection({super.key}); + + @override + Widget build(BuildContext context) { + final provider = context.watch(); + final theme = Theme.of(context); + final l10 = AppLocalizations.of(context)!; + final dateFormat = DateFormat.yMMMd().add_Hm(); + + if (provider.isLoading) { + return const Center(child: CircularProgressIndicator()); + } + if (provider.error != null) { + return Text( + l10.notificationError(provider.error ?? l10.noErrorInformation), + ); + } + final items = List.of(provider.payments); + items.sort((a, b) { + final left = a.createdAt; + final right = b.createdAt; + if (left == null && right == null) return 0; + if (left == null) return 1; + if (right == null) return -1; + return right.compareTo(left); + }); + + return Column( + children: [ + UploadHistoryHeader(theme: theme, l10n: l10), + const SizedBox(height: 8), + if (items.isEmpty) + Align( + alignment: Alignment.centerLeft, + child: Text( + l10.walletHistoryEmpty, + style: theme.textTheme.bodyMedium, + ), + ) + else ...[ + UploadHistoryTable( + items: items, + dateFormat: dateFormat, + l10n: l10, + ), + //TODO redirect to Reports page + // if (hasMore) ...[ + // const SizedBox(height: 8), + // Align( + // alignment: Alignment.centerLeft, + // child: TextButton.icon( + // onPressed: () => context.goNamed(PayoutRoutes.reports), + // icon: const Icon(Icons.open_in_new, size: 16), + // label: Text(l10.viewWholeHistory), + // ), + // ), + // ], + ], + ], + ); + } +} diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/sample/data.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/sample/data.dart new file mode 100644 index 00000000..b8612f41 --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/sample/data.dart @@ -0,0 +1,42 @@ +import 'package:pweb/models/multiple_payouts/csv_row.dart'; + + +const String sampleFileName = 'sample.csv'; + +final List sampleRows = [ + CsvPayoutRow( + pan: "9022****11", + firstName: "Alex", + lastName: "Ivanov", + expMonth: 12, + expYear: 27, + amount: "500", + ), + CsvPayoutRow( + pan: "9022****12", + firstName: "Maria", + lastName: "Sokolova", + expMonth: 7, + expYear: 26, + amount: "100", + ), + CsvPayoutRow( + pan: "9022****13", + firstName: "Dmitry", + lastName: "Smirnov", + expMonth: 3, + expYear: 28, + amount: "120", + ), +]; + +String buildSampleCsvContent() { + final rows = [ + 'pan,first_name,last_name,exp_month,exp_year,amount', + ...sampleRows.map( + (row) => + '${row.pan},${row.firstName},${row.lastName},${row.expMonth},${row.expYear},${row.amount}', + ), + ]; + return rows.join('\n'); +} diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/sample/download_button.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/sample/download_button.dart new file mode 100644 index 00000000..3b4309c3 --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/sample/download_button.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class FileFormatSampleDownloadButton extends StatelessWidget { + const FileFormatSampleDownloadButton({ + super.key, + required this.theme, + required this.l10n, + required this.onPressed, + }); + + final ThemeData theme; + final AppLocalizations l10n; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + final linkStyle = theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.primary, + ); + + return TextButton( + onPressed: onPressed, + style: TextButton.styleFrom(padding: EdgeInsets.zero), + child: Text(l10n.downloadSampleCSV, style: linkStyle), + ); + } +} diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/sample/header.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/sample/header.dart new file mode 100644 index 00000000..691a124c --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/sample/header.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class FileFormatSampleHeader extends StatelessWidget { + const FileFormatSampleHeader({ + super.key, + required this.theme, + required this.l10n, + }); + + final ThemeData theme; + final AppLocalizations l10n; + + @override + Widget build(BuildContext context) { + final titleStyle = theme.textTheme.bodyLarge?.copyWith( + fontSize: 18, + fontWeight: FontWeight.w600, + ); + + return Row( + children: [ + const Icon(Icons.filter_list), + const SizedBox(width: 5), + Text(l10n.exampleTitle, style: titleStyle), + ], + ); + } +} diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/sample/table.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/sample/table.dart new file mode 100644 index 00000000..7844d6b8 --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/sample/table.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/models/multiple_payouts/csv_row.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class FileFormatSampleTable extends StatelessWidget { + const FileFormatSampleTable({ + super.key, + required this.l10n, + required this.rows, + }); + + final AppLocalizations l10n; + final List rows; + + @override + Widget build(BuildContext context) { + return DataTable( + columnSpacing: 20, + columns: [ + DataColumn(label: Text(l10n.cardNumberColumn)), + DataColumn(label: Text(l10n.firstName)), + DataColumn(label: Text(l10n.lastName)), + DataColumn(label: Text(l10n.expiryDate)), + DataColumn(label: Text(l10n.amount)), + ], + rows: rows.map((row) { + return DataRow( + cells: [ + DataCell(Text(row.pan)), + DataCell(Text(row.firstName)), + DataCell(Text(row.lastName)), + DataCell( + Text('${row.expMonth.toString().padLeft(2, '0')}/${row.expYear}'), + ), + DataCell(Text(row.amount)), + ], + ); + }).toList(), + ); + } +} diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/sample/widget.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/sample/widget.dart new file mode 100644 index 00000000..5ced3fda --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/sample/widget.dart @@ -0,0 +1,48 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; + +import 'package:pshared/models/file/downloaded_file.dart'; +import 'package:pweb/pages/dashboard/payouts/multiple/sections/sample/data.dart'; +import 'package:pweb/pages/dashboard/payouts/multiple/sections/sample/download_button.dart'; +import 'package:pweb/pages/dashboard/payouts/multiple/sections/sample/header.dart'; +import 'package:pweb/pages/dashboard/payouts/multiple/sections/sample/table.dart'; +import 'package:pweb/utils/download.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class FileFormatSampleSection extends StatelessWidget { + const FileFormatSampleSection({super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l10n = AppLocalizations.of(context)!; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + FileFormatSampleHeader(theme: theme, l10n: l10n), + const SizedBox(height: 12), + FileFormatSampleTable(l10n: l10n, rows: sampleRows), + const SizedBox(height: 10), + FileFormatSampleDownloadButton( + theme: theme, + l10n: l10n, + onPressed: _downloadSampleCsv, + ), + ], + ); + } + + Future _downloadSampleCsv() async { + await downloadFile( + DownloadedFile( + bytes: utf8.encode(buildSampleCsvContent()), + filename: sampleFileName, + mimeType: 'text/csv;charset=utf-8', + ), + ); + } +} diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/upload_csv/header.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/upload_csv/header.dart new file mode 100644 index 00000000..55d9d870 --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/upload_csv/header.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + +class UploadCsvHeader extends StatelessWidget { + const UploadCsvHeader({ + super.key, + required this.theme, + required this.l10n, + }); + + final ThemeData theme; + final AppLocalizations l10n; + + static const double _iconTextSpacing = 5; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + const Icon(Icons.upload), + const SizedBox(width: _iconTextSpacing), + Text( + l10n.uploadCSV, + style: theme.textTheme.bodyLarge?.copyWith( + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + ], + ); + } +} diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/upload_csv/layout.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/upload_csv/layout.dart new file mode 100644 index 00000000..b9e8e761 --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/upload_csv/layout.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/controllers/balance_mask/wallets.dart'; + +import 'package:pweb/controllers/multiple_payouts.dart'; + +import 'package:pweb/pages/dashboard/payouts/multiple/panels/source_quote/widget.dart'; +import 'package:pweb/pages/dashboard/payouts/multiple/panels/upload_panel/widget.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; +import 'package:pweb/pages/dashboard/payouts/multiple/sections/upload_csv/panel_card.dart'; + + +class UploadCsvLayout extends StatelessWidget { + const UploadCsvLayout({ + super.key, + required this.controller, + required this.walletsController, + required this.theme, + required this.l10n, + }); + + final MultiplePayoutsController controller; + final WalletsController walletsController; + final ThemeData theme; + final AppLocalizations l10n; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final useHorizontal = constraints.maxWidth >= 760; + if (!useHorizontal) { + return Column( + children: [ + PanelCard( + theme: theme, + child: UploadPanel( + controller: controller, + theme: theme, + l10n: l10n, + ), + ), + const SizedBox(height: 12), + SourceQuotePanel( + controller: controller, + walletsController: walletsController, + theme: theme, + l10n: l10n, + ), + ], + ); + } + + return IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + flex: 3, + child: PanelCard( + theme: theme, + child: UploadPanel( + controller: controller, + theme: theme, + l10n: l10n, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + flex: 5, + child: SourceQuotePanel( + controller: controller, + walletsController: walletsController, + theme: theme, + l10n: l10n, + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/upload_csv/panel_card.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/upload_csv/panel_card.dart new file mode 100644 index 00000000..e922bf69 --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/upload_csv/panel_card.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; + + +class PanelCard extends StatelessWidget { + const PanelCard({ + super.key, + required this.theme, + required this.child, + }); + + final ThemeData theme; + final Widget child; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: theme.colorScheme.surface, + border: Border.all(color: theme.colorScheme.outlineVariant), + borderRadius: BorderRadius.circular(8), + ), + child: child, + ); + } +} diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/upload_csv/widget.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/upload_csv/widget.dart new file mode 100644 index 00000000..a30f33cf --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/upload_csv/widget.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:pweb/controllers/multiple_payouts.dart'; +import 'package:pweb/pages/dashboard/payouts/multiple/sections/upload_csv/header.dart'; +import 'package:pweb/pages/dashboard/payouts/multiple/sections/upload_csv/layout.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class UploadCSVSection extends StatelessWidget { + const UploadCSVSection({super.key}); + + static const double _verticalSpacing = 10; + + @override + Widget build(BuildContext context) { + final controller = context.watch(); + final theme = Theme.of(context); + final l10n = AppLocalizations.of(context)!; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UploadCsvHeader(theme: theme, l10n: l10n), + const SizedBox(height: _verticalSpacing), + UploadCsvLayout( + controller: controller, + walletsController: context.watch(), + theme: theme, + l10n: l10n, + ), + ], + ); + } +} diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/source_quote_panel.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/source_quote_panel.dart deleted file mode 100644 index d568b37a..00000000 --- a/frontend/pweb/lib/pages/dashboard/payouts/multiple/source_quote_panel.dart +++ /dev/null @@ -1,134 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:pshared/controllers/balance_mask/wallets.dart'; -import 'package:pshared/models/money.dart'; -import 'package:pshared/utils/currency.dart'; - -import 'package:pweb/controllers/multiple_payouts.dart'; - -import 'package:pweb/generated/i18n/app_localizations.dart'; - - -class SourceQuotePanel extends StatelessWidget { - const SourceQuotePanel({ - super.key, - required this.controller, - required this.walletsController, - required this.theme, - required this.l10n, - }); - - final MultiplePayoutsController controller; - final WalletsController walletsController; - final ThemeData theme; - final AppLocalizations l10n; - - @override - Widget build(BuildContext context) { - final wallets = walletsController.wallets; - final selectedWalletRef = walletsController.selectedWalletRef; - - return Container( - width: double.infinity, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: theme.colorScheme.surface, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: theme.colorScheme.outlineVariant), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l10n.sourceOfFunds, - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 8), - if (wallets.isEmpty) - Text(l10n.noWalletsAvailable, style: theme.textTheme.bodySmall) - else - DropdownButtonFormField( - initialValue: selectedWalletRef, - isExpanded: true, - decoration: InputDecoration( - labelText: l10n.whereGetMoney, - border: const OutlineInputBorder(), - contentPadding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 10, - ), - ), - items: wallets - .map( - (wallet) => DropdownMenuItem( - value: wallet.id, - child: Text( - '${wallet.name} · ${amountToString(wallet.balance)} ${currencyCodeToString(wallet.currency)}', - overflow: TextOverflow.ellipsis, - ), - ), - ) - .toList(growable: false), - onChanged: controller.isBusy - ? null - : (value) { - if (value == null) return; - walletsController.selectWalletByRef(value); - }, - ), - const SizedBox(height: 12), - const Divider(height: 1), - const SizedBox(height: 12), - Text( - controller.aggregateDebitAmount == null - ? l10n.quoteUnavailable - : l10n.quoteActive, - style: theme.textTheme.labelLarge?.copyWith( - color: theme.colorScheme.primary, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 8), - Text( - l10n.sentAmount(_sentAmountLabel(controller)), - style: theme.textTheme.bodyMedium, - ), - Text( - l10n.recipientsWillReceive( - _moneyLabel(controller.aggregateSettlementAmount), - ), - style: theme.textTheme.bodyMedium, - ), - Text( - controller.aggregateFeePercent == null - ? l10n.fee(_moneyLabel(controller.aggregateFeeAmount)) - : '${l10n.fee(_moneyLabel(controller.aggregateFeeAmount))} (${controller.aggregateFeePercent!.toStringAsFixed(2)}%)', - style: theme.textTheme.bodyMedium, - ), - ], - ), - ); - } - - String _moneyLabel(Money? money) { - if (money == null) return '-'; - return '${money.amount} ${money.currency}'; - } - - String _sentAmountLabel(MultiplePayoutsController controller) { - final requested = controller.requestedSentAmount; - final sourceDebit = controller.aggregateDebitAmount; - - if (requested == null && sourceDebit == null) return '-'; - if (requested == null) return _moneyLabel(sourceDebit); - if (sourceDebit == null) return _moneyLabel(requested); - - if (requested.currency.toUpperCase() == - sourceDebit.currency.toUpperCase()) { - return _moneyLabel(sourceDebit); - } - return '${_moneyLabel(requested)} (${_moneyLabel(sourceDebit)})'; - } -} diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/upload_panel.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/upload_panel.dart deleted file mode 100644 index 641ed19c..00000000 --- a/frontend/pweb/lib/pages/dashboard/payouts/multiple/upload_panel.dart +++ /dev/null @@ -1,160 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:provider/provider.dart'; - -import 'package:pshared/provider/payment/payments.dart'; - -import 'package:pweb/controllers/multiple_payouts.dart'; - -import 'package:pweb/generated/i18n/app_localizations.dart'; - -//TODO this file is too long -class UploadPanel extends StatelessWidget { - const UploadPanel({ - super.key, - required this.controller, - required this.theme, - required this.l10n, - }); - - final MultiplePayoutsController controller; - final ThemeData theme; - final AppLocalizations l10n; - - static const double _buttonVerticalPadding = 12; - static const double _buttonHorizontalPadding = 24; - - @override - Widget build(BuildContext context) { - return Column( - children: [ - Icon(Icons.upload_file, size: 36, color: theme.colorScheme.primary), - const SizedBox(height: 8), - Wrap( - spacing: 8, - runSpacing: 8, - alignment: WrapAlignment.center, - children: [ - ElevatedButton( - onPressed: controller.isBusy - ? null - : () => context - .read() - .pickAndQuote(), - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: _buttonHorizontalPadding, - vertical: _buttonVerticalPadding, - ), - ), - child: Text(l10n.upload), - ), - ElevatedButton( - onPressed: controller.canSend - ? () => _handleSend(context) - : null, - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: _buttonHorizontalPadding, - vertical: _buttonVerticalPadding, - ), - ), - child: Text(l10n.send), - ), - ], - ), - const SizedBox(height: 8), - Text( - l10n.hintUpload, - style: const TextStyle(fontSize: 12), - textAlign: TextAlign.center, - ), - if (controller.isQuoting || controller.isSending) ...[ - const SizedBox(height: 10), - const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator(strokeWidth: 2), - ), - ], - if (controller.selectedFileName != null) ...[ - const SizedBox(height: 8), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Flexible( - child: Text( - '${controller.selectedFileName} · ${controller.rows.length}', - style: theme.textTheme.bodySmall, - overflow: TextOverflow.ellipsis, - ), - ), - IconButton( - tooltip: l10n.close, - visualDensity: VisualDensity.compact, - onPressed: controller.isBusy - ? null - : () => context - .read() - .removeUploadedFile(), - icon: const Icon(Icons.close, size: 18), - ), - ], - ), - ], - if (controller.sentCount > 0) ...[ - const SizedBox(height: 8), - Text( - '${l10n.payout}: ${controller.sentCount}', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.primary, - ), - ), - ], - if (controller.error != null) ...[ - const SizedBox(height: 8), - Text( - controller.error.toString(), - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.error, - ), - ), - ], - ], - ); - } - - Future _handleSend(BuildContext context) async { - final paymentsProvider = context.read(); - final result = await controller.send(); - paymentsProvider.addPayments(result); - await paymentsProvider.refresh(); - - if (!context.mounted) return; - - final isSuccess = controller.error == null && result.isNotEmpty; - await showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text( - isSuccess - ? l10n.paymentStatusSuccessTitle - : l10n.paymentStatusFailureTitle, - ), - content: Text( - isSuccess - ? l10n.paymentStatusSuccessMessage - : l10n.paymentStatusFailureMessage, - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: Text(l10n.close), - ), - ], - ), - ); - - controller.removeUploadedFile(); - } -} diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/widget.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/widget.dart index cf3efe6f..48e68305 100644 --- a/frontend/pweb/lib/pages/dashboard/payouts/multiple/widget.dart +++ b/frontend/pweb/lib/pages/dashboard/payouts/multiple/widget.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; -import 'package:pweb/pages/dashboard/payouts/multiple/csv.dart'; -import 'package:pweb/pages/dashboard/payouts/multiple/history.dart'; -import 'package:pweb/pages/dashboard/payouts/multiple/sample.dart'; +import 'package:pweb/pages/dashboard/payouts/multiple/sections/upload_csv/widget.dart'; +import 'package:pweb/pages/dashboard/payouts/multiple/sections/history/widget.dart'; +import 'package:pweb/pages/dashboard/payouts/multiple/sections/sample/widget.dart'; class MultiplePayoutForm extends StatelessWidget { diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/widgets/quote_status.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/widgets/quote_status.dart new file mode 100644 index 00000000..6aeb3315 --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/payouts/multiple/widgets/quote_status.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/models/payment/quote/status_type.dart'; + +import 'package:pweb/controllers/multiple_payouts.dart'; +import 'package:pweb/pages/dashboard/payouts/quote_status/widgets/card.dart'; +import 'package:pweb/utils/quote_duration_format.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class MultipleQuoteStatusCard extends StatelessWidget { + const MultipleQuoteStatusCard({ + super.key, + required this.controller, + }); + + final MultiplePayoutsController controller; + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; + final isLoading = controller.quoteIsLoading; + final statusType = controller.quoteStatusType; + final timeLeft = controller.quoteTimeLeft; + + String statusText; + String? helperText; + switch (statusType) { + case QuoteStatusType.loading: + statusText = loc.quoteUpdating; + break; + case QuoteStatusType.error: + statusText = loc.quoteErrorGeneric; + break; + case QuoteStatusType.missing: + statusText = loc.quoteUnavailable; + break; + case QuoteStatusType.expired: + statusText = loc.quoteExpired; + break; + case QuoteStatusType.active: + statusText = timeLeft == null + ? loc.quoteActive + : loc.quoteExpiresIn(formatQuoteDuration(timeLeft)); + break; + } + + return QuoteStatusCard( + statusType: statusType, + statusText: statusText, + helperText: helperText, + isLoading: isLoading, + canRefresh: false, + showPrimaryRefresh: false, + onRefresh: () {}, + ); + } +} diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/title.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/widgets/title.dart similarity index 100% rename from frontend/pweb/lib/pages/dashboard/payouts/multiple/title.dart rename to frontend/pweb/lib/pages/dashboard/payouts/multiple/widgets/title.dart diff --git a/frontend/pweb/lib/pages/dashboard/payouts/summary/row.dart b/frontend/pweb/lib/pages/dashboard/payouts/summary/row.dart index 0bbbe678..f459261d 100644 --- a/frontend/pweb/lib/pages/dashboard/payouts/summary/row.dart +++ b/frontend/pweb/lib/pages/dashboard/payouts/summary/row.dart @@ -7,18 +7,21 @@ import 'package:pshared/utils/currency.dart'; class PaymentSummaryRow extends StatelessWidget { final String Function(String) labelFactory; final Asset? asset; + final String? value; final TextStyle? style; const PaymentSummaryRow({ super.key, required this.labelFactory, required this.asset, + this.value, this.style, }); @override - Widget build(BuildContext context) => Text( - labelFactory(asset == null ? 'N/A' : assetToString(asset!)), - style: style, - ); + Widget build(BuildContext context) { + final formatted = value ?? + (asset == null ? 'N/A' : assetToString(asset!)); + return Text(labelFactory(formatted), style: style); + } } diff --git a/frontend/pweb/lib/pages/dashboard/payouts/summary/widget.dart b/frontend/pweb/lib/pages/dashboard/payouts/summary/widget.dart index a0dc5257..90811718 100644 --- a/frontend/pweb/lib/pages/dashboard/payouts/summary/widget.dart +++ b/frontend/pweb/lib/pages/dashboard/payouts/summary/widget.dart @@ -5,29 +5,86 @@ import 'package:provider/provider.dart'; import 'package:pshared/controllers/balance_mask/wallets.dart'; import 'package:pshared/utils/currency.dart'; +import 'package:pweb/models/summary_values.dart'; import 'package:pweb/pages/dashboard/payouts/summary/fee.dart'; import 'package:pweb/pages/dashboard/payouts/summary/recipient_receives.dart'; +import 'package:pweb/pages/dashboard/payouts/summary/row.dart'; import 'package:pweb/pages/dashboard/payouts/summary/sent_amount.dart'; import 'package:pweb/pages/dashboard/payouts/summary/total.dart'; +import 'package:pweb/generated/i18n/app_localizations.dart'; + class PaymentSummary extends StatelessWidget { final double spacing; + final PaymentSummaryValues? values; - const PaymentSummary({super.key, required this.spacing}); + const PaymentSummary({ + super.key, + required this.spacing, + this.values, + }); @override - Widget build(BuildContext context) => Align( - alignment: Alignment.center, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - PaymentSentAmountRow(currency: currencyStringToCode(context.read().selectedWallet?.tokenSymbol ?? 'USDT')), - const PaymentFeeRow(), - const PaymentRecipientReceivesRow(), - SizedBox(height: spacing), - const PaymentTotalRow(), - ], - ), - ); -} + Widget build(BuildContext context) { + final resolvedValues = values; + if (resolvedValues != null) { + final theme = Theme.of(context); + final loc = AppLocalizations.of(context)!; + return Align( + alignment: Alignment.center, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + PaymentSummaryRow( + labelFactory: loc.sentAmount, + asset: null, + value: resolvedValues.sentAmount, + style: theme.textTheme.titleMedium, + ), + PaymentSummaryRow( + labelFactory: loc.fee, + asset: null, + value: resolvedValues.fee, + style: theme.textTheme.titleMedium, + ), + PaymentSummaryRow( + labelFactory: loc.recipientWillReceive, + asset: null, + value: resolvedValues.recipientReceives, + style: theme.textTheme.titleMedium, + ), + SizedBox(height: spacing), + PaymentSummaryRow( + labelFactory: loc.total, + asset: null, + value: resolvedValues.total, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + } + + return Align( + alignment: Alignment.center, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + PaymentSentAmountRow( + currency: currencyStringToCode( + context.read().selectedWallet?.tokenSymbol ?? + 'USDT', + ), + ), + const PaymentFeeRow(), + const PaymentRecipientReceivesRow(), + SizedBox(height: spacing), + const PaymentTotalRow(), + ], + ), + ); + } +} \ No newline at end of file diff --git a/frontend/pweb/lib/pages/errors/error.dart b/frontend/pweb/lib/pages/errors/error.dart index 36135742..6c8ea91c 100644 --- a/frontend/pweb/lib/pages/errors/error.dart +++ b/frontend/pweb/lib/pages/errors/error.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:pweb/app/router/pages.dart'; import 'package:pweb/widgets/vspacer.dart'; -import 'package:pweb/utils/error_handler.dart'; +import 'package:pweb/utils/error/handler.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; diff --git a/frontend/pweb/lib/pages/invitations/page.dart b/frontend/pweb/lib/pages/invitations/page.dart index 57a7be25..74b65561 100644 --- a/frontend/pweb/lib/pages/invitations/page.dart +++ b/frontend/pweb/lib/pages/invitations/page.dart @@ -13,7 +13,7 @@ import 'package:pweb/pages/invitations/widgets/header.dart'; import 'package:pweb/pages/invitations/widgets/form/form.dart'; import 'package:pweb/pages/invitations/widgets/list/list.dart'; import 'package:pweb/pages/loader.dart'; -import 'package:pweb/widgets/error/snackbar.dart'; +import 'package:pweb/utils/error/snackbar.dart'; import 'package:pweb/widgets/roles/create_role_dialog.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; diff --git a/frontend/pweb/lib/pages/invitations/widgets/card/actions.dart b/frontend/pweb/lib/pages/invitations/widgets/card/actions.dart index 446b96db..78930075 100644 --- a/frontend/pweb/lib/pages/invitations/widgets/card/actions.dart +++ b/frontend/pweb/lib/pages/invitations/widgets/card/actions.dart @@ -5,7 +5,7 @@ import 'package:provider/provider.dart'; import 'package:pshared/models/invitation/invitation.dart'; import 'package:pshared/provider/invitations.dart'; -import 'package:pweb/widgets/error/snackbar.dart'; +import 'package:pweb/utils/error/snackbar.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; diff --git a/frontend/pweb/lib/pages/invitations/widgets/list/view.dart b/frontend/pweb/lib/pages/invitations/widgets/list/view.dart index a9b88458..df82d1ca 100644 --- a/frontend/pweb/lib/pages/invitations/widgets/list/view.dart +++ b/frontend/pweb/lib/pages/invitations/widgets/list/view.dart @@ -9,7 +9,7 @@ import 'package:pweb/pages/invitations/widgets/filter/chips.dart'; import 'package:pweb/pages/invitations/widgets/list/body.dart'; import 'package:pweb/pages/invitations/widgets/list/view_model.dart'; import 'package:pweb/pages/invitations/widgets/search_field.dart'; -import 'package:pweb/widgets/error/snackbar.dart'; +import 'package:pweb/utils/error/snackbar.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; diff --git a/frontend/pweb/lib/pages/loaders/account.dart b/frontend/pweb/lib/pages/loaders/account.dart index 68e957a8..1b1c2908 100644 --- a/frontend/pweb/lib/pages/loaders/account.dart +++ b/frontend/pweb/lib/pages/loaders/account.dart @@ -6,7 +6,7 @@ import 'package:pshared/models/auth/state.dart'; import 'package:pshared/provider/account.dart'; import 'package:pweb/app/router/pages.dart'; -import 'package:pweb/widgets/error/snackbar.dart'; +import 'package:pweb/utils/error/snackbar.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; diff --git a/frontend/pweb/lib/pages/login/form.dart b/frontend/pweb/lib/pages/login/form.dart index c2d1c565..632ecb3b 100644 --- a/frontend/pweb/lib/pages/login/form.dart +++ b/frontend/pweb/lib/pages/login/form.dart @@ -15,7 +15,7 @@ import 'package:pweb/widgets/password/hint/short.dart'; import 'package:pweb/widgets/password/password.dart'; import 'package:pweb/widgets/username.dart'; import 'package:pweb/widgets/vspacer.dart'; -import 'package:pweb/widgets/error/snackbar.dart'; +import 'package:pweb/utils/error/snackbar.dart'; import 'package:pweb/services/posthog.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; diff --git a/frontend/pweb/lib/pages/signup/confirmation/card/card.dart b/frontend/pweb/lib/pages/signup/confirmation/card/card.dart index c5b8b817..83d2bf9f 100644 --- a/frontend/pweb/lib/pages/signup/confirmation/card/card.dart +++ b/frontend/pweb/lib/pages/signup/confirmation/card/card.dart @@ -7,7 +7,7 @@ import 'package:provider/provider.dart'; import 'package:pshared/provider/account.dart'; import 'package:pweb/utils/snackbar.dart'; -import 'package:pweb/widgets/error/snackbar.dart'; +import 'package:pweb/utils/error/snackbar.dart'; import 'package:pweb/widgets/vspacer.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; diff --git a/frontend/pweb/lib/pages/signup/form/content.dart b/frontend/pweb/lib/pages/signup/form/content.dart index f3f9dada..17f18d78 100644 --- a/frontend/pweb/lib/pages/signup/form/content.dart +++ b/frontend/pweb/lib/pages/signup/form/content.dart @@ -30,6 +30,7 @@ class SignUpFormContent extends StatelessWidget { constraints: BoxConstraints(maxWidth: maxWidth), child: Card( child: Column( + mainAxisSize: MainAxisSize.min, children: [ Row( children: [ diff --git a/frontend/pweb/lib/pages/signup/form/state.dart b/frontend/pweb/lib/pages/signup/form/state.dart index 3914cb44..d1f7686a 100644 --- a/frontend/pweb/lib/pages/signup/form/state.dart +++ b/frontend/pweb/lib/pages/signup/form/state.dart @@ -16,7 +16,7 @@ import 'package:pweb/pages/signup/form/content.dart'; import 'package:pweb/pages/signup/form/controllers.dart'; import 'package:pweb/pages/signup/form/form.dart'; import 'package:pweb/pages/signup/confirmation/args.dart'; -import 'package:pweb/widgets/error/snackbar.dart'; +import 'package:pweb/utils/error/snackbar.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; diff --git a/frontend/pweb/lib/pages/signup/page.dart b/frontend/pweb/lib/pages/signup/page.dart index 752b78c3..89fff537 100644 --- a/frontend/pweb/lib/pages/signup/page.dart +++ b/frontend/pweb/lib/pages/signup/page.dart @@ -13,4 +13,4 @@ class SignUpPage extends StatelessWidget { appBar: const LoginAppBar(), child: SignUpForm(), ); -} \ No newline at end of file +} diff --git a/frontend/pweb/lib/pages/verification/content.dart b/frontend/pweb/lib/pages/verification/content.dart index b5c82f88..c3c90221 100644 --- a/frontend/pweb/lib/pages/verification/content.dart +++ b/frontend/pweb/lib/pages/verification/content.dart @@ -11,7 +11,7 @@ import 'package:pweb/pages/with_footer.dart'; import 'package:pweb/pages/verification/controller.dart'; import 'package:pweb/pages/verification/resend_dialog.dart'; import 'package:pweb/utils/snackbar.dart'; -import 'package:pweb/widgets/error/snackbar.dart'; +import 'package:pweb/utils/error/snackbar.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; diff --git a/frontend/pweb/lib/pages/with_footer.dart b/frontend/pweb/lib/pages/with_footer.dart index d96612d8..2455ce65 100644 --- a/frontend/pweb/lib/pages/with_footer.dart +++ b/frontend/pweb/lib/pages/with_footer.dart @@ -16,20 +16,20 @@ class PageWithFooter extends StatelessWidget { @override Widget build(BuildContext context) => Scaffold( appBar: appBar, - body: LayoutBuilder( - builder: (context, constraints) => SingleChildScrollView( - child: ConstrainedBox( - constraints: BoxConstraints(minHeight: constraints.maxHeight), - child: IntrinsicHeight( - child: Column( - children: [ - Expanded(child: child), - FooterWidget(), - ], + body: CustomScrollView( + slivers: [ + SliverFillRemaining( + hasScrollBody: false, + child: Column( + children: [ + Expanded( + child: Center(child: child), ), - ), + FooterWidget(), + ], ), ), - ), + ], + ), ); -} \ No newline at end of file +} diff --git a/frontend/pweb/lib/providers/multiple_payouts.dart b/frontend/pweb/lib/providers/multiple_payouts.dart new file mode 100644 index 00000000..4f376eea --- /dev/null +++ b/frontend/pweb/lib/providers/multiple_payouts.dart @@ -0,0 +1,295 @@ +import 'package:flutter/foundation.dart'; + +import 'package:pshared/models/money.dart'; +import 'package:pshared/models/payment/payment.dart'; +import 'package:pshared/models/payment/quote/status_type.dart'; +import 'package:pshared/models/payment/wallet.dart'; +import 'package:pshared/provider/payment/multiple/provider.dart'; +import 'package:pshared/provider/payment/multiple/quotation.dart'; +import 'package:pshared/provider/payment/payments.dart'; +import 'package:pshared/utils/currency.dart'; + +import 'package:pweb/models/multiple_payouts/csv_row.dart'; +import 'package:pweb/models/multiple_payouts/state.dart'; +import 'package:pweb/utils/payment/multiple_csv_parser.dart'; +import 'package:pweb/utils/payment/multiple_intent_builder.dart'; + + +class MultiplePayoutsProvider extends ChangeNotifier { + final MultipleCsvParser _csvParser; + final MultipleIntentBuilder _intentBuilder; + + MultiQuotationProvider? _quotation; + MultiPaymentProvider? _payment; + PaymentsProvider? _payments; + + MultiplePayoutsState _state = MultiplePayoutsState.idle; + String? _selectedFileName; + List _rows = const []; + int _sentCount = 0; + Exception? _error; + + MultiplePayoutsProvider({ + MultipleCsvParser? csvParser, + MultipleIntentBuilder? intentBuilder, + }) : _csvParser = csvParser ?? MultipleCsvParser(), + _intentBuilder = intentBuilder ?? MultipleIntentBuilder(); + + void update( + MultiQuotationProvider quotation, + MultiPaymentProvider payment, + PaymentsProvider payments, + ) { + _bindQuotation(quotation); + _payment = payment; + _payments = payments; + } + + MultiplePayoutsState get state => _state; + String? get selectedFileName => _selectedFileName; + List get rows => List.unmodifiable(_rows); + int get sentCount => _sentCount; + Exception? get error => _error; + + bool get isQuoting => _state == MultiplePayoutsState.quoting; + bool get isSending => _state == MultiplePayoutsState.sending; + bool get isBusy => isQuoting || isSending; + + bool get quoteIsLoading => _quotation?.isLoading ?? false; + + QuoteStatusType get quoteStatusType { + final quotation = _quotation; + if (quotation == null) return QuoteStatusType.missing; + if (quotation.isLoading) return QuoteStatusType.loading; + if (quotation.error != null) return QuoteStatusType.error; + if (quotation.quotation == null) return QuoteStatusType.missing; + if (_isQuoteExpired(quotation.quoteExpiresAt)) return QuoteStatusType.expired; + return QuoteStatusType.active; + } + + Duration? get quoteTimeLeft { + final expiresAt = _quotation?.quoteExpiresAt; + if (expiresAt == null) return null; + return expiresAt.difference(DateTime.now().toUtc()); + } + + bool get canSend { + if (isBusy || _rows.isEmpty) return false; + final quoteRef = _quotation?.quotation?.quoteRef; + return quoteRef != null && quoteRef.isNotEmpty; + } + + Money? aggregateDebitAmountFor(Wallet? sourceWallet) { + if (_rows.isEmpty) return null; + return _moneyForSourceCurrency( + _quotation?.quotation?.aggregate?.debitAmounts, + sourceWallet, + ); + } + + Money? get requestedSentAmount { + if (_rows.isEmpty) return null; + const currency = 'RUB'; + + double total = 0; + for (final row in _rows) { + final value = double.tryParse(row.amount); + if (value == null) return null; + total += value; + } + return Money(amount: amountToString(total), currency: currency); + } + + Money? aggregateSettlementAmountFor(Wallet? sourceWallet) { + if (_rows.isEmpty) return null; + return _moneyForSourceCurrency( + _quotation?.quotation?.aggregate?.expectedSettlementAmounts, + sourceWallet, + ); + } + + Money? aggregateFeeAmountFor(Wallet? sourceWallet) { + if (_rows.isEmpty) return null; + return _moneyForSourceCurrency( + _quotation?.quotation?.aggregate?.expectedFeeTotals, + sourceWallet, + ); + } + + double? aggregateFeePercentFor(Wallet? sourceWallet) { + final debit = aggregateDebitAmountFor(sourceWallet); + final fee = aggregateFeeAmountFor(sourceWallet); + if (debit == null || fee == null) return null; + + final debitValue = double.tryParse(debit.amount); + final feeValue = double.tryParse(fee.amount); + if (debit.currency.toUpperCase() != fee.currency.toUpperCase()) return null; + if (debitValue == null || feeValue == null || debitValue <= 0) return null; + return (feeValue / debitValue) * 100; + } + + Future quoteFromCsv({ + required String fileName, + required String content, + required Wallet sourceWallet, + }) async { + if (isBusy) return; + + final quotation = _quotation; + if (quotation == null) { + _setErrorObject( + StateError('Multiple payouts dependencies are not ready'), + ); + return; + } + + try { + _setState(MultiplePayoutsState.quoting); + _error = null; + _sentCount = 0; + + final rows = _csvParser.parseRows(content); + final intents = _intentBuilder.buildIntents(sourceWallet, rows); + + _selectedFileName = fileName; + _rows = rows; + + await quotation.quotePayments( + intents, + metadata: { + 'upload_filename': fileName, + 'upload_rows': rows.length.toString(), + ...?_uploadAmountMetadata(), + }, + ); + + if (quotation.error != null) { + _setErrorObject(quotation.error!); + } + } catch (e) { + _setErrorObject(e); + } finally { + _setState(MultiplePayoutsState.idle); + } + } + + void setError(Object error) { + _setErrorObject(error); + } + + Future> send() async { + if (isBusy) return const []; + + final payment = _payment; + if (payment == null) { + _setErrorObject( + StateError('Multiple payouts payment provider is not ready'), + ); + return const []; + } + if (!canSend) { + _setErrorObject( + StateError('Upload CSV and wait for quote before sending'), + ); + return const []; + } + + try { + _setState(MultiplePayoutsState.sending); + _error = null; + + final result = await payment.pay( + metadata: { + ...?_selectedFileName == null + ? null + : {'upload_filename': _selectedFileName!}, + 'upload_rows': _rows.length.toString(), + ...?_uploadAmountMetadata(), + }, + ); + + _sentCount = result.length; + return result; + } catch (e) { + _setErrorObject(e); + return const []; + } finally { + _setState(MultiplePayoutsState.idle); + } + } + + Future> sendAndStorePayments() async { + final result = await send(); + _payments?.addPayments(result); + return result; + } + + void removeUploadedFile() { + if (isBusy) return; + + _selectedFileName = null; + _rows = const []; + _sentCount = 0; + _error = null; + notifyListeners(); + } + + void _setState(MultiplePayoutsState value) { + _state = value; + notifyListeners(); + } + + void _setErrorObject(Object error) { + _error = error is Exception ? error : Exception(error.toString()); + notifyListeners(); + } + + void _bindQuotation(MultiQuotationProvider quotation) { + if (identical(_quotation, quotation)) return; + _quotation?.removeListener(_onQuotationChanged); + _quotation = quotation; + _quotation?.addListener(_onQuotationChanged); + } + + void _onQuotationChanged() { + notifyListeners(); + } + + bool _isQuoteExpired(DateTime? expiresAt) { + if (expiresAt == null) return false; + return expiresAt.difference(DateTime.now().toUtc()) <= Duration.zero; + } + + Map? _uploadAmountMetadata() { + final sentAmount = requestedSentAmount; + if (sentAmount == null) return null; + return { + 'upload_amount': sentAmount.amount, + 'upload_currency': sentAmount.currency, + }; + } + + Money? _moneyForSourceCurrency( + List? values, + Wallet? sourceWallet, + ) { + if (values == null || values.isEmpty) return null; + + if (sourceWallet != null) { + final sourceCurrency = currencyCodeToString(sourceWallet.currency); + for (final value in values) { + if (value.currency.toUpperCase() == sourceCurrency.toUpperCase()) { + return value; + } + } + } + + return values.first; + } + + @override + void dispose() { + _quotation?.removeListener(_onQuotationChanged); + super.dispose(); + } +} diff --git a/frontend/pweb/lib/utils/error/handler.dart b/frontend/pweb/lib/utils/error/handler.dart index 2313b4f7..333543e9 100644 --- a/frontend/pweb/lib/utils/error/handler.dart +++ b/frontend/pweb/lib/utils/error/handler.dart @@ -16,6 +16,14 @@ class ErrorHandler { 'unauthorized': locs.errorLoginUnauthorized, 'verification_token_not_found': locs.errorVerificationTokenNotFound, 'internal_error': locs.errorInternalError, + 'invalid_target': locs.errorInvalidTarget, + 'pending_token_required': locs.errorPendingTokenRequired, + 'missing_destination': locs.errorMissingDestination, + 'missing_code': locs.errorMissingCode, + 'missing_session': locs.errorMissingSession, + 'token_expired': locs.errorTokenExpired, + 'code_attempts_exceeded': locs.errorCodeAttemptsExceeded, + 'too_many_requests': locs.errorTooManyRequests, 'data_conflict': locs.errorDataConflict, 'access_denied': locs.errorAccessDenied, diff --git a/frontend/pweb/lib/utils/error/snackbar.dart b/frontend/pweb/lib/utils/error/snackbar.dart index 23df4c86..380fe699 100644 --- a/frontend/pweb/lib/utils/error/snackbar.dart +++ b/frontend/pweb/lib/utils/error/snackbar.dart @@ -15,18 +15,23 @@ Future notifyUserOfErrorX({ int delaySeconds = 3, }) async { if (!context.mounted) return; + if (!_shouldShowError(errorSituation, exception)) { + return; + } final localizedError = ErrorHandler.handleErrorLocs(appLocalizations, exception); final technicalDetails = exception.toString(); final scaffoldMessenger = ScaffoldMessenger.maybeOf(context); if (scaffoldMessenger != null) { + final durationSeconds = _normalizeDelaySeconds(delaySeconds); + scaffoldMessenger.clearSnackBars(); final snackBar = _buildMainErrorSnackBar( errorSituation: errorSituation, localizedError: localizedError, technicalDetails: technicalDetails, loc: appLocalizations, scaffoldMessenger: scaffoldMessenger, - delaySeconds: delaySeconds, + delaySeconds: durationSeconds, ); scaffoldMessenger.showSnackBar(snackBar); return; @@ -46,15 +51,20 @@ void showErrorSnackBar({ required AppLocalizations appLocalizations, int delaySeconds = 3, }) { + if (!_shouldShowError(errorSituation, exception)) { + return; + } final localizedError = ErrorHandler.handleErrorLocs(appLocalizations, exception); final technicalDetails = exception.toString(); + final durationSeconds = _normalizeDelaySeconds(delaySeconds); + scaffoldMessenger.clearSnackBars(); final snackBar = _buildMainErrorSnackBar( errorSituation: errorSituation, localizedError: localizedError, technicalDetails: technicalDetails, loc: appLocalizations, scaffoldMessenger: scaffoldMessenger, - delaySeconds: delaySeconds, + delaySeconds: durationSeconds, ); scaffoldMessenger.showSnackBar(snackBar); } @@ -139,19 +149,22 @@ SnackBar _buildMainErrorSnackBar({ int delaySeconds = 3, }) => SnackBar( - duration: Duration(seconds: delaySeconds), + duration: Duration(seconds: _normalizeDelaySeconds(delaySeconds)), content: ErrorSnackBarContent( situation: errorSituation, localizedError: localizedError, ), action: SnackBarAction( label: loc.showDetailsAction, - onPressed: () => scaffoldMessenger.showSnackBar( - SnackBar( - content: Text(technicalDetails), - duration: const Duration(seconds: 6), - ), - ), + onPressed: () { + scaffoldMessenger.hideCurrentSnackBar(); + scaffoldMessenger.showSnackBar( + SnackBar( + content: Text(technicalDetails), + duration: const Duration(seconds: 6), + ), + ); + }, ), ); @@ -176,3 +189,27 @@ Future _showErrorDialog( ), ); } + +int _normalizeDelaySeconds(int delaySeconds) => + delaySeconds <= 0 ? 3 : delaySeconds; + +String? _lastErrorSignature; +DateTime? _lastErrorShownAt; +const int _errorCooldownSeconds = 60; + +bool _shouldShowError(String errorSituation, Object exception) { + final signature = '$errorSituation|${exception.runtimeType}|${exception.toString()}'; + final now = DateTime.now(); + + if (_lastErrorSignature == signature) { + final lastShownAt = _lastErrorShownAt; + if (lastShownAt != null && + now.difference(lastShownAt).inSeconds < _errorCooldownSeconds) { + return false; + } + } + + _lastErrorSignature = signature; + _lastErrorShownAt = now; + return true; +} diff --git a/frontend/pweb/lib/utils/error_handler.dart b/frontend/pweb/lib/utils/error_handler.dart deleted file mode 100644 index 90625dab..00000000 --- a/frontend/pweb/lib/utils/error_handler.dart +++ /dev/null @@ -1,69 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:pshared/api/responses/error/connectivity.dart'; -import 'package:pshared/api/responses/error/server.dart'; -import 'package:pshared/config/constants.dart'; - -import 'package:pweb/generated/i18n/app_localizations.dart'; -import 'package:pweb/services/accounts.dart'; - - -class ErrorHandler { - /// A mapping of server-side error codes to localized user-friendly messages. - /// Update these keys to match the 'ErrorResponse.Error' field in your Go code. - static Map getErrorMessagesLocs(AppLocalizations locs) { - return { - 'account_not_verified': locs.errorAccountNotVerified, - 'unauthorized': locs.errorLoginUnauthorized, - 'verification_token_not_found': locs.errorVerificationTokenNotFound, - 'internal_error': locs.errorInternalError, - - 'data_conflict': locs.errorDataConflict, - 'access_denied': locs.errorAccessDenied, - 'broken_payload': locs.errorBrokenPayload, - 'invalid_argument': locs.errorInvalidArgument, - 'broken_reference': locs.errorBrokenReference, - 'invalid_query_parameter': locs.errorInvalidQueryParameter, - 'not_implemented': locs.errorNotImplemented, - 'license_required': locs.errorLicenseRequired, - 'not_found': locs.errorNotFound, - 'name_missing': locs.errorNameMissing, - 'email_missing': locs.errorEmailMissing, - 'password_missing': locs.errorPasswordMissing, - 'email_not_registered': locs.errorEmailNotRegistered, - 'duplicate_email': locs.errorDuplicateEmail, - }; - } - - static Map getErrorMessages(BuildContext context) { - return getErrorMessagesLocs(AppLocalizations.of(context)!); - } - - /// Determine which handler to use based on the runtime type of [e]. - /// If no match is found, just return the error’s string representation. - static String handleError(BuildContext context, Object e) { - return handleErrorLocs(AppLocalizations.of(context)!, e); - } - - static String handleErrorLocs(AppLocalizations locs, Object e) { - final errorHandlers = { - ErrorResponse: (ex) => _handleErrorResponseLocs(locs, ex as ErrorResponse), - ConnectivityError: (ex) => _handleConnectivityErrorLocs(locs, ex as ConnectivityError), - InvalidCredentialsException: (_) => locs.errorLoginUnauthorized, - DuplicateAccountException: (_) => locs.errorAccountExists, - }; - - return errorHandlers[e.runtimeType]?.call(e) ?? e.toString(); - } - - static String _handleErrorResponseLocs(AppLocalizations locs, ErrorResponse e) { - final errorMessages = getErrorMessagesLocs(locs); - // Return the localized message if we recognize the error key, else use the raw details - return errorMessages[e.error] ?? e.details; - } - - /// Handler for connectivity issues. - static String _handleConnectivityErrorLocs(AppLocalizations locs, ConnectivityError e) { - return locs.connectivityError(Constants.serviceUrl); - } -} diff --git a/frontend/pweb/lib/utils/notify.dart b/frontend/pweb/lib/utils/notify.dart index 77f69cec..c280a42b 100644 --- a/frontend/pweb/lib/utils/notify.dart +++ b/frontend/pweb/lib/utils/notify.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; import 'package:pweb/utils/snackbar.dart'; -import 'package:pweb/widgets/error/snackbar.dart'; +import 'package:pweb/utils/error/snackbar.dart'; Future invokeAndNotify( diff --git a/frontend/pweb/lib/utils/payment/multiple_csv_parser.dart b/frontend/pweb/lib/utils/payment/multiple_csv_parser.dart index 18e57112..f5c180c0 100644 --- a/frontend/pweb/lib/utils/payment/multiple_csv_parser.dart +++ b/frontend/pweb/lib/utils/payment/multiple_csv_parser.dart @@ -14,8 +14,10 @@ class MultipleCsvParser { throw FormatException('CSV is empty'); } + final delimiter = _detectDelimiter(lines.first); final header = _parseCsvLine( lines.first, + delimiter, ).map((value) => value.trim().toLowerCase()).toList(growable: false); final panIndex = _resolveHeaderIndex(header, const ['pan', 'card_pan']); @@ -27,6 +29,11 @@ class MultipleCsvParser { 'last_name', 'lastname', ]); + final expDateIndex = _resolveHeaderIndex(header, const [ + 'exp_date', + 'expiry', + 'expiry_date', + ]); final expMonthIndex = _resolveHeaderIndex(header, const [ 'exp_month', 'expiry_month', @@ -40,20 +47,21 @@ class MultipleCsvParser { if (panIndex < 0 || firstNameIndex < 0 || lastNameIndex < 0 || - expMonthIndex < 0 || - expYearIndex < 0 || + (expDateIndex < 0 && + (expMonthIndex < 0 || expYearIndex < 0)) || amountIndex < 0) { throw FormatException( - 'CSV header must contain pan, first_name, last_name, exp_month, exp_year, amount columns', + 'CSV header must contain pan, first_name, last_name, amount columns and either exp_date/expiry or exp_month and exp_year', ); } final rows = []; for (var i = 1; i < lines.length; i++) { - final raw = _parseCsvLine(lines[i]); + final raw = _parseCsvLine(lines[i], delimiter); final pan = _cell(raw, panIndex); final firstName = _cell(raw, firstNameIndex); final lastName = _cell(raw, lastNameIndex); + final expDateRaw = expDateIndex >= 0 ? _cell(raw, expDateIndex) : ''; final expMonthRaw = _cell(raw, expMonthIndex); final expYearRaw = _cell(raw, expYearIndex); final amount = _normalizeAmount(_cell(raw, amountIndex)); @@ -78,13 +86,25 @@ class MultipleCsvParser { ); } - final expMonth = int.tryParse(expMonthRaw); - if (expMonth == null || expMonth < 1 || expMonth > 12) { - throw FormatException('CSV row ${i + 1}: exp_month must be 1-12'); - } - final expYear = int.tryParse(expYearRaw); - if (expYear == null || expYear < 0) { - throw FormatException('CSV row ${i + 1}: exp_year is invalid'); + int expMonth; + int expYear; + if (expDateIndex >= 0 && expDateRaw.isNotEmpty) { + final parsed = _parseExpiryDate(expDateRaw, i + 1); + expMonth = parsed.month; + expYear = parsed.year; + } else if (expMonthIndex >= 0 && expYearIndex >= 0) { + final parsedMonth = int.tryParse(expMonthRaw); + if (parsedMonth == null || parsedMonth < 1 || parsedMonth > 12) { + throw FormatException('CSV row ${i + 1}: exp_month must be 1-12'); + } + final parsedYear = int.tryParse(expYearRaw); + if (parsedYear == null || parsedYear < 0) { + throw FormatException('CSV row ${i + 1}: exp_year is invalid'); + } + expMonth = parsedMonth; + expYear = parsedYear; + } else { + throw FormatException('CSV row ${i + 1}: exp_date is required'); } rows.add( @@ -114,7 +134,36 @@ class MultipleCsvParser { return -1; } - List _parseCsvLine(String line) { + String _detectDelimiter(String line) { + final commaCount = _countUnquoted(line, ','); + final semicolonCount = _countUnquoted(line, ';'); + if (semicolonCount > commaCount) return ';'; + return ','; + } + + int _countUnquoted(String line, String needle) { + var count = 0; + var inQuotes = false; + + for (var i = 0; i < line.length; i++) { + final char = line[i]; + if (char == '"') { + final isEscaped = inQuotes && i + 1 < line.length && line[i + 1] == '"'; + if (isEscaped) { + i++; + } else { + inQuotes = !inQuotes; + } + continue; + } + if (char == needle && !inQuotes) { + count++; + } + } + return count; + } + + List _parseCsvLine(String line, String delimiter) { final values = []; final buffer = StringBuffer(); var inQuotes = false; @@ -133,7 +182,7 @@ class MultipleCsvParser { continue; } - if (char == ',' && !inQuotes) { + if (char == delimiter && !inQuotes) { values.add(buffer.toString()); buffer.clear(); continue; @@ -154,4 +203,26 @@ class MultipleCsvParser { String _normalizeAmount(String value) { return value.trim().replaceAll(' ', '').replaceAll(',', '.'); } + + _ExpiryDate _parseExpiryDate(String value, int rowNumber) { + final match = RegExp(r'^\s*(\d{1,2})\s*/\s*(\d{2})\s*$').firstMatch(value); + if (match == null) { + throw FormatException( + 'CSV row $rowNumber: exp_date must be in MM/YY format', + ); + } + final month = int.parse(match.group(1)!); + final year = int.parse(match.group(2)!); + if (month < 1 || month > 12) { + throw FormatException('CSV row $rowNumber: exp_date month must be 1-12'); + } + return _ExpiryDate(month, year); + } +} + +class _ExpiryDate { + final int month; + final int year; + + const _ExpiryDate(this.month, this.year); } diff --git a/frontend/pweb/lib/utils/payment/multiple_intent_builder.dart b/frontend/pweb/lib/utils/payment/multiple_intent_builder.dart index a3c51517..675527d4 100644 --- a/frontend/pweb/lib/utils/payment/multiple_intent_builder.dart +++ b/frontend/pweb/lib/utils/payment/multiple_intent_builder.dart @@ -1,15 +1,14 @@ -import 'package:pshared/controllers/balance_mask/wallets.dart'; import 'package:pshared/models/money.dart'; import 'package:pshared/models/payment/asset.dart'; import 'package:pshared/models/payment/chain_network.dart'; -import 'package:pshared/models/payment/currency_pair.dart'; -import 'package:pshared/models/payment/fx/intent.dart'; -import 'package:pshared/models/payment/fx/side.dart'; import 'package:pshared/models/payment/intent.dart'; import 'package:pshared/models/payment/kind.dart'; import 'package:pshared/models/payment/methods/card.dart'; import 'package:pshared/models/payment/methods/managed_wallet.dart'; import 'package:pshared/models/payment/settlement_mode.dart'; +import 'package:pshared/models/payment/wallet.dart'; +import 'package:pshared/utils/currency.dart'; +import 'package:pshared/utils/payment/fx_helpers.dart'; import 'package:pweb/models/multiple_payouts/csv_row.dart'; @@ -18,14 +17,10 @@ class MultipleIntentBuilder { static const String _currency = 'RUB'; List buildIntents( - WalletsController wallets, + Wallet sourceWallet, List rows, ) { - final sourceWallet = wallets.selectedWallet; - if (sourceWallet == null) { - throw StateError('Select source wallet first'); - } - + final sourceCurrency = currencyCodeToString(sourceWallet.currency); final hasAsset = (sourceWallet.tokenSymbol ?? '').isNotEmpty; final sourceAsset = hasAsset ? PaymentAsset( @@ -34,32 +29,37 @@ class MultipleIntentBuilder { contractAddress: sourceWallet.contractAddress, ) : null; + final fxIntent = FxIntentHelper.buildSellBaseBuyQuote( + baseCurrency: sourceCurrency, + quoteCurrency: _currency, + ); return rows .map( - (row) => PaymentIntent( - kind: PaymentKind.payout, - source: ManagedWalletPaymentMethod( - managedWalletRef: sourceWallet.id, - asset: sourceAsset, - ), - destination: CardPaymentMethod( - pan: row.pan, - firstName: row.firstName, - lastName: row.lastName, - expMonth: row.expMonth, - expYear: row.expYear, - ), - amount: Money(amount: row.amount, currency: _currency), - settlementMode: SettlementMode.fixReceived, - fx : FxIntent( - pair: CurrencyPair( - base: 'USDT', // TODO: fix currencies picking - quote: 'RUB', + (row) { + final amount = Money(amount: row.amount, currency: _currency); + return PaymentIntent( + kind: PaymentKind.payout, + source: ManagedWalletPaymentMethod( + managedWalletRef: sourceWallet.id, + asset: sourceAsset, ), - side: FxSide.sellBaseBuyQuote, - ), - ), + destination: CardPaymentMethod( + pan: row.pan, + firstName: row.firstName, + lastName: row.lastName, + expMonth: row.expMonth, + expYear: row.expYear, + ), + amount: amount, + settlementMode: SettlementMode.fixReceived, + settlementCurrency: FxIntentHelper.resolveSettlementCurrency( + amount: amount, + fx: fxIntent, + ), + fx: fxIntent, + ); + }, ) .toList(growable: false); } diff --git a/frontend/pweb/lib/widgets/error/snackbar.dart b/frontend/pweb/lib/widgets/error/snackbar.dart deleted file mode 100644 index 0344fa49..00000000 --- a/frontend/pweb/lib/widgets/error/snackbar.dart +++ /dev/null @@ -1,172 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; - -import 'package:pweb/utils/error_handler.dart'; -import 'package:pweb/utils/snackbar.dart'; -import 'package:pweb/widgets/error/content.dart'; - -import 'package:pweb/generated/i18n/app_localizations.dart'; - - -Future notifyUserOfErrorX({ - required BuildContext context, - required String errorSituation, - required Object exception, - required AppLocalizations appLocalizations, - int delaySeconds = 3, -}) async { - if (!context.mounted) return; - final localizedError = ErrorHandler.handleErrorLocs(appLocalizations, exception); - final technicalDetails = exception.toString(); - final scaffoldMessenger = ScaffoldMessenger.maybeOf(context); - - if (scaffoldMessenger != null) { - final snackBar = _buildMainErrorSnackBar( - errorSituation: errorSituation, - localizedError: localizedError, - technicalDetails: technicalDetails, - loc: appLocalizations, - scaffoldMessenger: scaffoldMessenger, - delaySeconds: delaySeconds, - ); - scaffoldMessenger.showSnackBar(snackBar); - return; - } - - await _showErrorDialog( - context, - title: errorSituation, - message: localizedError, - ); -} - -Future notifyUserOfError({ - required BuildContext context, - required String errorSituation, - required Object exception, - int delaySeconds = 3, -}) => - notifyUserOfErrorX( - context: context, - errorSituation: errorSituation, - exception: exception, - appLocalizations: AppLocalizations.of(context)!, - delaySeconds: delaySeconds, - ); - -Future executeActionWithNotification({ - required BuildContext context, - required Future Function() action, - required String errorMessage, - String? successMessage, - int delaySeconds = 3, -}) async { - final localizations = AppLocalizations.of(context)!; - - try { - final res = await action(); - if (successMessage != null) { - await notifyUser(context, successMessage, delaySeconds: delaySeconds); - } - return res; - } catch (e) { - await notifyUserOfErrorX( - context: context, - errorSituation: errorMessage, - exception: e, - appLocalizations: localizations, - delaySeconds: delaySeconds, - ); - } - return null; -} - -Future postNotifyUserOfError({ - required BuildContext context, - required String errorSituation, - required Object exception, - required AppLocalizations appLocalizations, - int delaySeconds = 3, -}) { - final completer = Completer(); - - WidgetsBinding.instance.addPostFrameCallback((_) async { - if (!context.mounted) { - completer.complete(); - return; - } - await notifyUserOfErrorX( - context: context, - errorSituation: errorSituation, - exception: exception, - appLocalizations: appLocalizations, - delaySeconds: delaySeconds, - ); - completer.complete(); - }); - - return completer.future; -} - -Future postNotifyUserOfErrorX({ - required BuildContext context, - required String errorSituation, - required Object exception, - int delaySeconds = 3, -}) => - postNotifyUserOfError( - context: context, - errorSituation: errorSituation, - exception: exception, - appLocalizations: AppLocalizations.of(context)!, - delaySeconds: delaySeconds, - ); - - -SnackBar _buildMainErrorSnackBar({ - required String errorSituation, - required String localizedError, - required String technicalDetails, - required AppLocalizations loc, - required ScaffoldMessengerState scaffoldMessenger, - int delaySeconds = 3, -}) => - SnackBar( - duration: Duration(seconds: delaySeconds), - content: ErrorSnackBarContent( - situation: errorSituation, - localizedError: localizedError, - ), - action: SnackBarAction( - label: loc.showDetailsAction, - onPressed: () => scaffoldMessenger.showSnackBar( - SnackBar( - content: Text(technicalDetails), - duration: const Duration(seconds: 6), - ), - ), - ), - ); - -Future _showErrorDialog( - BuildContext context, { - required String title, - required String message, -}) async { - if (!context.mounted) return; - final loc = AppLocalizations.of(context)!; - await showDialog( - context: context, - builder: (dialogContext) => AlertDialog( - title: Text(title), - content: Text(message), - actions: [ - TextButton( - onPressed: () => Navigator.of(dialogContext).pop(), - child: Text(loc.ok), - ), - ], - ), - ); -} diff --git a/frontend/pweb/lib/widgets/password/hint/widget.dart b/frontend/pweb/lib/widgets/password/hint/widget.dart index 473f5709..7c4fd3f2 100644 --- a/frontend/pweb/lib/widgets/password/hint/widget.dart +++ b/frontend/pweb/lib/widgets/password/hint/widget.dart @@ -13,11 +13,8 @@ class PasswordValidationOutput extends StatelessWidget { return Column( children: [ VSpacer(multiplier: 0.25), - ListView( - shrinkWrap: true, - children: children, - ) - ] + ...children, + ], ); } -} \ No newline at end of file +}