multiple payout page and small fixes #464
@@ -11,10 +11,10 @@ import (
|
|||||||
type pendingLoginResponse struct {
|
type pendingLoginResponse struct {
|
||||||
Account accountResponse `json:"account"`
|
Account accountResponse `json:"account"`
|
||||||
PendingToken TokenData `json:"pendingToken"`
|
PendingToken TokenData `json:"pendingToken"`
|
||||||
Destination string `json:"destination"`
|
Target string `json:"target"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func LoginPending(logger mlogger.Logger, account *model.Account, pendingToken *TokenData, destination string) http.HandlerFunc {
|
func LoginPending(logger mlogger.Logger, account *model.Account, pendingToken *TokenData, target string) http.HandlerFunc {
|
||||||
return response.Accepted(
|
return response.Accepted(
|
||||||
logger,
|
logger,
|
||||||
&pendingLoginResponse{
|
&pendingLoginResponse{
|
||||||
@@ -23,7 +23,7 @@ func LoginPending(logger mlogger.Logger, account *model.Account, pendingToken *T
|
|||||||
authResponse: authResponse{},
|
authResponse: authResponse{},
|
||||||
},
|
},
|
||||||
PendingToken: *pendingToken,
|
PendingToken: *pendingToken,
|
||||||
Destination: destination,
|
Target: target,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,12 +10,12 @@ part 'login_pending.g.dart';
|
|||||||
class PendingLoginResponse {
|
class PendingLoginResponse {
|
||||||
final AccountResponse account;
|
final AccountResponse account;
|
||||||
final TokenData pendingToken;
|
final TokenData pendingToken;
|
||||||
final String 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);
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ 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;
|
||||||
|
tech
commented
есть же хелпер toException? есть же хелпер toException?
protuberanets
commented
toException тут не помогает, он же приводит Object к Exception, но не к ErrorResponse toException тут не помогает, он же приводит Object к Exception, но не к ErrorResponse
|
|||||||
: null;
|
int? get errorCode => errorResponse?.code;
|
||||||
bool get canResendVerification =>
|
bool get canResendVerification =>
|
||||||
errorCode == 400 || errorCode == 410 || errorCode == 500;
|
errorCode == 400 || errorCode == 410 || errorCode == 500;
|
||||||
|
|
||||||
|
tech
commented
а на код ошибки не надежнее полагаться? а на код ошибки не надежнее полагаться?
|
|||||||
@@ -38,10 +38,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)),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
|
|
||||||
|
import 'package:share_plus/share_plus.dart';
|
||||||
|
|
||||||
import 'package:pshared/config/constants.dart';
|
import 'package:pshared/config/constants.dart';
|
||||||
import 'package:pshared/models/organization/organization.dart';
|
import 'package:pshared/models/organization/organization.dart';
|
||||||
import 'package:pshared/provider/resource.dart';
|
import 'package:pshared/provider/resource.dart';
|
||||||
@@ -88,4 +90,55 @@ class OrganizationsProvider extends ChangeNotifier {
|
|||||||
// Best-effort cleanup of stored selection to avoid using stale org on next login.
|
// Best-effort cleanup of stored selection to avoid using stale org on next login.
|
||||||
await SecureStorageService.delete(Constants.currentOrgKey);
|
await SecureStorageService.delete(Constants.currentOrgKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Organization> uploadLogo(XFile logoFile) async {
|
||||||
|
if (!isOrganizationSet) {
|
||||||
|
throw StateError('Organization is not set');
|
||||||
|
}
|
||||||
|
|
||||||
|
_setResource(_resource.copyWith(isLoading: true, error: null));
|
||||||
|
try {
|
||||||
|
final updated = await OrganizationService.uploadLogoAndUpdate(current, logoFile);
|
||||||
|
final updatedList = organizations
|
||||||
|
.map((org) => org.id == updated.id ? updated : org)
|
||||||
|
.toList(growable: false);
|
||||||
|
_setResource(Resource(data: updatedList, isLoading: false));
|
||||||
|
_currentOrg = updated.id;
|
||||||
|
return updated;
|
||||||
|
} catch (e) {
|
||||||
|
_setResource(_resource.copyWith(isLoading: false, error: toException(e)));
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Organization> updateCurrent({
|
||||||
|
String? name,
|
||||||
|
String? description,
|
||||||
|
String? timeZone,
|
||||||
|
String? logoUrl,
|
||||||
|
}) async {
|
||||||
|
if (!isOrganizationSet) {
|
||||||
|
throw StateError('Organization is not set');
|
||||||
|
}
|
||||||
|
|
||||||
|
_setResource(_resource.copyWith(isLoading: true, error: null));
|
||||||
|
try {
|
||||||
|
final updated = await OrganizationService.updateSettings(
|
||||||
|
current,
|
||||||
|
name: name,
|
||||||
|
description: description,
|
||||||
|
timeZone: timeZone,
|
||||||
|
logoUrl: logoUrl,
|
||||||
|
);
|
||||||
|
final updatedList = organizations
|
||||||
|
.map((org) => org.id == updated.id ? updated : org)
|
||||||
|
.toList(growable: false);
|
||||||
|
_setResource(Resource(data: updatedList, isLoading: false));
|
||||||
|
_currentOrg = updated.id;
|
||||||
|
return updated;
|
||||||
|
} catch (e) {
|
||||||
|
_setResource(_resource.copyWith(isLoading: false, error: toException(e)));
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ 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;
|
||||||
@@ -29,7 +28,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;
|
||||||
@@ -94,7 +93,6 @@ class MultiQuotationProvider extends ChangeNotifier {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
_isLoaded = true;
|
|
||||||
_setResource(
|
_setResource(
|
||||||
_quotation.copyWith(data: response, isLoading: false, error: null),
|
_quotation.copyWith(data: response, isLoading: false, error: null),
|
||||||
);
|
);
|
||||||
@@ -123,7 +121,6 @@ class MultiQuotationProvider extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void reset() {
|
void reset() {
|
||||||
_isLoaded = false;
|
|
||||||
_lastIntents = null;
|
_lastIntents = null;
|
||||||
_lastPreviewOnly = false;
|
_lastPreviewOnly = false;
|
||||||
_lastMetadata = null;
|
_lastMetadata = null;
|
||||||
@@ -135,4 +132,9 @@ class MultiQuotationProvider extends ChangeNotifier {
|
|||||||
_quotation = quotation;
|
_quotation = quotation;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
tech
commented
лучше все-таки разделить уже провайдер и контроллер. провайдер - про данные, контроллер - GUI-специфичная тема с автообновлением. А то сложно. лучше все-таки разделить уже провайдер и контроллер. провайдер - про данные, контроллер - GUI-специфичная тема с автообновлением. А то сложно.
|
|||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,22 +38,19 @@ 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,
|
||||||
amount: amount,
|
amount: amount,
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
47
frontend/pshared/lib/utils/payment/fx_helpers.dart
Normal file
47
frontend/pshared/lib/utils/payment/fx_helpers.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,8 @@ 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/controllers/multi_quotation.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 +37,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';
|
||||||
@@ -42,6 +45,7 @@ import 'package:pweb/services/payments/csv_input.dart';
|
|||||||
|
|
||||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
|
||||||
RouteBase payoutShellRoute() => ShellRoute(
|
RouteBase payoutShellRoute() => ShellRoute(
|
||||||
builder: (context, state, child) => MultiProvider(
|
builder: (context, state, child) => MultiProvider(
|
||||||
providers: [
|
providers: [
|
||||||
@@ -136,6 +140,13 @@ RouteBase payoutShellRoute() => ShellRoute(
|
|||||||
update: (context, organization, provider) =>
|
update: (context, organization, provider) =>
|
||||||
provider!..update(organization),
|
provider!..update(organization),
|
||||||
),
|
),
|
||||||
|
ChangeNotifierProxyProvider<
|
||||||
|
MultiQuotationProvider,
|
||||||
|
MultiQuotationController
|
||||||
|
>(
|
||||||
|
create: (_) => MultiQuotationController(),
|
||||||
|
update: (_, quotation, controller) => controller!..update(quotation),
|
||||||
|
),
|
||||||
ChangeNotifierProxyProvider2<
|
ChangeNotifierProxyProvider2<
|
||||||
OrganizationsProvider,
|
OrganizationsProvider,
|
||||||
MultiQuotationProvider,
|
MultiQuotationProvider,
|
||||||
@@ -146,15 +157,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),
|
||||||
|
|||||||
70
frontend/pweb/lib/controllers/multi_quotation.dart
Normal file
70
frontend/pweb/lib/controllers/multi_quotation.dart
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import 'package:pshared/provider/payment/multiple/quotation.dart';
|
||||||
|
|
||||||
|
import 'package:pweb/providers/quotation/auto_refresh.dart';
|
||||||
|
|
||||||
|
|
||||||
|
class MultiQuotationController extends ChangeNotifier {
|
||||||
|
static const Duration _autoRefreshLead = Duration(seconds: 5);
|
||||||
|
|
||||||
|
MultiQuotationProvider? _quotation;
|
||||||
|
final QuotationAutoRefreshController _autoRefreshController =
|
||||||
|
QuotationAutoRefreshController();
|
||||||
|
|
||||||
|
void update(MultiQuotationProvider quotation) {
|
||||||
|
if (identical(_quotation, quotation)) return;
|
||||||
|
_quotation?.removeListener(_handleQuotationChanged);
|
||||||
|
_quotation = quotation;
|
||||||
|
_quotation?.addListener(_handleQuotationChanged);
|
||||||
|
_handleQuotationChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get isLoading => _quotation?.isLoading ?? false;
|
||||||
|
Exception? get error => _quotation?.error;
|
||||||
|
bool get canRefresh => _quotation?.canRefresh ?? false;
|
||||||
|
bool get isReady => _quotation?.isReady ?? false;
|
||||||
|
|
||||||
|
DateTime? get quoteExpiresAt => _quotation?.quoteExpiresAt;
|
||||||
|
|
||||||
|
void refreshQuotation() {
|
||||||
|
_quotation?.refreshQuotation();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleQuotationChanged() {
|
||||||
|
_syncAutoRefresh();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _syncAutoRefresh() {
|
||||||
|
final quotation = _quotation;
|
||||||
|
if (quotation == null) {
|
||||||
|
_autoRefreshController.reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final expiresAt = quoteExpiresAt;
|
||||||
|
final scheduledAt = expiresAt == null
|
||||||
|
? null
|
||||||
|
: expiresAt.subtract(_autoRefreshLead);
|
||||||
|
|
||||||
|
_autoRefreshController.setEnabled(true);
|
||||||
|
_autoRefreshController.sync(
|
||||||
|
isLoading: quotation.isLoading,
|
||||||
|
canRefresh: quotation.canRefresh,
|
||||||
|
expiresAt: scheduledAt,
|
||||||
|
onRefresh: _refreshQuotation,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _refreshQuotation() async {
|
||||||
|
await _quotation?.refreshQuotation();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_quotation?.removeListener(_handleQuotationChanged);
|
||||||
|
_autoRefreshController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
_wallets = wallets;
|
_provider?.addListener(_onProviderChanged);
|
||||||
_quotation = quotation;
|
shouldNotify = true;
|
||||||
_payment = payment;
|
}
|
||||||
}
|
if (!identical(_wallets, wallets)) {
|
||||||
|
_wallets?.removeListener(_onWalletsChanged);
|
||||||
MultiplePayoutsState get state => _state;
|
_wallets = wallets;
|
||||||
String? get selectedFileName => _selectedFileName;
|
_wallets?.addListener(_onWalletsChanged);
|
||||||
List<CsvPayoutRow> get rows => List.unmodifiable(_rows);
|
shouldNotify = true;
|
||||||
int get sentCount => _sentCount;
|
}
|
||||||
Exception? get error => _error;
|
if (shouldNotify) {
|
||||||
|
notifyListeners();
|
||||||
bool get isQuoting => _state == MultiplePayoutsState.quoting;
|
|
||||||
bool get isSending => _state == MultiplePayoutsState.sending;
|
|
||||||
bool get isBusy => isQuoting || isSending;
|
|
||||||
|
|
||||||
bool get canSend {
|
|
||||||
if (isBusy || _rows.isEmpty) return false;
|
|
||||||
final quoteRef = _quotation?.quotation?.quoteRef;
|
|
||||||
return quoteRef != null && quoteRef.isNotEmpty;
|
|
||||||
}
|
|
||||||
|
|
||||||
Money? get aggregateDebitAmount {
|
|
||||||
if (_rows.isEmpty) return null;
|
|
||||||
return _moneyForSourceCurrency(
|
|
||||||
_quotation?.quotation?.aggregate?.debitAmounts,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Money? get requestedSentAmount {
|
|
||||||
if (_rows.isEmpty) return null;
|
|
||||||
const currency = 'RUB';
|
|
||||||
|
|
||||||
double total = 0;
|
|
||||||
for (final row in _rows) {
|
|
||||||
final value = double.tryParse(row.amount);
|
|
||||||
if (value == null) return null;
|
|
||||||
total += value;
|
|
||||||
}
|
}
|
||||||
return Money(amount: amountToString(total), currency: currency);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Money? get aggregateSettlementAmount {
|
MultiplePayoutsState get state =>
|
||||||
if (_rows.isEmpty) return null;
|
_provider?.state ?? MultiplePayoutsState.idle;
|
||||||
return _moneyForSourceCurrency(
|
String? get selectedFileName => _provider?.selectedFileName;
|
||||||
_quotation?.quotation?.aggregate?.expectedSettlementAmounts,
|
List<CsvPayoutRow> get rows => _provider?.rows ?? const <CsvPayoutRow>[];
|
||||||
);
|
int get sentCount => _provider?.sentCount ?? 0;
|
||||||
}
|
Exception? get error => _provider?.error;
|
||||||
|
|
||||||
Money? get aggregateFeeAmount {
|
bool get isQuoting => _provider?.isQuoting ?? false;
|
||||||
if (_rows.isEmpty) return null;
|
bool get isSending => _provider?.isSending ?? false;
|
||||||
return _moneyForSourceCurrency(
|
bool get isBusy => _provider?.isBusy ?? false;
|
||||||
_quotation?.quotation?.aggregate?.expectedFeeTotals,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
double? get aggregateFeePercent {
|
bool get quoteIsLoading => _provider?.quoteIsLoading ?? false;
|
||||||
final debit = aggregateDebitAmount;
|
QuoteStatusType get quoteStatusType =>
|
||||||
final fee = aggregateFeeAmount;
|
_provider?.quoteStatusType ?? QuoteStatusType.missing;
|
||||||
if (debit == null || fee == null) return null;
|
Duration? get quoteTimeLeft => _provider?.quoteTimeLeft;
|
||||||
|
|
||||||
final debitValue = double.tryParse(debit.amount);
|
bool get canSend => _provider?.canSend ?? false;
|
||||||
final feeValue = double.tryParse(fee.amount);
|
Money? get aggregateDebitAmount =>
|
||||||
if (debit.currency.toUpperCase() != fee.currency.toUpperCase()) return null;
|
_provider?.aggregateDebitAmountFor(_selectedWallet);
|
||||||
if (debitValue == null || feeValue == null || debitValue <= 0) return null;
|
Money? get requestedSentAmount => _provider?.requestedSentAmount;
|
||||||
return (feeValue / debitValue) * 100;
|
Money? get aggregateSettlementAmount =>
|
||||||
}
|
_provider?.aggregateSettlementAmountFor(_selectedWallet);
|
||||||
|
Money? get aggregateFeeAmount =>
|
||||||
|
_provider?.aggregateFeeAmountFor(_selectedWallet);
|
||||||
|
double? get aggregateFeePercent =>
|
||||||
|
_provider?.aggregateFeePercentFor(_selectedWallet);
|
||||||
|
|
||||||
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;
|
Future<MultiplePayoutSendOutcome> sendAndStorePayments() async {
|
||||||
if (payment == null) {
|
final payments =
|
||||||
_setErrorObject(
|
await _provider?.sendAndStorePayments() ?? const <Payment>[];
|
||||||
StateError('Multiple payouts payment provider is not ready'),
|
final hasError = _provider?.error != null;
|
||||||
);
|
if (hasError || payments.isEmpty) {
|
||||||
return const <Payment>[];
|
return MultiplePayoutSendOutcome.failure;
|
||||||
}
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
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 }
|
||||||
|
|||||||
20
frontend/pweb/lib/controllers/upload_history_table.dart
Normal file
20
frontend/pweb/lib/controllers/upload_history_table.dart
Normal 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 '-';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "Отправить выплату",
|
||||||
|
|||||||
1
frontend/pweb/lib/models/dashboard_payment_mode.dart
Normal file
1
frontend/pweb/lib/models/dashboard_payment_mode.dart
Normal file
@@ -0,0 +1 @@
|
|||||||
|
enum DashboardPayoutMode { single, multiple }
|
||||||
13
frontend/pweb/lib/models/summary_values.dart
Normal file
13
frontend/pweb/lib/models/summary_values.dart
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)}%)';
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
// ),
|
||||||
|
// ),
|
||||||
|
// ],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)})';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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: () {},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,29 +5,86 @@ 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) {
|
||||||
alignment: Alignment.center,
|
final resolvedValues = values;
|
||||||
child: Column(
|
if (resolvedValues != null) {
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
final theme = Theme.of(context);
|
||||||
children: [
|
final loc = AppLocalizations.of(context)!;
|
||||||
PaymentSentAmountRow(currency: currencyStringToCode(context.read<WalletsController>().selectedWallet?.tokenSymbol ?? 'USDT')),
|
return Align(
|
||||||
const PaymentFeeRow(),
|
alignment: Alignment.center,
|
||||||
const PaymentRecipientReceivesRow(),
|
child: Column(
|
||||||
SizedBox(height: spacing),
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
const PaymentTotalRow(),
|
children: [
|
||||||
],
|
PaymentSummaryRow(
|
||||||
),
|
labelFactory: loc.sentAmount,
|
||||||
);
|
asset: null,
|
||||||
}
|
value: resolvedValues.sentAmount,
|
||||||
|
style: theme.textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
PaymentSummaryRow(
|
||||||
|
labelFactory: loc.fee,
|
||||||
|
asset: null,
|
||||||
|
value: resolvedValues.fee,
|
||||||
|
style: theme.textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
PaymentSummaryRow(
|
||||||
|
labelFactory: loc.recipientWillReceive,
|
||||||
|
asset: null,
|
||||||
|
value: resolvedValues.recipientReceives,
|
||||||
|
style: theme.textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
SizedBox(height: spacing),
|
||||||
|
PaymentSummaryRow(
|
||||||
|
labelFactory: loc.total,
|
||||||
|
asset: null,
|
||||||
|
value: resolvedValues.total,
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Align(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
PaymentSentAmountRow(
|
||||||
|
currency: currencyStringToCode(
|
||||||
|
context.read<WalletsController>().selectedWallet?.tokenSymbol ??
|
||||||
|
'USDT',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const PaymentFeeRow(),
|
||||||
|
const PaymentRecipientReceivesRow(),
|
||||||
|
SizedBox(height: spacing),
|
||||||
|
const PaymentTotalRow(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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: [
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -13,4 +13,4 @@ class SignUpPage extends StatelessWidget {
|
|||||||
appBar: const LoginAppBar(),
|
appBar: const LoginAppBar(),
|
||||||
child: SignUpForm(),
|
child: SignUpForm(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
Expanded(child: child),
|
child: Center(child: child),
|
||||||
FooterWidget(),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
FooterWidget(),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
295
frontend/pweb/lib/providers/multiple_payouts.dart
Normal file
295
frontend/pweb/lib/providers/multiple_payouts.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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: () {
|
||||||
SnackBar(
|
scaffoldMessenger.hideCurrentSnackBar();
|
||||||
content: Text(technicalDetails),
|
scaffoldMessenger.showSnackBar(
|
||||||
duration: const Duration(seconds: 6),
|
SnackBar(
|
||||||
),
|
content: Text(technicalDetails),
|
||||||
),
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 error’s string representation.
|
|
||||||
static String handleError(BuildContext context, Object e) {
|
|
||||||
return handleErrorLocs(AppLocalizations.of(context)!, e);
|
|
||||||
}
|
|
||||||
|
|
||||||
static String handleErrorLocs(AppLocalizations locs, Object e) {
|
|
||||||
final errorHandlers = <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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>(
|
||||||
|
|||||||
@@ -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,13 +86,25 @@ class MultipleCsvParser {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final expMonth = int.tryParse(expMonthRaw);
|
int expMonth;
|
||||||
if (expMonth == null || expMonth < 1 || expMonth > 12) {
|
int expYear;
|
||||||
throw FormatException('CSV row ${i + 1}: exp_month must be 1-12');
|
if (expDateIndex >= 0 && expDateRaw.isNotEmpty) {
|
||||||
}
|
final parsed = _parseExpiryDate(expDateRaw, i + 1);
|
||||||
final expYear = int.tryParse(expYearRaw);
|
expMonth = parsed.month;
|
||||||
if (expYear == null || expYear < 0) {
|
expYear = parsed.year;
|
||||||
throw FormatException('CSV row ${i + 1}: exp_year is invalid');
|
} else if (expMonthIndex >= 0 && expYearIndex >= 0) {
|
||||||
|
final parsedMonth = int.tryParse(expMonthRaw);
|
||||||
|
if (parsedMonth == null || parsedMonth < 1 || parsedMonth > 12) {
|
||||||
|
throw FormatException('CSV row ${i + 1}: exp_month must be 1-12');
|
||||||
|
}
|
||||||
|
final parsedYear = int.tryParse(expYearRaw);
|
||||||
|
if (parsedYear == null || parsedYear < 0) {
|
||||||
|
throw FormatException('CSV row ${i + 1}: exp_year is invalid');
|
||||||
|
}
|
||||||
|
expMonth = parsedMonth;
|
||||||
|
expYear = parsedYear;
|
||||||
|
} else {
|
||||||
|
throw FormatException('CSV row ${i + 1}: exp_date is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
rows.add(
|
rows.add(
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,32 +29,37 @@ 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) {
|
||||||
kind: PaymentKind.payout,
|
final amount = Money(amount: row.amount, currency: _currency);
|
||||||
source: ManagedWalletPaymentMethod(
|
return PaymentIntent(
|
||||||
managedWalletRef: sourceWallet.id,
|
kind: PaymentKind.payout,
|
||||||
asset: sourceAsset,
|
source: ManagedWalletPaymentMethod(
|
||||||
),
|
managedWalletRef: sourceWallet.id,
|
||||||
destination: CardPaymentMethod(
|
asset: sourceAsset,
|
||||||
pan: row.pan,
|
|
||||||
firstName: row.firstName,
|
|
||||||
lastName: row.lastName,
|
|
||||||
expMonth: row.expMonth,
|
|
||||||
expYear: row.expYear,
|
|
||||||
),
|
|
||||||
amount: Money(amount: row.amount, currency: _currency),
|
|
||||||
settlementMode: SettlementMode.fixReceived,
|
|
||||||
fx : FxIntent(
|
|
||||||
pair: CurrencyPair(
|
|
||||||
base: 'USDT', // TODO: fix currencies picking
|
|
||||||
quote: 'RUB',
|
|
||||||
),
|
),
|
||||||
side: FxSide.sellBaseBuyQuote,
|
destination: CardPaymentMethod(
|
||||||
),
|
pan: row.pan,
|
||||||
),
|
firstName: row.firstName,
|
||||||
|
lastName: row.lastName,
|
||||||
|
expMonth: row.expMonth,
|
||||||
|
expYear: row.expYear,
|
||||||
|
),
|
||||||
|
amount: amount,
|
||||||
|
settlementMode: SettlementMode.fixReceived,
|
||||||
|
settlementCurrency: FxIntentHelper.resolveSettlementCurrency(
|
||||||
|
amount: amount,
|
||||||
|
fx: fxIntent,
|
||||||
|
),
|
||||||
|
fx: fxIntent,
|
||||||
|
);
|
||||||
|
},
|
||||||
)
|
)
|
||||||
.toList(growable: false);
|
.toList(growable: false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
)
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user
Поправь, пожалуйста, здесь имя переменной и атавизм в бэке на Target тоже, чтобы не расходилось.