multiple payout page and small fixes

This commit is contained in:
Arseni
2026-02-11 02:48:30 +03:00
parent 66989ea36c
commit edb43f9909
77 changed files with 2120 additions and 1289 deletions

View File

@@ -10,12 +10,13 @@ part 'login_pending.g.dart';
class PendingLoginResponse { class PendingLoginResponse {
final AccountResponse account; final AccountResponse account;
final TokenData pendingToken; final TokenData pendingToken;
final String destination; @JsonKey(name: 'destination')
final String target;
const PendingLoginResponse({ const PendingLoginResponse({
required this.account, required this.account,
required this.pendingToken, required this.pendingToken,
required this.destination, required this.target,
}); });
factory PendingLoginResponse.fromJson(Map<String, dynamic> json) => _$PendingLoginResponseFromJson(json); factory PendingLoginResponse.fromJson(Map<String, dynamic> json) => _$PendingLoginResponseFromJson(json);

View File

@@ -9,6 +9,7 @@ part 'payments.g.dart';
@JsonSerializable(explicitToJson: true) @JsonSerializable(explicitToJson: true)
class PaymentsResponse extends CursorPageResponse { class PaymentsResponse extends CursorPageResponse {
@JsonKey(defaultValue: <PaymentDTO>[])
final List<PaymentDTO> payments; final List<PaymentDTO> payments;
const PaymentsResponse({ const PaymentsResponse({

View File

@@ -8,7 +8,7 @@ import 'package:pshared/models/session_identifier.dart';
class PendingLogin { class PendingLogin {
final Account account; final Account account;
final TokenData pendingToken; final TokenData pendingToken;
final String destination; final String target;
final SessionIdentifier session; final SessionIdentifier session;
final int? ttlSeconds; final int? ttlSeconds;
@@ -19,7 +19,7 @@ class PendingLogin {
const PendingLogin({ const PendingLogin({
required this.account, required this.account,
required this.pendingToken, required this.pendingToken,
required this.destination, required this.target,
this.ttlSeconds, this.ttlSeconds,
required this.session, required this.session,
this.cooldownSeconds, this.cooldownSeconds,
@@ -33,14 +33,14 @@ class PendingLogin {
}) => PendingLogin( }) => PendingLogin(
account: response.account.account.toDomain(), account: response.account.account.toDomain(),
pendingToken: response.pendingToken, pendingToken: response.pendingToken,
destination: response.destination, target: response.target,
session: session, session: session,
); );
PendingLogin copyWith({ PendingLogin copyWith({
Account? account, Account? account,
TokenData? pendingToken, TokenData? pendingToken,
String? destination, String? target,
int? ttlSeconds, int? ttlSeconds,
SessionIdentifier? session, SessionIdentifier? session,
int? cooldownSeconds, int? cooldownSeconds,
@@ -51,7 +51,7 @@ class PendingLogin {
return PendingLogin( return PendingLogin(
account: account ?? this.account, account: account ?? this.account,
pendingToken: pendingToken ?? this.pendingToken, pendingToken: pendingToken ?? this.pendingToken,
destination: destination ?? this.destination, target: target ?? this.target,
ttlSeconds: ttlSeconds ?? this.cooldownSeconds, ttlSeconds: ttlSeconds ?? this.cooldownSeconds,
session: session ?? this.session, session: session ?? this.session,
cooldownSeconds: clearCooldown ? null : cooldownSeconds ?? this.cooldownSeconds, cooldownSeconds: clearCooldown ? null : cooldownSeconds ?? this.cooldownSeconds,

View File

@@ -120,12 +120,12 @@ class AccountProvider extends ChangeNotifier {
VerificationResponse confirmation, VerificationResponse confirmation,
) { ) {
final ttlSeconds = confirmation.ttlSeconds != 0 ? confirmation.ttlSeconds : pending.ttlSeconds; 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; final cooldownSeconds = confirmation.cooldownSeconds;
return pending.copyWith( return pending.copyWith(
ttlSeconds: ttlSeconds, ttlSeconds: ttlSeconds,
destination: destination, target: target,
cooldownSeconds: cooldownSeconds > 0 ? cooldownSeconds : null, cooldownSeconds: cooldownSeconds > 0 ? cooldownSeconds : null,
cooldownUntil: cooldownSeconds > 0 ? DateTime.now().add(confirmation.cooldownDuration) : null, cooldownUntil: cooldownSeconds > 0 ? DateTime.now().add(confirmation.cooldownDuration) : null,
clearCooldown: cooldownSeconds <= 0, clearCooldown: cooldownSeconds <= 0,

View File

@@ -13,11 +13,21 @@ class EmailVerificationProvider extends ChangeNotifier {
bool get isLoading => _resource.isLoading; bool get isLoading => _resource.isLoading;
bool get isSuccess => _resource.data == true; bool get isSuccess => _resource.data == true;
Exception? get error => _resource.error; Exception? get error => _resource.error;
int? get errorCode => _resource.error is ErrorResponse ErrorResponse? get errorResponse =>
? (_resource.error as ErrorResponse).code _resource.error is ErrorResponse ? _resource.error as ErrorResponse : null;
: null; bool get canResendVerification {
bool get canResendVerification => final err = errorResponse;
errorCode == 400 || errorCode == 410 || errorCode == 500; if (err == null) return false;
switch (err.error) {
case 'not_found':
case 'token_expired':
case 'data_conflict':
case 'internal_error':
return true;
default:
return false;
}
}
Future<void> verify(String token) async { Future<void> verify(String token) async {
final trimmed = token.trim(); final trimmed = token.trim();
@@ -38,10 +48,6 @@ class EmailVerificationProvider extends ChangeNotifier {
await AccountService.verifyEmail(trimmed); await AccountService.verifyEmail(trimmed);
_setResource(Resource(data: true, isLoading: false)); _setResource(Resource(data: true, isLoading: false));
} catch (e) { } catch (e) {
if (e is ErrorResponse && e.code == 404) {
_setResource(Resource(data: true, isLoading: false));
return;
}
_setResource( _setResource(
Resource(data: null, isLoading: false, error: toException(e)), Resource(data: null, isLoading: false, error: toException(e)),
); );

View File

@@ -12,14 +12,13 @@ class MultiPaymentProvider extends ChangeNotifier {
late OrganizationsProvider _organization; late OrganizationsProvider _organization;
late MultiQuotationProvider _quotation; late MultiQuotationProvider _quotation;
Resource<List<Payment>> _payments = Resource(data: []); Resource<List<Payment>> _payments = Resource(data: null);
bool _isLoaded = false;
List<Payment> get payments => _payments.data ?? []; List<Payment> get payments => _payments.data ?? [];
bool get isLoading => _payments.isLoading; bool get isLoading => _payments.isLoading;
Exception? get error => _payments.error; Exception? get error => _payments.error;
bool get isReady => bool get isReady =>
_isLoaded && !_payments.isLoading && _payments.error == null; _payments.data != null && !_payments.isLoading && _payments.error == null;
void update( void update(
OrganizationsProvider organization, OrganizationsProvider organization,
@@ -56,7 +55,6 @@ class MultiPaymentProvider extends ChangeNotifier {
metadata: metadata, metadata: metadata,
); );
_isLoaded = true;
_setResource( _setResource(
_payments.copyWith(data: response, isLoading: false, error: null), _payments.copyWith(data: response, isLoading: false, error: null),
); );
@@ -70,8 +68,7 @@ class MultiPaymentProvider extends ChangeNotifier {
} }
void reset() { void reset() {
_isLoaded = false; _setResource(Resource(data: null));
_setResource(Resource(data: []));
} }
void _setResource(Resource<List<Payment>> payments) { void _setResource(Resource<List<Payment>> payments) {

View File

@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
@@ -17,11 +19,13 @@ class MultiQuotationProvider extends ChangeNotifier {
String? _loadedOrganizationRef; String? _loadedOrganizationRef;
Resource<PaymentQuotes> _quotation = Resource(data: null); Resource<PaymentQuotes> _quotation = Resource(data: null);
bool _isLoaded = false;
List<PaymentIntent>? _lastIntents; List<PaymentIntent>? _lastIntents;
bool _lastPreviewOnly = false; bool _lastPreviewOnly = false;
Map<String, String>? _lastMetadata; Map<String, String>? _lastMetadata;
Timer? _autoRefreshTimer;
static const Duration _autoRefreshLead = Duration(seconds: 5);
Resource<PaymentQuotes> get resource => _quotation; Resource<PaymentQuotes> get resource => _quotation;
PaymentQuotes? get quotation => _quotation.data; PaymentQuotes? get quotation => _quotation.data;
@@ -29,7 +33,7 @@ class MultiQuotationProvider extends ChangeNotifier {
Exception? get error => _quotation.error; Exception? get error => _quotation.error;
bool get canRefresh => _lastIntents != null && _lastIntents!.isNotEmpty; bool get canRefresh => _lastIntents != null && _lastIntents!.isNotEmpty;
bool get isReady => bool get isReady =>
_isLoaded && !_quotation.isLoading && _quotation.error == null; quotation != null && !_quotation.isLoading && _quotation.error == null;
DateTime? get quoteExpiresAt { DateTime? get quoteExpiresAt {
final quotes = quotation?.quotes; final quotes = quotation?.quotes;
@@ -82,6 +86,7 @@ class MultiQuotationProvider extends ChangeNotifier {
? null ? null
: Map<String, String>.from(metadata); : Map<String, String>.from(metadata);
_cancelAutoRefresh();
_setResource(_quotation.copyWith(isLoading: true, error: null)); _setResource(_quotation.copyWith(isLoading: true, error: null));
try { try {
final response = await MultiplePaymentsService.getQuotation( final response = await MultiplePaymentsService.getQuotation(
@@ -94,10 +99,10 @@ class MultiQuotationProvider extends ChangeNotifier {
), ),
); );
_isLoaded = true;
_setResource( _setResource(
_quotation.copyWith(data: response, isLoading: false, error: null), _quotation.copyWith(data: response, isLoading: false, error: null),
); );
_scheduleAutoRefresh();
} catch (e) { } catch (e) {
_setResource( _setResource(
_quotation.copyWith( _quotation.copyWith(
@@ -123,10 +128,10 @@ class MultiQuotationProvider extends ChangeNotifier {
} }
void reset() { void reset() {
_isLoaded = false;
_lastIntents = null; _lastIntents = null;
_lastPreviewOnly = false; _lastPreviewOnly = false;
_lastMetadata = null; _lastMetadata = null;
_cancelAutoRefresh();
_quotation = Resource(data: null); _quotation = Resource(data: null);
notifyListeners(); notifyListeners();
} }
@@ -135,4 +140,37 @@ class MultiQuotationProvider extends ChangeNotifier {
_quotation = quotation; _quotation = quotation;
notifyListeners(); notifyListeners();
} }
void _scheduleAutoRefresh() {
_autoRefreshTimer?.cancel();
final expiresAt = quoteExpiresAt;
if (expiresAt == null) return;
final now = DateTime.now().toUtc();
var delay = expiresAt.difference(now) - _autoRefreshLead;
if (delay.isNegative) delay = Duration.zero;
_autoRefreshTimer = Timer(delay, _triggerAutoRefresh);
}
Future<void> _triggerAutoRefresh() async {
if (_quotation.isLoading) return;
final intents = _lastIntents;
if (intents == null || intents.isEmpty) return;
await quotePayments(
intents,
previewOnly: _lastPreviewOnly,
metadata: _lastMetadata,
);
}
void _cancelAutoRefresh() {
_autoRefreshTimer?.cancel();
_autoRefreshTimer = null;
}
@override
void dispose() {
_cancelAutoRefresh();
super.dispose();
}
} }

View File

@@ -1,10 +1,7 @@
import 'package:pshared/controllers/balance_mask/wallets.dart'; import 'package:pshared/controllers/balance_mask/wallets.dart';
import 'package:pshared/models/payment/asset.dart'; import 'package:pshared/models/payment/asset.dart';
import 'package:pshared/models/payment/chain_network.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/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/kind.dart';
import 'package:pshared/models/payment/methods/card.dart'; import 'package:pshared/models/payment/methods/card.dart';
import 'package:pshared/models/payment/methods/crypto_address.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/payment/flow.dart';
import 'package:pshared/provider/recipient/provider.dart'; import 'package:pshared/provider/recipient/provider.dart';
import 'package:pshared/utils/currency.dart'; import 'package:pshared/utils/currency.dart';
import 'package:pshared/utils/payment/fx_helpers.dart';
class QuotationIntentBuilder { class QuotationIntentBuilder {
@@ -40,21 +38,18 @@ class QuotationIntentBuilder {
method: selectedMethod, method: selectedMethod,
data: paymentData, data: paymentData,
); );
final sourceCurrency = currencyCodeToString(selectedWallet.currency);
final amount = Money( final amount = Money(
amount: payment.amount.toString(), amount: payment.amount.toString(),
// TODO: adapt to possible other sources // TODO: adapt to possible other sources
currency: currencyCodeToString(selectedWallet.currency), currency: sourceCurrency,
); );
final isCryptoToCrypto = paymentData is CryptoAddressPaymentMethod && final isCryptoToCrypto = paymentData is CryptoAddressPaymentMethod &&
(paymentData.asset?.tokenSymbol ?? '').trim().toUpperCase() == amount.currency; (paymentData.asset?.tokenSymbol ?? '').trim().toUpperCase() == amount.currency;
final fxIntent = isCryptoToCrypto final fxIntent = FxIntentHelper.buildSellBaseBuyQuote(
? null baseCurrency: sourceCurrency,
: FxIntent( quoteCurrency: 'RUB', // TODO: exentd target currencies
pair: CurrencyPair( enabled: !isCryptoToCrypto,
base: currencyCodeToString(selectedWallet.currency),
quote: 'RUB', // TODO: exentd target currencies
),
side: FxSide.sellBaseBuyQuote,
); );
return PaymentIntent( return PaymentIntent(
kind: PaymentKind.payout, kind: PaymentKind.payout,
@@ -69,35 +64,14 @@ class QuotationIntentBuilder {
), ),
fx: fxIntent, fx: fxIntent,
settlementMode: payment.payerCoversFee ? SettlementMode.fixReceived : SettlementMode.fixSource, settlementMode: payment.payerCoversFee ? SettlementMode.fixReceived : SettlementMode.fixSource,
settlementCurrency: _resolveSettlementCurrency(amount: amount, fx: fxIntent), settlementCurrency: FxIntentHelper.resolveSettlementCurrency(
amount: amount,
fx: fxIntent,
),
customer: customer, 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({ Customer? _buildCustomer({
required Recipient? recipient, required Recipient? recipient,
required PaymentMethod? method, required PaymentMethod? method,

View File

@@ -32,13 +32,13 @@ class VerificationService {
return VerificationResponse.fromJson(response); return VerificationResponse.fromJson(response);
} }
static Future<VerificationResponse> resendLoginCode(PendingLogin pending, {String? destination}) async { static Future<VerificationResponse> resendLoginCode(PendingLogin pending, {String? target}) async {
_logger.fine('Resending login confirmation code'); _logger.fine('Resending login confirmation code');
final response = await getPOSTResponse( final response = await getPOSTResponse(
_objectType, _objectType,
'/resend', '/resend',
LoginVerificationRequest( LoginVerificationRequest(
target: destination, target: target,
idempotencyKey: pending.idempotencyKey ?? Uuid().v4(), idempotencyKey: pending.idempotencyKey ?? Uuid().v4(),
).toJson(), ).toJson(),
authToken: pending.pendingToken.token, authToken: pending.pendingToken.token,

View File

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

View File

@@ -3,12 +3,17 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
ScaffoldFeatureController<SnackBar, SnackBarClosedReason> notifyUserX(ScaffoldMessengerState sm, String message, { int delaySeconds = 3 }) ScaffoldFeatureController<SnackBar, SnackBarClosedReason> notifyUserX(
{ ScaffoldMessengerState sm,
String message, {
int delaySeconds = 3,
}) {
final durationSeconds = _normalizeDelaySeconds(delaySeconds);
sm.clearSnackBars();
return sm.showSnackBar( return sm.showSnackBar(
SnackBar( SnackBar(
content: Text(message), content: Text(message),
duration: Duration(seconds: delaySeconds), duration: Duration(seconds: durationSeconds),
), ),
); );
} }
@@ -18,8 +23,10 @@ ScaffoldFeatureController<SnackBar, SnackBarClosedReason> notifyUser(BuildContex
} }
Future<ScaffoldFeatureController<SnackBar, SnackBarClosedReason>> postNotifyUser( Future<ScaffoldFeatureController<SnackBar, SnackBarClosedReason>> postNotifyUser(
BuildContext context, String message, {int delaySeconds = 3}) { BuildContext context,
String message, {
int delaySeconds = 3,
}) {
final completer = Completer<ScaffoldFeatureController<SnackBar, SnackBarClosedReason>>(); final completer = Completer<ScaffoldFeatureController<SnackBar, SnackBarClosedReason>>();
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -29,3 +36,6 @@ Future<ScaffoldFeatureController<SnackBar, SnackBarClosedReason>> postNotifyUser
return completer.future; return completer.future;
} }
int _normalizeDelaySeconds(int delaySeconds) =>
delaySeconds <= 0 ? 3 : delaySeconds;

View File

@@ -12,6 +12,7 @@ import 'package:pshared/provider/payment/amount.dart';
import 'package:pshared/provider/payment/flow.dart'; import 'package:pshared/provider/payment/flow.dart';
import 'package:pshared/provider/payment/multiple/provider.dart'; import 'package:pshared/provider/payment/multiple/provider.dart';
import 'package:pshared/provider/payment/multiple/quotation.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/provider.dart';
import 'package:pshared/provider/payment/quotation/quotation.dart'; import 'package:pshared/provider/payment/quotation/quotation.dart';
import 'package:pshared/provider/recipient/provider.dart'; import 'package:pshared/provider/recipient/provider.dart';
@@ -22,6 +23,7 @@ import 'package:pweb/app/router/pages.dart';
import 'package:pweb/app/router/payout_routes.dart'; import 'package:pweb/app/router/payout_routes.dart';
import 'package:pweb/controllers/multiple_payouts.dart'; import 'package:pweb/controllers/multiple_payouts.dart';
import 'package:pweb/controllers/payment_page.dart'; import 'package:pweb/controllers/payment_page.dart';
import 'package:pweb/providers/multiple_payouts.dart';
import 'package:pweb/providers/quotation/quotation.dart'; import 'package:pweb/providers/quotation/quotation.dart';
import 'package:pshared/models/payment/wallet.dart'; import 'package:pshared/models/payment/wallet.dart';
import 'package:pweb/pages/address_book/form/page.dart'; import 'package:pweb/pages/address_book/form/page.dart';
@@ -34,7 +36,7 @@ import 'package:pweb/pages/report/page.dart';
import 'package:pweb/pages/settings/profile/page.dart'; import 'package:pweb/pages/settings/profile/page.dart';
import 'package:pweb/pages/wallet_top_up/page.dart'; import 'package:pweb/pages/wallet_top_up/page.dart';
import 'package:pweb/widgets/dialogs/confirmation_dialog.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/destinations.dart';
import 'package:pweb/widgets/sidebar/page.dart'; import 'package:pweb/widgets/sidebar/page.dart';
import 'package:pweb/utils/payment/availability.dart'; import 'package:pweb/utils/payment/availability.dart';
@@ -146,15 +148,24 @@ RouteBase payoutShellRoute() => ShellRoute(
provider!..update(organization, quotation), provider!..update(organization, quotation),
), ),
ChangeNotifierProxyProvider3< ChangeNotifierProxyProvider3<
WalletsController,
MultiQuotationProvider, MultiQuotationProvider,
MultiPaymentProvider, MultiPaymentProvider,
PaymentsProvider,
MultiplePayoutsProvider
>(
create: (_) => MultiplePayoutsProvider(),
update: (context, quotation, payment, payments, provider) =>
provider!..update(quotation, payment, payments),
),
ChangeNotifierProxyProvider2<
MultiplePayoutsProvider,
WalletsController,
MultiplePayoutsController MultiplePayoutsController
>( >(
create: (_) => create: (_) =>
MultiplePayoutsController(csvInput: WebCsvInputService()), MultiplePayoutsController(csvInput: WebCsvInputService()),
update: (context, wallets, quotation, payment, provider) => update: (context, provider, wallets, controller) =>
provider!..update(wallets, quotation, payment), controller!..update(provider, wallets),
), ),
], ],
child: PageSelector(child: child, routerState: state), child: PageSelector(child: child, routerState: state),

View File

@@ -3,242 +3,133 @@ import 'package:flutter/foundation.dart';
import 'package:pshared/controllers/balance_mask/wallets.dart'; import 'package:pshared/controllers/balance_mask/wallets.dart';
import 'package:pshared/models/money.dart'; import 'package:pshared/models/money.dart';
import 'package:pshared/models/payment/payment.dart'; import 'package:pshared/models/payment/payment.dart';
import 'package:pshared/provider/payment/multiple/provider.dart'; import 'package:pshared/models/payment/quote/status_type.dart';
import 'package:pshared/provider/payment/multiple/quotation.dart'; import 'package:pshared/models/payment/wallet.dart';
import 'package:pshared/utils/currency.dart';
import 'package:pweb/models/multiple_payouts/csv_row.dart'; import 'package:pweb/models/multiple_payouts/csv_row.dart';
import 'package:pweb/models/multiple_payouts/state.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/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 { class MultiplePayoutsController extends ChangeNotifier {
final CsvInputService _csvInput; final CsvInputService _csvInput;
final MultipleCsvParser _csvParser; MultiplePayoutsProvider? _provider;
final MultipleIntentBuilder _intentBuilder;
WalletsController? _wallets; WalletsController? _wallets;
MultiQuotationProvider? _quotation; _PickState _pickState = _PickState.idle;
MultiPaymentProvider? _payment;
MultiplePayoutsState _state = MultiplePayoutsState.idle;
String? _selectedFileName;
List<CsvPayoutRow> _rows = const <CsvPayoutRow>[];
int _sentCount = 0;
Exception? _error;
MultiplePayoutsController({ MultiplePayoutsController({
required CsvInputService csvInput, required CsvInputService csvInput,
MultipleCsvParser? csvParser, }) : _csvInput = csvInput;
MultipleIntentBuilder? intentBuilder,
}) : _csvInput = csvInput,
_csvParser = csvParser ?? MultipleCsvParser(),
_intentBuilder = intentBuilder ?? MultipleIntentBuilder();
void update( void update(MultiplePayoutsProvider provider, WalletsController wallets) {
WalletsController wallets, var shouldNotify = false;
MultiQuotationProvider quotation, if (!identical(_provider, provider)) {
MultiPaymentProvider payment, _provider?.removeListener(_onProviderChanged);
) { _provider = provider;
_provider?.addListener(_onProviderChanged);
shouldNotify = true;
}
if (!identical(_wallets, wallets)) {
_wallets?.removeListener(_onWalletsChanged);
_wallets = wallets; _wallets = wallets;
_quotation = quotation; _wallets?.addListener(_onWalletsChanged);
_payment = payment; shouldNotify = true;
}
if (shouldNotify) {
notifyListeners();
}
} }
MultiplePayoutsState get state => _state; MultiplePayoutsState get state =>
String? get selectedFileName => _selectedFileName; _provider?.state ?? MultiplePayoutsState.idle;
List<CsvPayoutRow> get rows => List.unmodifiable(_rows); String? get selectedFileName => _provider?.selectedFileName;
int get sentCount => _sentCount; List<CsvPayoutRow> get rows => _provider?.rows ?? const <CsvPayoutRow>[];
Exception? get error => _error; int get sentCount => _provider?.sentCount ?? 0;
Exception? get error => _provider?.error;
bool get isQuoting => _state == MultiplePayoutsState.quoting; bool get isQuoting => _provider?.isQuoting ?? false;
bool get isSending => _state == MultiplePayoutsState.sending; bool get isSending => _provider?.isSending ?? false;
bool get isBusy => isQuoting || isSending; bool get isBusy => _provider?.isBusy ?? false;
bool get canSend { bool get quoteIsLoading => _provider?.quoteIsLoading ?? false;
if (isBusy || _rows.isEmpty) return false; QuoteStatusType get quoteStatusType =>
final quoteRef = _quotation?.quotation?.quoteRef; _provider?.quoteStatusType ?? QuoteStatusType.missing;
return quoteRef != null && quoteRef.isNotEmpty; Duration? get quoteTimeLeft => _provider?.quoteTimeLeft;
}
Money? get aggregateDebitAmount { bool get canSend => _provider?.canSend ?? false;
if (_rows.isEmpty) return null; Money? get aggregateDebitAmount =>
return _moneyForSourceCurrency( _provider?.aggregateDebitAmountFor(_selectedWallet);
_quotation?.quotation?.aggregate?.debitAmounts, Money? get requestedSentAmount => _provider?.requestedSentAmount;
); Money? get aggregateSettlementAmount =>
} _provider?.aggregateSettlementAmountFor(_selectedWallet);
Money? get aggregateFeeAmount =>
Money? get requestedSentAmount { _provider?.aggregateFeeAmountFor(_selectedWallet);
if (_rows.isEmpty) return null; double? get aggregateFeePercent =>
const currency = 'RUB'; _provider?.aggregateFeePercentFor(_selectedWallet);
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? get aggregateSettlementAmount {
if (_rows.isEmpty) return null;
return _moneyForSourceCurrency(
_quotation?.quotation?.aggregate?.expectedSettlementAmounts,
);
}
Money? get aggregateFeeAmount {
if (_rows.isEmpty) return null;
return _moneyForSourceCurrency(
_quotation?.quotation?.aggregate?.expectedFeeTotals,
);
}
double? get aggregateFeePercent {
final debit = aggregateDebitAmount;
final fee = aggregateFeeAmount;
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<void> pickAndQuote() async { Future<void> pickAndQuote() async {
if (isBusy) return; if (_pickState == _PickState.picking) return;
final provider = _provider;
final wallets = _wallets; if (provider == null) return;
final quotation = _quotation;
if (wallets == null || quotation == null) {
_setErrorObject(
StateError('Multiple payouts dependencies are not ready'),
);
return;
}
_pickState = _PickState.picking;
try { try {
_setState(MultiplePayoutsState.quoting);
_error = null;
_sentCount = 0;
final picked = await _csvInput.pickCsv(); 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; return;
} }
await provider.quoteFromCsv(
final rows = _csvParser.parseRows(picked.content); fileName: picked.name,
final intents = _intentBuilder.buildIntents(wallets, rows); content: picked.content,
sourceWallet: wallet,
_selectedFileName = picked.name;
_rows = rows;
await quotation.quotePayments(
intents,
metadata: <String, String>{
'upload_filename': picked.name,
'upload_rows': rows.length.toString(),
...?_uploadAmountMetadata(),
},
); );
if (quotation.error != null) {
_setErrorObject(quotation.error!);
}
} catch (e) { } catch (e) {
_setErrorObject(e); provider.setError(e);
} finally { } finally {
_setState(MultiplePayoutsState.idle); _pickState = _PickState.idle;
} }
} }
Future<List<Payment>> send() async { Future<List<Payment>> send() async {
if (isBusy) return const <Payment>[]; return _provider?.send() ?? const <Payment>[];
final payment = _payment;
if (payment == null) {
_setErrorObject(
StateError('Multiple payouts payment provider is not ready'),
);
return const <Payment>[];
}
if (!canSend) {
_setErrorObject(
StateError('Upload CSV and wait for quote before sending'),
);
return const <Payment>[];
} }
try { Future<MultiplePayoutSendOutcome> sendAndStorePayments() async {
_setState(MultiplePayoutsState.sending); final payments =
_error = null; await _provider?.sendAndStorePayments() ?? const <Payment>[];
final hasError = _provider?.error != null;
final result = await payment.pay( if (hasError || payments.isEmpty) {
metadata: <String, String>{ return MultiplePayoutSendOutcome.failure;
...?_selectedFileName == null
? null
: <String, String>{'upload_filename': _selectedFileName!},
'upload_rows': _rows.length.toString(),
...?_uploadAmountMetadata(),
},
);
_sentCount = result.length;
return result;
} catch (e) {
_setErrorObject(e);
return const <Payment>[];
} finally {
_setState(MultiplePayoutsState.idle);
} }
return MultiplePayoutSendOutcome.success;
} }
void removeUploadedFile() { void removeUploadedFile() {
if (isBusy) return; _provider?.removeUploadedFile();
}
_selectedFileName = null; void _onProviderChanged() {
_rows = const <CsvPayoutRow>[];
_sentCount = 0;
_error = null;
notifyListeners(); notifyListeners();
} }
void _setState(MultiplePayoutsState value) { void _onWalletsChanged() {
_state = value;
notifyListeners(); notifyListeners();
} }
void _setErrorObject(Object error) { Wallet? get _selectedWallet => _wallets?.selectedWallet;
_error = error is Exception ? error : Exception(error.toString());
notifyListeners();
}
Map<String, String>? _uploadAmountMetadata() { @override
final sentAmount = requestedSentAmount; void dispose() {
if (sentAmount == null) return null; _provider?.removeListener(_onProviderChanged);
return <String, String>{ _wallets?.removeListener(_onWalletsChanged);
'upload_amount': sentAmount.amount, super.dispose();
'upload_currency': sentAmount.currency,
};
}
Money? _moneyForSourceCurrency(List<Money>? 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;
} }
} }
enum _PickState { idle, picking }
enum MultiplePayoutSendOutcome { success, failure }

View File

@@ -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 '-';
}
}

View File

@@ -71,6 +71,14 @@
"errorAccountExists": "Account with this login already exists", "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", "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", "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", "created": "Created",
"edited": "Edited", "edited": "Edited",
"errorDataConflict": "This action conflicts with existing data. Check for duplicates or conflicting values and try again.", "errorDataConflict": "This action conflicts with existing data. Check for duplicates or conflicting values and try again.",
@@ -503,12 +511,19 @@
}, },
"tokenColumn": "Token (required)", "tokenColumn": "Token (required)",
"currency": "Currency", "currency": "Currency",
"amount": "Amount", "amount": "Amount",
"comment": "Comment", "comment": "Comment",
"uploadCSV": "Upload your CSV", "uploadCSV": "Upload your CSV",
"upload": "Upload", "upload": "Upload",
"hintUpload": "Supported format: .CSV · Max size 1 MB", "hintUpload": "Supported format: .CSV · Max size 1 MB",
"uploadHistory": "Upload History", "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", "payout": "Payout",
"sendTo": "Send Payout To", "sendTo": "Send Payout To",
"send": "Send Payout", "send": "Send Payout",

View File

@@ -71,6 +71,14 @@
"errorAccountExists": "Аккаунт с таким логином уже существует", "errorAccountExists": "Аккаунт с таким логином уже существует",
"errorInternalError": "Произошла внутренняя ошибка. Мы в курсе проблемы и работаем над ее решением. Пожалуйста, попробуйте позже", "errorInternalError": "Произошла внутренняя ошибка. Мы в курсе проблемы и работаем над ее решением. Пожалуйста, попробуйте позже",
"errorVerificationTokenNotFound": "Аккаунт для верификации не найден. Зарегистрируйтесь снова", "errorVerificationTokenNotFound": "Аккаунт для верификации не найден. Зарегистрируйтесь снова",
"errorInvalidTarget": "Неверная цель верификации. Попробуйте еще раз.",
"errorPendingTokenRequired": "Требуется дополнительная проверка. Повторите вход.",
"errorMissingDestination": "Укажите адрес для верификации.",
"errorMissingCode": "Введите код подтверждения.",
"errorMissingSession": "Отсутствуют данные сессии. Повторите вход.",
"errorTokenExpired": "Срок действия кода истек. Запросите новый.",
"errorCodeAttemptsExceeded": "Слишком много неверных попыток. Запросите новый код.",
"errorTooManyRequests": "Слишком много запросов. Подождите и попробуйте снова.",
"created": "Создано", "created": "Создано",
"edited": "Изменено", "edited": "Изменено",
"errorDataConflict": "Действие конфликтует с уже существующими данными. Проверьте дубликаты или противоречащие значения и попробуйте снова.", "errorDataConflict": "Действие конфликтует с уже существующими данными. Проверьте дубликаты или противоречащие значения и попробуйте снова.",
@@ -503,12 +511,19 @@
}, },
"tokenColumn": "Токен (обязательно)", "tokenColumn": "Токен (обязательно)",
"currency": "Валюта", "currency": "Валюта",
"amount": "Сумма", "amount": "Сумма",
"comment": "Комментарий", "comment": "Комментарий",
"uploadCSV": "Загрузите ваш CSV", "uploadCSV": "Загрузите ваш CSV",
"upload": "Загрузить", "upload": "Загрузить",
"hintUpload": "Поддерживаемый формат: .CSV · Макс. размер 1 МБ", "hintUpload": "Поддерживаемый формат: .CSV · Макс. размер 1 МБ",
"uploadHistory": "История загрузок", "uploadHistory": "История загрузок",
"viewWholeHistory": "Смотреть всю историю",
"paymentStatusSuccessful": "Платеж успешен",
"paymentStatusProcessing": "В обработке",
"paymentStatusReserved": "Средства зарезервированы",
"paymentStatusFailed": "Платеж неуспешен",
"paymentStatusCancelled": "Платеж отменен",
"paymentStatusPending": "В ожидании",
"payout": "Выплата", "payout": "Выплата",
"sendTo": "Отправить выплату", "sendTo": "Отправить выплату",
"send": "Отправить выплату", "send": "Отправить выплату",

View File

@@ -0,0 +1 @@
enum DashboardPayoutMode { single, multiple }

View File

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

View File

@@ -13,7 +13,7 @@ class TwoFactorPromptText extends StatelessWidget {
@override @override
Widget build(BuildContext context) => Text( Widget build(BuildContext context) => Text(
AppLocalizations.of(context)!.twoFactorPrompt( AppLocalizations.of(context)!.twoFactorPrompt(
context.watch<TwoFactorProvider>().pendingLogin?.destination ?? '', context.watch<TwoFactorProvider>().pendingLogin?.target ?? '',
), ),
style: Theme.of(context).textTheme.bodyLarge, style: Theme.of(context).textTheme.bodyLarge,
textAlign: TextAlign.center, textAlign: TextAlign.center,

View File

@@ -6,10 +6,11 @@ import 'package:pshared/models/payment/type.dart';
import 'package:pshared/models/recipient/recipient.dart'; import 'package:pshared/models/recipient/recipient.dart';
import 'package:pshared/models/payment/wallet.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/balance.dart';
import 'package:pweb/pages/dashboard/buttons/balance/controller.dart'; import 'package:pweb/pages/dashboard/buttons/balance/controller.dart';
import 'package:pweb/pages/dashboard/buttons/buttons.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/multiple/widget.dart';
import 'package:pweb/pages/dashboard/payouts/single/widget.dart'; import 'package:pweb/pages/dashboard/payouts/single/widget.dart';
import 'package:pweb/pages/loader.dart'; import 'package:pweb/pages/loader.dart';
@@ -42,19 +43,19 @@ class DashboardPage extends StatefulWidget {
} }
class _DashboardPageState extends State<DashboardPage> { class _DashboardPageState extends State<DashboardPage> {
bool _showContainerSingle = true; DashboardPayoutMode _payoutMode = DashboardPayoutMode.single;
bool _showContainerMultiple = false;
void _setActive(bool single) { void _setActive(DashboardPayoutMode mode) {
setState(() { setState(() {
_showContainerSingle = single; _payoutMode = mode;
_showContainerMultiple = !single;
}); });
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final showSingle = _payoutMode == DashboardPayoutMode.single;
final showMultiple = _payoutMode == DashboardPayoutMode.multiple;
return PageViewLoader( return PageViewLoader(
child: SafeArea( child: SafeArea(
child: SingleChildScrollView( child: SingleChildScrollView(
@@ -66,8 +67,8 @@ class _DashboardPageState extends State<DashboardPage> {
Expanded( Expanded(
flex: 0, flex: 0,
child: TransactionRefButton( child: TransactionRefButton(
onTap: () => _setActive(true), onTap: () => _setActive(DashboardPayoutMode.single),
isActive: _showContainerSingle, isActive: showSingle,
label: l10n.sendSingle, label: l10n.sendSingle,
icon: Icons.person_add, icon: Icons.person_add,
), ),
@@ -76,8 +77,8 @@ class _DashboardPageState extends State<DashboardPage> {
Expanded( Expanded(
flex: 0, flex: 0,
child: TransactionRefButton( child: TransactionRefButton(
onTap: () => _setActive(false), onTap: () => _setActive(DashboardPayoutMode.multiple),
isActive: _showContainerMultiple, isActive: showMultiple,
label: l10n.sendMultiple, label: l10n.sendMultiple,
icon: Icons.group_add, icon: Icons.group_add,
), ),
@@ -93,14 +94,14 @@ class _DashboardPageState extends State<DashboardPage> {
), ),
), ),
const SizedBox(height: AppSpacing.small), const SizedBox(height: AppSpacing.small),
if (_showContainerMultiple) TitleMultiplePayout(), if (showMultiple) TitleMultiplePayout(),
const SizedBox(height: AppSpacing.medium), const SizedBox(height: AppSpacing.medium),
if (_showContainerSingle) if (showSingle)
SinglePayoutForm( SinglePayoutForm(
onRecipientSelected: widget.onRecipientSelected, onRecipientSelected: widget.onRecipientSelected,
onGoToPayment: widget.onGoToPaymentWithoutRecipient, onGoToPayment: widget.onGoToPaymentWithoutRecipient,
), ),
if (_showContainerMultiple) MultiplePayoutForm(), if (showMultiple) MultiplePayoutForm(),
], ],
), ),
), ),

View File

@@ -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<MultiplePayoutsController>();
final walletsController = context.watch<WalletsController>();
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,
),
),
],
);
},
),
),
],
);
}
}

View File

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

View File

@@ -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<PaymentsProvider>();
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(),
),
],
);
}
}

View File

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

View File

@@ -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)}%)';
}

View File

@@ -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<String>(
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<String>(
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);
},
);
}
}

View File

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

View File

@@ -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),
],
),
);
}
}

View File

@@ -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),
),
],
);
}
}

View File

@@ -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,
),
),
],
],
),
),
);
}
}

View File

@@ -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<void> handleUploadSend(
BuildContext context,
MultiplePayoutsController controller,
) async {
final outcome = await controller.sendAndStorePayments();
if (!context.mounted) return;
await showPaymentStatusDialog(
context,
isSuccess: outcome == MultiplePayoutSendOutcome.success,
);
controller.removeUploadedFile();
}

View File

@@ -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,
),
),
],
);
}
}

View File

@@ -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,
),
),
],
],
);
}
}

View File

@@ -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),
],
);
}
}

View File

@@ -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<MultiplePayoutRow> 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<void> _downloadSampleCsv() async {
final rows = <String>[
'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',
),
);
}
}

View File

@@ -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),
],
);
}
}

View File

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

View File

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

View File

@@ -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<Payment> 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(),
);
}
}

View File

@@ -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<PaymentsProvider>();
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),
// ),
// ),
// ],
],
],
);
}
}

View File

@@ -0,0 +1,42 @@
import 'package:pweb/models/multiple_payouts/csv_row.dart';
const String sampleFileName = 'sample.csv';
final List<CsvPayoutRow> 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 = <String>[
'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');
}

View File

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

View File

@@ -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),
],
);
}
}

View File

@@ -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<CsvPayoutRow> 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(),
);
}
}

View File

@@ -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<void> _downloadSampleCsv() async {
await downloadFile(
DownloadedFile(
bytes: utf8.encode(buildSampleCsvContent()),
filename: sampleFileName,
mimeType: 'text/csv;charset=utf-8',
),
);
}
}

View File

@@ -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,
),
),
],
);
}
}

View File

@@ -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,
),
),
],
),
);
},
);
}
}

View File

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

View File

@@ -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<MultiplePayoutsController>();
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,
),
],
);
}
}

View File

@@ -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<String>(
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<String>(
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)})';
}
}

View File

@@ -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<MultiplePayoutsController>()
.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<MultiplePayoutsController>()
.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<void> _handleSend(BuildContext context) async {
final paymentsProvider = context.read<PaymentsProvider>();
final result = await controller.send();
paymentsProvider.addPayments(result);
await paymentsProvider.refresh();
if (!context.mounted) return;
final isSuccess = controller.error == null && result.isNotEmpty;
await showDialog<void>(
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();
}
}

View File

@@ -1,8 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:pweb/pages/dashboard/payouts/multiple/csv.dart'; import 'package:pweb/pages/dashboard/payouts/multiple/sections/upload_csv/widget.dart';
import 'package:pweb/pages/dashboard/payouts/multiple/history.dart'; import 'package:pweb/pages/dashboard/payouts/multiple/sections/history/widget.dart';
import 'package:pweb/pages/dashboard/payouts/multiple/sample.dart'; import 'package:pweb/pages/dashboard/payouts/multiple/sections/sample/widget.dart';
class MultiplePayoutForm extends StatelessWidget { class MultiplePayoutForm extends StatelessWidget {

View File

@@ -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: () {},
);
}
}

View File

@@ -7,18 +7,21 @@ import 'package:pshared/utils/currency.dart';
class PaymentSummaryRow extends StatelessWidget { class PaymentSummaryRow extends StatelessWidget {
final String Function(String) labelFactory; final String Function(String) labelFactory;
final Asset? asset; final Asset? asset;
final String? value;
final TextStyle? style; final TextStyle? style;
const PaymentSummaryRow({ const PaymentSummaryRow({
super.key, super.key,
required this.labelFactory, required this.labelFactory,
required this.asset, required this.asset,
this.value,
this.style, this.style,
}); });
@override @override
Widget build(BuildContext context) => Text( Widget build(BuildContext context) {
labelFactory(asset == null ? 'N/A' : assetToString(asset!)), final formatted = value ??
style: style, (asset == null ? 'N/A' : assetToString(asset!));
); return Text(labelFactory(formatted), style: style);
}
} }

View File

@@ -5,24 +5,80 @@ import 'package:provider/provider.dart';
import 'package:pshared/controllers/balance_mask/wallets.dart'; import 'package:pshared/controllers/balance_mask/wallets.dart';
import 'package:pshared/utils/currency.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/fee.dart';
import 'package:pweb/pages/dashboard/payouts/summary/recipient_receives.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/sent_amount.dart';
import 'package:pweb/pages/dashboard/payouts/summary/total.dart'; import 'package:pweb/pages/dashboard/payouts/summary/total.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class PaymentSummary extends StatelessWidget { class PaymentSummary extends StatelessWidget {
final double spacing; final double spacing;
final PaymentSummaryValues? values;
const PaymentSummary({super.key, required this.spacing}); const PaymentSummary({
super.key,
required this.spacing,
this.values,
});
@override @override
Widget build(BuildContext context) => Align( 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, alignment: Alignment.center,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
PaymentSentAmountRow(currency: currencyStringToCode(context.read<WalletsController>().selectedWallet?.tokenSymbol ?? 'USDT')), 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<WalletsController>().selectedWallet?.tokenSymbol ??
'USDT',
),
),
const PaymentFeeRow(), const PaymentFeeRow(),
const PaymentRecipientReceivesRow(), const PaymentRecipientReceivesRow(),
SizedBox(height: spacing), SizedBox(height: spacing),
@@ -30,4 +86,5 @@ class PaymentSummary extends StatelessWidget {
], ],
), ),
); );
}
} }

View File

@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:pweb/app/router/pages.dart'; import 'package:pweb/app/router/pages.dart';
import 'package:pweb/widgets/vspacer.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'; import 'package:pweb/generated/i18n/app_localizations.dart';

View File

@@ -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/form/form.dart';
import 'package:pweb/pages/invitations/widgets/list/list.dart'; import 'package:pweb/pages/invitations/widgets/list/list.dart';
import 'package:pweb/pages/loader.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/widgets/roles/create_role_dialog.dart';
import 'package:pweb/generated/i18n/app_localizations.dart'; import 'package:pweb/generated/i18n/app_localizations.dart';

View File

@@ -5,7 +5,7 @@ import 'package:provider/provider.dart';
import 'package:pshared/models/invitation/invitation.dart'; import 'package:pshared/models/invitation/invitation.dart';
import 'package:pshared/provider/invitations.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'; import 'package:pweb/generated/i18n/app_localizations.dart';

View File

@@ -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/body.dart';
import 'package:pweb/pages/invitations/widgets/list/view_model.dart'; import 'package:pweb/pages/invitations/widgets/list/view_model.dart';
import 'package:pweb/pages/invitations/widgets/search_field.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'; import 'package:pweb/generated/i18n/app_localizations.dart';

View File

@@ -6,7 +6,7 @@ import 'package:pshared/models/auth/state.dart';
import 'package:pshared/provider/account.dart'; import 'package:pshared/provider/account.dart';
import 'package:pweb/app/router/pages.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'; import 'package:pweb/generated/i18n/app_localizations.dart';

View File

@@ -15,7 +15,7 @@ import 'package:pweb/widgets/password/hint/short.dart';
import 'package:pweb/widgets/password/password.dart'; import 'package:pweb/widgets/password/password.dart';
import 'package:pweb/widgets/username.dart'; import 'package:pweb/widgets/username.dart';
import 'package:pweb/widgets/vspacer.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/services/posthog.dart';
import 'package:pweb/generated/i18n/app_localizations.dart'; import 'package:pweb/generated/i18n/app_localizations.dart';

View File

@@ -7,7 +7,7 @@ import 'package:provider/provider.dart';
import 'package:pshared/provider/account.dart'; import 'package:pshared/provider/account.dart';
import 'package:pweb/utils/snackbar.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/widgets/vspacer.dart';
import 'package:pweb/generated/i18n/app_localizations.dart'; import 'package:pweb/generated/i18n/app_localizations.dart';

View File

@@ -30,6 +30,7 @@ class SignUpFormContent extends StatelessWidget {
constraints: BoxConstraints(maxWidth: maxWidth), constraints: BoxConstraints(maxWidth: maxWidth),
child: Card( child: Card(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min,
children: [ children: [
Row( Row(
children: [ children: [

View File

@@ -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/controllers.dart';
import 'package:pweb/pages/signup/form/form.dart'; import 'package:pweb/pages/signup/form/form.dart';
import 'package:pweb/pages/signup/confirmation/args.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'; import 'package:pweb/generated/i18n/app_localizations.dart';

View File

@@ -11,7 +11,7 @@ import 'package:pweb/pages/with_footer.dart';
import 'package:pweb/pages/verification/controller.dart'; import 'package:pweb/pages/verification/controller.dart';
import 'package:pweb/pages/verification/resend_dialog.dart'; import 'package:pweb/pages/verification/resend_dialog.dart';
import 'package:pweb/utils/snackbar.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'; import 'package:pweb/generated/i18n/app_localizations.dart';

View File

@@ -16,20 +16,20 @@ class PageWithFooter extends StatelessWidget {
@override @override
Widget build(BuildContext context) => Scaffold( Widget build(BuildContext context) => Scaffold(
appBar: appBar, appBar: appBar,
body: LayoutBuilder( body: CustomScrollView(
builder: (context, constraints) => SingleChildScrollView( slivers: [
child: ConstrainedBox( SliverFillRemaining(
constraints: BoxConstraints(minHeight: constraints.maxHeight), hasScrollBody: false,
child: IntrinsicHeight(
child: Column( child: Column(
children: [ children: [
Expanded(child: child), Expanded(
child: Center(child: child),
),
FooterWidget(), FooterWidget(),
], ],
), ),
), ),
), ],
),
), ),
); );
} }

View File

@@ -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<CsvPayoutRow> _rows = const <CsvPayoutRow>[];
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<CsvPayoutRow> 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<void> 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: <String, String>{
'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<List<Payment>> send() async {
if (isBusy) return const <Payment>[];
final payment = _payment;
if (payment == null) {
_setErrorObject(
StateError('Multiple payouts payment provider is not ready'),
);
return const <Payment>[];
}
if (!canSend) {
_setErrorObject(
StateError('Upload CSV and wait for quote before sending'),
);
return const <Payment>[];
}
try {
_setState(MultiplePayoutsState.sending);
_error = null;
final result = await payment.pay(
metadata: <String, String>{
...?_selectedFileName == null
? null
: <String, String>{'upload_filename': _selectedFileName!},
'upload_rows': _rows.length.toString(),
...?_uploadAmountMetadata(),
},
);
_sentCount = result.length;
return result;
} catch (e) {
_setErrorObject(e);
return const <Payment>[];
} finally {
_setState(MultiplePayoutsState.idle);
}
}
Future<List<Payment>> sendAndStorePayments() async {
final result = await send();
_payments?.addPayments(result);
return result;
}
void removeUploadedFile() {
if (isBusy) return;
_selectedFileName = null;
_rows = const <CsvPayoutRow>[];
_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<String, String>? _uploadAmountMetadata() {
final sentAmount = requestedSentAmount;
if (sentAmount == null) return null;
return <String, String>{
'upload_amount': sentAmount.amount,
'upload_currency': sentAmount.currency,
};
}
Money? _moneyForSourceCurrency(
List<Money>? 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();
}
}

View File

@@ -16,6 +16,14 @@ class ErrorHandler {
'unauthorized': locs.errorLoginUnauthorized, 'unauthorized': locs.errorLoginUnauthorized,
'verification_token_not_found': locs.errorVerificationTokenNotFound, 'verification_token_not_found': locs.errorVerificationTokenNotFound,
'internal_error': locs.errorInternalError, '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, 'data_conflict': locs.errorDataConflict,
'access_denied': locs.errorAccessDenied, 'access_denied': locs.errorAccessDenied,

View File

@@ -15,18 +15,23 @@ Future<void> notifyUserOfErrorX({
int delaySeconds = 3, int delaySeconds = 3,
}) async { }) async {
if (!context.mounted) return; if (!context.mounted) return;
if (!_shouldShowError(errorSituation, exception)) {
return;
}
final localizedError = ErrorHandler.handleErrorLocs(appLocalizations, exception); final localizedError = ErrorHandler.handleErrorLocs(appLocalizations, exception);
final technicalDetails = exception.toString(); final technicalDetails = exception.toString();
final scaffoldMessenger = ScaffoldMessenger.maybeOf(context); final scaffoldMessenger = ScaffoldMessenger.maybeOf(context);
if (scaffoldMessenger != null) { if (scaffoldMessenger != null) {
final durationSeconds = _normalizeDelaySeconds(delaySeconds);
scaffoldMessenger.clearSnackBars();
final snackBar = _buildMainErrorSnackBar( final snackBar = _buildMainErrorSnackBar(
errorSituation: errorSituation, errorSituation: errorSituation,
localizedError: localizedError, localizedError: localizedError,
technicalDetails: technicalDetails, technicalDetails: technicalDetails,
loc: appLocalizations, loc: appLocalizations,
scaffoldMessenger: scaffoldMessenger, scaffoldMessenger: scaffoldMessenger,
delaySeconds: delaySeconds, delaySeconds: durationSeconds,
); );
scaffoldMessenger.showSnackBar(snackBar); scaffoldMessenger.showSnackBar(snackBar);
return; return;
@@ -46,15 +51,20 @@ void showErrorSnackBar({
required AppLocalizations appLocalizations, required AppLocalizations appLocalizations,
int delaySeconds = 3, int delaySeconds = 3,
}) { }) {
if (!_shouldShowError(errorSituation, exception)) {
return;
}
final localizedError = ErrorHandler.handleErrorLocs(appLocalizations, exception); final localizedError = ErrorHandler.handleErrorLocs(appLocalizations, exception);
final technicalDetails = exception.toString(); final technicalDetails = exception.toString();
final durationSeconds = _normalizeDelaySeconds(delaySeconds);
scaffoldMessenger.clearSnackBars();
final snackBar = _buildMainErrorSnackBar( final snackBar = _buildMainErrorSnackBar(
errorSituation: errorSituation, errorSituation: errorSituation,
localizedError: localizedError, localizedError: localizedError,
technicalDetails: technicalDetails, technicalDetails: technicalDetails,
loc: appLocalizations, loc: appLocalizations,
scaffoldMessenger: scaffoldMessenger, scaffoldMessenger: scaffoldMessenger,
delaySeconds: delaySeconds, delaySeconds: durationSeconds,
); );
scaffoldMessenger.showSnackBar(snackBar); scaffoldMessenger.showSnackBar(snackBar);
} }
@@ -139,19 +149,22 @@ SnackBar _buildMainErrorSnackBar({
int delaySeconds = 3, int delaySeconds = 3,
}) => }) =>
SnackBar( SnackBar(
duration: Duration(seconds: delaySeconds), duration: Duration(seconds: _normalizeDelaySeconds(delaySeconds)),
content: ErrorSnackBarContent( content: ErrorSnackBarContent(
situation: errorSituation, situation: errorSituation,
localizedError: localizedError, localizedError: localizedError,
), ),
action: SnackBarAction( action: SnackBarAction(
label: loc.showDetailsAction, label: loc.showDetailsAction,
onPressed: () => scaffoldMessenger.showSnackBar( onPressed: () {
scaffoldMessenger.hideCurrentSnackBar();
scaffoldMessenger.showSnackBar(
SnackBar( SnackBar(
content: Text(technicalDetails), content: Text(technicalDetails),
duration: const Duration(seconds: 6), duration: const Duration(seconds: 6),
), ),
), );
},
), ),
); );
@@ -176,3 +189,27 @@ Future<void> _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;
}

View File

@@ -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<String, String> 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<String, String> 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 errors string representation.
static String handleError(BuildContext context, Object e) {
return handleErrorLocs(AppLocalizations.of(context)!, e);
}
static String handleErrorLocs(AppLocalizations locs, Object e) {
final errorHandlers = <Type, String Function(Object)>{
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);
}
}

View File

@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:pweb/generated/i18n/app_localizations.dart'; import 'package:pweb/generated/i18n/app_localizations.dart';
import 'package:pweb/utils/snackbar.dart'; import 'package:pweb/utils/snackbar.dart';
import 'package:pweb/widgets/error/snackbar.dart'; import 'package:pweb/utils/error/snackbar.dart';
Future<void> invokeAndNotify<T>( Future<void> invokeAndNotify<T>(

View File

@@ -14,8 +14,10 @@ class MultipleCsvParser {
throw FormatException('CSV is empty'); throw FormatException('CSV is empty');
} }
final delimiter = _detectDelimiter(lines.first);
final header = _parseCsvLine( final header = _parseCsvLine(
lines.first, lines.first,
delimiter,
).map((value) => value.trim().toLowerCase()).toList(growable: false); ).map((value) => value.trim().toLowerCase()).toList(growable: false);
final panIndex = _resolveHeaderIndex(header, const ['pan', 'card_pan']); final panIndex = _resolveHeaderIndex(header, const ['pan', 'card_pan']);
@@ -27,6 +29,11 @@ class MultipleCsvParser {
'last_name', 'last_name',
'lastname', 'lastname',
]); ]);
final expDateIndex = _resolveHeaderIndex(header, const [
'exp_date',
'expiry',
'expiry_date',
]);
final expMonthIndex = _resolveHeaderIndex(header, const [ final expMonthIndex = _resolveHeaderIndex(header, const [
'exp_month', 'exp_month',
'expiry_month', 'expiry_month',
@@ -40,20 +47,21 @@ class MultipleCsvParser {
if (panIndex < 0 || if (panIndex < 0 ||
firstNameIndex < 0 || firstNameIndex < 0 ||
lastNameIndex < 0 || lastNameIndex < 0 ||
expMonthIndex < 0 || (expDateIndex < 0 &&
expYearIndex < 0 || (expMonthIndex < 0 || expYearIndex < 0)) ||
amountIndex < 0) { amountIndex < 0) {
throw FormatException( 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 = <CsvPayoutRow>[]; final rows = <CsvPayoutRow>[];
for (var i = 1; i < lines.length; i++) { 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 pan = _cell(raw, panIndex);
final firstName = _cell(raw, firstNameIndex); final firstName = _cell(raw, firstNameIndex);
final lastName = _cell(raw, lastNameIndex); final lastName = _cell(raw, lastNameIndex);
final expDateRaw = expDateIndex >= 0 ? _cell(raw, expDateIndex) : '';
final expMonthRaw = _cell(raw, expMonthIndex); final expMonthRaw = _cell(raw, expMonthIndex);
final expYearRaw = _cell(raw, expYearIndex); final expYearRaw = _cell(raw, expYearIndex);
final amount = _normalizeAmount(_cell(raw, amountIndex)); final amount = _normalizeAmount(_cell(raw, amountIndex));
@@ -78,14 +86,26 @@ class MultipleCsvParser {
); );
} }
final expMonth = int.tryParse(expMonthRaw); int expMonth;
if (expMonth == null || expMonth < 1 || expMonth > 12) { 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'); throw FormatException('CSV row ${i + 1}: exp_month must be 1-12');
} }
final expYear = int.tryParse(expYearRaw); final parsedYear = int.tryParse(expYearRaw);
if (expYear == null || expYear < 0) { if (parsedYear == null || parsedYear < 0) {
throw FormatException('CSV row ${i + 1}: exp_year is invalid'); 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( rows.add(
CsvPayoutRow( CsvPayoutRow(
@@ -114,7 +134,36 @@ class MultipleCsvParser {
return -1; return -1;
} }
List<String> _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<String> _parseCsvLine(String line, String delimiter) {
final values = <String>[]; final values = <String>[];
final buffer = StringBuffer(); final buffer = StringBuffer();
var inQuotes = false; var inQuotes = false;
@@ -133,7 +182,7 @@ class MultipleCsvParser {
continue; continue;
} }
if (char == ',' && !inQuotes) { if (char == delimiter && !inQuotes) {
values.add(buffer.toString()); values.add(buffer.toString());
buffer.clear(); buffer.clear();
continue; continue;
@@ -154,4 +203,26 @@ class MultipleCsvParser {
String _normalizeAmount(String value) { String _normalizeAmount(String value) {
return value.trim().replaceAll(' ', '').replaceAll(',', '.'); 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);
} }

View File

@@ -1,15 +1,14 @@
import 'package:pshared/controllers/balance_mask/wallets.dart';
import 'package:pshared/models/money.dart'; import 'package:pshared/models/money.dart';
import 'package:pshared/models/payment/asset.dart'; import 'package:pshared/models/payment/asset.dart';
import 'package:pshared/models/payment/chain_network.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/intent.dart';
import 'package:pshared/models/payment/kind.dart'; import 'package:pshared/models/payment/kind.dart';
import 'package:pshared/models/payment/methods/card.dart'; import 'package:pshared/models/payment/methods/card.dart';
import 'package:pshared/models/payment/methods/managed_wallet.dart'; import 'package:pshared/models/payment/methods/managed_wallet.dart';
import 'package:pshared/models/payment/settlement_mode.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'; import 'package:pweb/models/multiple_payouts/csv_row.dart';
@@ -18,14 +17,10 @@ class MultipleIntentBuilder {
static const String _currency = 'RUB'; static const String _currency = 'RUB';
List<PaymentIntent> buildIntents( List<PaymentIntent> buildIntents(
WalletsController wallets, Wallet sourceWallet,
List<CsvPayoutRow> rows, List<CsvPayoutRow> rows,
) { ) {
final sourceWallet = wallets.selectedWallet; final sourceCurrency = currencyCodeToString(sourceWallet.currency);
if (sourceWallet == null) {
throw StateError('Select source wallet first');
}
final hasAsset = (sourceWallet.tokenSymbol ?? '').isNotEmpty; final hasAsset = (sourceWallet.tokenSymbol ?? '').isNotEmpty;
final sourceAsset = hasAsset final sourceAsset = hasAsset
? PaymentAsset( ? PaymentAsset(
@@ -34,10 +29,16 @@ class MultipleIntentBuilder {
contractAddress: sourceWallet.contractAddress, contractAddress: sourceWallet.contractAddress,
) )
: null; : null;
final fxIntent = FxIntentHelper.buildSellBaseBuyQuote(
baseCurrency: sourceCurrency,
quoteCurrency: _currency,
);
return rows return rows
.map( .map(
(row) => PaymentIntent( (row) {
final amount = Money(amount: row.amount, currency: _currency);
return PaymentIntent(
kind: PaymentKind.payout, kind: PaymentKind.payout,
source: ManagedWalletPaymentMethod( source: ManagedWalletPaymentMethod(
managedWalletRef: sourceWallet.id, managedWalletRef: sourceWallet.id,
@@ -50,16 +51,15 @@ class MultipleIntentBuilder {
expMonth: row.expMonth, expMonth: row.expMonth,
expYear: row.expYear, expYear: row.expYear,
), ),
amount: Money(amount: row.amount, currency: _currency), amount: amount,
settlementMode: SettlementMode.fixReceived, settlementMode: SettlementMode.fixReceived,
fx : FxIntent( settlementCurrency: FxIntentHelper.resolveSettlementCurrency(
pair: CurrencyPair( amount: amount,
base: 'USDT', // TODO: fix currencies picking fx: fxIntent,
quote: 'RUB',
),
side: FxSide.sellBaseBuyQuote,
),
), ),
fx: fxIntent,
);
},
) )
.toList(growable: false); .toList(growable: false);
} }

View File

@@ -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<void> 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<void> 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<T?> executeActionWithNotification<T>({
required BuildContext context,
required Future<T> 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<void> postNotifyUserOfError({
required BuildContext context,
required String errorSituation,
required Object exception,
required AppLocalizations appLocalizations,
int delaySeconds = 3,
}) {
final completer = Completer<void>();
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<void> 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<void> _showErrorDialog(
BuildContext context, {
required String title,
required String message,
}) async {
if (!context.mounted) return;
final loc = AppLocalizations.of(context)!;
await showDialog<void>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(title),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: Text(loc.ok),
),
],
),
);
}

View File

@@ -13,11 +13,8 @@ class PasswordValidationOutput extends StatelessWidget {
return Column( return Column(
children: [ children: [
VSpacer(multiplier: 0.25), VSpacer(multiplier: 0.25),
ListView( ...children,
shrinkWrap: true, ],
children: children,
)
]
); );
} }
} }