diff --git a/frontend/pshared/lib/data/mapper/payment/enums.dart b/frontend/pshared/lib/data/mapper/payment/enums.dart index d7c136b6..1b1a4f20 100644 --- a/frontend/pshared/lib/data/mapper/payment/enums.dart +++ b/frontend/pshared/lib/data/mapper/payment/enums.dart @@ -160,16 +160,20 @@ String insufficientNetPolicyToValue(InsufficientNetPolicy policy) { PaymentType endpointTypeFromValue(String? value) { switch (value) { case 'managedWallet': + case 'managed_wallet': return PaymentType.managedWallet; case 'externalChain': + case 'external_chain': return PaymentType.externalChain; case 'card': return PaymentType.card; case 'cardToken': + case 'card_token': return PaymentType.cardToken; case 'ledger': return PaymentType.ledger; case 'bankAccount': + case 'bank_account': return PaymentType.bankAccount; case 'iban': return PaymentType.iban; @@ -185,15 +189,15 @@ String endpointTypeToValue(PaymentType type) { case PaymentType.ledger: return 'ledger'; case PaymentType.managedWallet: - return 'managedWallet'; + return 'managed_wallet'; case PaymentType.externalChain: - return 'externalChain'; + return 'external_chain'; case PaymentType.card: return 'card'; case PaymentType.cardToken: - return 'cardToken'; + return 'card'; case PaymentType.bankAccount: - return 'bankAccount'; + return 'bank_account'; case PaymentType.iban: return 'iban'; case PaymentType.wallet: diff --git a/frontend/pshared/lib/data/mapper/payment/payment.dart b/frontend/pshared/lib/data/mapper/payment/payment.dart index 6c36078f..39b6483d 100644 --- a/frontend/pshared/lib/data/mapper/payment/payment.dart +++ b/frontend/pshared/lib/data/mapper/payment/payment.dart @@ -5,6 +5,7 @@ import 'package:pshared/data/dto/payment/external_chain.dart'; import 'package:pshared/data/dto/payment/ledger.dart'; import 'package:pshared/data/dto/payment/managed_wallet.dart'; import 'package:pshared/data/mapper/payment/asset.dart'; +import 'package:pshared/data/mapper/payment/enums.dart'; import 'package:pshared/data/mapper/payment/type.dart'; import 'package:pshared/models/payment/methods/card.dart'; import 'package:pshared/models/payment/methods/card_token.dart'; @@ -23,7 +24,7 @@ extension PaymentMethodDataEndpointMapper on PaymentMethodData { case PaymentType.ledger: final payload = this as LedgerPaymentMethod; return PaymentEndpointDTO( - type: paymentTypeToValue(type), + type: endpointTypeToValue(type), data: LedgerEndpointDTO( ledgerAccountRef: payload.ledgerAccountRef, contraLedgerAccountRef: payload.contraLedgerAccountRef, @@ -33,7 +34,7 @@ extension PaymentMethodDataEndpointMapper on PaymentMethodData { case PaymentType.managedWallet: final payload = this as ManagedWalletPaymentMethod; return PaymentEndpointDTO( - type: paymentTypeToValue(type), + type: endpointTypeToValue(type), data: ManagedWalletEndpointDTO( managedWalletRef: payload.managedWalletRef, asset: payload.asset?.toDTO(), @@ -43,7 +44,7 @@ extension PaymentMethodDataEndpointMapper on PaymentMethodData { case PaymentType.externalChain: final payload = this as CryptoAddressPaymentMethod; return PaymentEndpointDTO( - type: paymentTypeToValue(type), + type: endpointTypeToValue(type), data: ExternalChainEndpointDTO( asset: payload.asset?.toDTO(), address: payload.address, @@ -54,7 +55,7 @@ extension PaymentMethodDataEndpointMapper on PaymentMethodData { case PaymentType.card: final payload = this as CardPaymentMethod; return PaymentEndpointDTO( - type: paymentTypeToValue(type), + type: endpointTypeToValue(type), data: CardEndpointDTO( pan: payload.pan, expMonth: payload.expMonth, @@ -68,7 +69,7 @@ extension PaymentMethodDataEndpointMapper on PaymentMethodData { case PaymentType.cardToken: final payload = this as CardTokenPaymentMethod; return PaymentEndpointDTO( - type: paymentTypeToValue(type), + type: endpointTypeToValue(type), data: CardTokenEndpointDTO( token: payload.token, maskedPan: payload.maskedPan, @@ -85,7 +86,7 @@ extension PaymentEndpointDTOMapper on PaymentEndpointDTO { PaymentMethodData toDomain() { final metadata = this.metadata; - switch (paymentTypeFromValue(type)) { + switch (_resolveEndpointType(type, data)) { case PaymentType.ledger: final payload = LedgerEndpointDTO.fromJson(data); return LedgerPaymentMethod( @@ -131,3 +132,10 @@ extension PaymentEndpointDTOMapper on PaymentEndpointDTO { } } } + +PaymentType _resolveEndpointType(String type, Map data) { + if (type == 'card' && (data.containsKey('token') || data.containsKey('masked_pan'))) { + return PaymentType.cardToken; + } + return endpointTypeFromValue(type); +} diff --git a/frontend/pshared/lib/data/mapper/payment/payment_quote.dart b/frontend/pshared/lib/data/mapper/payment/payment_quote.dart index f083c9e6..5ac69e05 100644 --- a/frontend/pshared/lib/data/mapper/payment/payment_quote.dart +++ b/frontend/pshared/lib/data/mapper/payment/payment_quote.dart @@ -3,7 +3,7 @@ import 'package:pshared/data/mapper/payment/fee_line.dart'; import 'package:pshared/data/mapper/payment/fx_quote.dart'; import 'package:pshared/data/mapper/payment/money.dart'; import 'package:pshared/data/mapper/payment/network_fee.dart'; -import 'package:pshared/models/payment/quote.dart'; +import 'package:pshared/models/payment/quote/quote.dart'; extension PaymentQuoteDTOMapper on PaymentQuoteDTO { diff --git a/frontend/pshared/lib/data/mapper/payment/quote_aggregate.dart b/frontend/pshared/lib/data/mapper/payment/quote/aggregate.dart similarity index 94% rename from frontend/pshared/lib/data/mapper/payment/quote_aggregate.dart rename to frontend/pshared/lib/data/mapper/payment/quote/aggregate.dart index 45d9fc0b..563c0c2a 100644 --- a/frontend/pshared/lib/data/mapper/payment/quote_aggregate.dart +++ b/frontend/pshared/lib/data/mapper/payment/quote/aggregate.dart @@ -1,6 +1,6 @@ import 'package:pshared/data/dto/payment/quote_aggregate.dart'; import 'package:pshared/data/mapper/payment/money.dart'; -import 'package:pshared/models/payment/quote_aggregate.dart'; +import 'package:pshared/models/payment/quote/aggregate.dart'; extension PaymentQuoteAggregateDTOMapper on PaymentQuoteAggregateDTO { diff --git a/frontend/pshared/lib/data/mapper/payment/quotes.dart b/frontend/pshared/lib/data/mapper/payment/quote/quotes.dart similarity index 82% rename from frontend/pshared/lib/data/mapper/payment/quotes.dart rename to frontend/pshared/lib/data/mapper/payment/quote/quotes.dart index 3b9785f2..75015ebd 100644 --- a/frontend/pshared/lib/data/mapper/payment/quotes.dart +++ b/frontend/pshared/lib/data/mapper/payment/quote/quotes.dart @@ -1,7 +1,7 @@ import 'package:pshared/data/dto/payment/quotes.dart'; import 'package:pshared/data/mapper/payment/payment_quote.dart'; -import 'package:pshared/data/mapper/payment/quote_aggregate.dart'; -import 'package:pshared/models/payment/quotes.dart'; +import 'package:pshared/data/mapper/payment/quote/aggregate.dart'; +import 'package:pshared/models/payment/quote/quotes.dart'; extension PaymentQuotesDTOMapper on PaymentQuotesDTO { diff --git a/frontend/pshared/lib/models/auto_refresh_mode.dart b/frontend/pshared/lib/models/auto_refresh_mode.dart new file mode 100644 index 00000000..7de4c0fd --- /dev/null +++ b/frontend/pshared/lib/models/auto_refresh_mode.dart @@ -0,0 +1 @@ +enum AutoRefreshMode { off, on } diff --git a/frontend/pshared/lib/models/payment/payment.dart b/frontend/pshared/lib/models/payment/payment.dart index 42219f82..004687af 100644 --- a/frontend/pshared/lib/models/payment/payment.dart +++ b/frontend/pshared/lib/models/payment/payment.dart @@ -1,4 +1,4 @@ -import 'package:pshared/models/payment/quote.dart'; +import 'package:pshared/models/payment/quote/quote.dart'; class Payment { diff --git a/frontend/pshared/lib/models/payment/quote_aggregate.dart b/frontend/pshared/lib/models/payment/quote/aggregate.dart similarity index 100% rename from frontend/pshared/lib/models/payment/quote_aggregate.dart rename to frontend/pshared/lib/models/payment/quote/aggregate.dart diff --git a/frontend/pshared/lib/models/payment/quote.dart b/frontend/pshared/lib/models/payment/quote/quote.dart similarity index 100% rename from frontend/pshared/lib/models/payment/quote.dart rename to frontend/pshared/lib/models/payment/quote/quote.dart diff --git a/frontend/pshared/lib/models/payment/quotes.dart b/frontend/pshared/lib/models/payment/quote/quotes.dart similarity index 66% rename from frontend/pshared/lib/models/payment/quotes.dart rename to frontend/pshared/lib/models/payment/quote/quotes.dart index 6d548c78..e4bc7e5e 100644 --- a/frontend/pshared/lib/models/payment/quotes.dart +++ b/frontend/pshared/lib/models/payment/quote/quotes.dart @@ -1,5 +1,5 @@ -import 'package:pshared/models/payment/quote.dart'; -import 'package:pshared/models/payment/quote_aggregate.dart'; +import 'package:pshared/models/payment/quote/quote.dart'; +import 'package:pshared/models/payment/quote/aggregate.dart'; class PaymentQuotes { diff --git a/frontend/pshared/lib/models/payment/quote/status_type.dart b/frontend/pshared/lib/models/payment/quote/status_type.dart new file mode 100644 index 00000000..ad2334aa --- /dev/null +++ b/frontend/pshared/lib/models/payment/quote/status_type.dart @@ -0,0 +1 @@ +enum QuoteStatusType { loading, error, missing, expired, active } diff --git a/frontend/pshared/lib/provider/payment/provider.dart b/frontend/pshared/lib/provider/payment/provider.dart index 6afd032e..e66bda48 100644 --- a/frontend/pshared/lib/provider/payment/provider.dart +++ b/frontend/pshared/lib/provider/payment/provider.dart @@ -2,7 +2,7 @@ import 'package:flutter/foundation.dart'; import 'package:pshared/models/payment/payment.dart'; import 'package:pshared/provider/organizations.dart'; -import 'package:pshared/provider/payment/quotation.dart'; +import 'package:pshared/provider/payment/quotation/quotation.dart'; import 'package:pshared/provider/resource.dart'; import 'package:pshared/service/payment/service.dart'; @@ -31,7 +31,7 @@ class PaymentProvider extends ChangeNotifier { Future pay({String? idempotencyKey, Map? metadata}) async { if (!_organization.isOrganizationSet) throw StateError('Organization is not set'); - if (!_quotation.isReady) throw StateError('Quotation is not ready'); + if (!_quotation.hasLiveQuote) throw StateError('Quotation is not ready'); final quoteRef = _quotation.quotation?.quoteRef; if (quoteRef == null || quoteRef.isEmpty) { throw StateError('Quotation reference is not set'); diff --git a/frontend/pshared/lib/provider/payment/quotation.dart b/frontend/pshared/lib/provider/payment/quotation.dart deleted file mode 100644 index 6af30e02..00000000 --- a/frontend/pshared/lib/provider/payment/quotation.dart +++ /dev/null @@ -1,199 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:collection/collection.dart'; - -import 'package:uuid/uuid.dart'; - -import 'package:pshared/api/requests/payment/quote.dart'; -import 'package:pshared/data/mapper/payment/intent/payment.dart'; -import 'package:pshared/models/asset.dart'; -import 'package:pshared/models/payment/currency_pair.dart'; -import 'package:pshared/models/payment/customer.dart'; -import 'package:pshared/models/payment/fx/intent.dart'; -import 'package:pshared/models/payment/fx/side.dart'; -import 'package:pshared/models/payment/kind.dart'; -import 'package:pshared/models/payment/methods/managed_wallet.dart'; -import 'package:pshared/models/payment/methods/type.dart'; -import 'package:pshared/models/payment/money.dart'; -import 'package:pshared/models/payment/settlement_mode.dart'; -import 'package:pshared/models/payment/intent.dart'; -import 'package:pshared/models/payment/quote.dart'; -import 'package:pshared/models/recipient/recipient.dart'; -import 'package:pshared/provider/organizations.dart'; -import 'package:pshared/provider/payment/amount.dart'; -import 'package:pshared/provider/payment/flow.dart'; -import 'package:pshared/provider/payment/wallets.dart'; -import 'package:pshared/provider/recipient/provider.dart'; -import 'package:pshared/provider/recipient/pmethods.dart'; -import 'package:pshared/provider/resource.dart'; -import 'package:pshared/service/payment/quotation.dart'; -import 'package:pshared/utils/currency.dart'; - - -class QuotationProvider extends ChangeNotifier { - Resource _quotation = Resource(data: null, isLoading: false, error: null); - late OrganizationsProvider _organizations; - bool _isLoaded = false; - - void update( - OrganizationsProvider venue, - PaymentAmountProvider payment, - WalletsProvider wallets, - PaymentFlowProvider flow, - RecipientsProvider recipients, - PaymentMethodsProvider methods, - ) { - _organizations = venue; - final t = flow.selectedType; - final method = methods.methods.firstWhereOrNull((m) => m.type == t); - if ((wallets.selectedWallet != null) && (method != null)) { - final customer = _buildCustomer( - recipient: recipients.currentObject, - method: method, - ); - final amount = Money( - amount: payment.amount.toString(), - // TODO: adapt to possible other sources - currency: currencyCodeToString(wallets.selectedWallet!.currency), - ); - final fxIntent = FxIntent( - pair: CurrencyPair( - base: currencyCodeToString(wallets.selectedWallet!.currency), - quote: 'RUB', // TODO: exentd target currencies - ), - side: FxSide.sellBaseBuyQuote, - ); - getQuotation(PaymentIntent( - kind: PaymentKind.payout, - amount: amount, - destination: method.data, - source: ManagedWalletPaymentMethod( - managedWalletRef: wallets.selectedWallet!.id, - ), - fx: fxIntent, - settlementMode: payment.payerCoversFee ? SettlementMode.fixReceived : SettlementMode.fixSource, - settlementCurrency: _resolveSettlementCurrency(amount: amount, fx: fxIntent), - customer: customer, - )); - } - } - - PaymentQuote? get quotation => _quotation.data; - - bool get isReady => _isLoaded && !_quotation.isLoading && _quotation.error == null; - - Asset? get fee => quotation == null ? null : createAsset(quotation!.expectedFeeTotal!.currency, quotation!.expectedFeeTotal!.amount); - Asset? get total => quotation == null ? null : createAsset(quotation!.debitAmount!.currency, quotation!.debitAmount!.amount); - Asset? get recipientGets => quotation == null ? null : createAsset(quotation!.expectedSettlementAmount!.currency, quotation!.expectedSettlementAmount!.amount); - - String _resolveSettlementCurrency({ - required Money amount, - required FxIntent? fx, - }) { - final pair = fx?.pair; - if (pair != null) { - switch (fx?.side ?? FxSide.unspecified) { - case FxSide.buyBaseSellQuote: - if (pair.base.isNotEmpty) return pair.base; - break; - case FxSide.sellBaseBuyQuote: - if (pair.quote.isNotEmpty) return pair.quote; - break; - case FxSide.unspecified: - break; - } - if (amount.currency == pair.base && pair.quote.isNotEmpty) return pair.quote; - if (amount.currency == pair.quote && pair.base.isNotEmpty) return pair.base; - if (pair.quote.isNotEmpty) return pair.quote; - if (pair.base.isNotEmpty) return pair.base; - } - return amount.currency; - } - - Customer _buildCustomer({ - required Recipient? recipient, - required PaymentMethod method, - }) { - final name = _resolveCustomerName(method, recipient); - String? firstName; - String? middleName; - String? lastName; - - if (name != null && name.isNotEmpty) { - final parts = name.split(RegExp(r'\s+')); - if (parts.length == 1) { - firstName = parts.first; - } else if (parts.length == 2) { - firstName = parts.first; - lastName = parts.last; - } else { - firstName = parts.first; - lastName = parts.last; - middleName = parts.sublist(1, parts.length - 1).join(' '); - } - } - - return Customer( - id: recipient?.id ?? method.recipientRef, - firstName: firstName, - middleName: middleName, - lastName: lastName, - country: method.cardData?.country, - ); - } - - String? _resolveCustomerName(PaymentMethod method, Recipient? recipient) { - final card = method.cardData; - if (card != null) { - return '${card.firstName} ${card.lastName}'.trim(); - } - - final iban = method.ibanData; - if (iban != null && iban.accountHolder.trim().isNotEmpty) { - return iban.accountHolder.trim(); - } - - final bank = method.bankAccountData; - if (bank != null && bank.recipientName.trim().isNotEmpty) { - return bank.recipientName.trim(); - } - - final recipientName = recipient?.name.trim(); - return recipientName?.isNotEmpty == true ? recipientName : null; - } - - void _setResource(Resource quotation) { - _quotation = quotation; - notifyListeners(); - } - - Future getQuotation(PaymentIntent intent) async { - if (!_organizations.isOrganizationSet) throw StateError('Organization is not set'); - try { - _quotation = _quotation.copyWith(isLoading: true, error: null); - final response = await QuotationService.getQuotation( - _organizations.current.id, - QuotePaymentRequest( - idempotencyKey: Uuid().v4(), - intent: intent.toDTO(), - ), - ); - _isLoaded = true; - _setResource(_quotation.copyWith(data: response, isLoading: false, error: null)); - } catch (e) { - _setResource(_quotation.copyWith( - data: null, - error: e is Exception ? e : Exception(e.toString()), - isLoading: false, - )); - } - notifyListeners(); - return _quotation.data; - } - - void reset() { - _setResource(Resource(data: null, isLoading: false, error: null)); - _isLoaded = false; - notifyListeners(); - } -} diff --git a/frontend/pshared/lib/provider/payment/quotation/auto_refresh.dart b/frontend/pshared/lib/provider/payment/quotation/auto_refresh.dart new file mode 100644 index 00000000..20b00313 --- /dev/null +++ b/frontend/pshared/lib/provider/payment/quotation/auto_refresh.dart @@ -0,0 +1,77 @@ +import 'dart:async'; + + +class QuotationAutoRefreshController { + bool _enabled = true; + Timer? _timer; + DateTime? _scheduledAt; + DateTime? _triggeredAt; + + bool get isEnabled => _enabled; + + void setEnabled(bool enabled) { + if (_enabled == enabled) return; + _enabled = enabled; + } + + void sync({ + required bool isLoading, + required bool canRefresh, + required DateTime? expiresAt, + required Future Function() onRefresh, + }) { + if (!_enabled || isLoading || !canRefresh) { + _clearTimer(); + _scheduledAt = null; + _triggeredAt = null; + return; + } + + if (expiresAt == null) { + _clearTimer(); + _scheduledAt = null; + _triggeredAt = null; + return; + } + + final delay = expiresAt.difference(DateTime.now().toUtc()); + if (delay <= Duration.zero) { + if (_triggeredAt != null && _triggeredAt!.isAtSameMomentAs(expiresAt)) { + return; + } + _triggeredAt = expiresAt; + _clearTimer(); + onRefresh(); + return; + } + + if (_scheduledAt != null && + _scheduledAt!.isAtSameMomentAs(expiresAt) && + _timer?.isActive == true) { + return; + } + + _triggeredAt = null; + _clearTimer(); + _scheduledAt = expiresAt; + _timer = Timer(delay, () { + onRefresh(); + }); + } + + void reset() { + _enabled = false; + _scheduledAt = null; + _triggeredAt = null; + _clearTimer(); + } + + void dispose() { + _clearTimer(); + } + + void _clearTimer() { + _timer?.cancel(); + _timer = null; + } +} diff --git a/frontend/pshared/lib/provider/payment/quotation/intent_builder.dart b/frontend/pshared/lib/provider/payment/quotation/intent_builder.dart new file mode 100644 index 00000000..dbbfd584 --- /dev/null +++ b/frontend/pshared/lib/provider/payment/quotation/intent_builder.dart @@ -0,0 +1,139 @@ +import 'package:collection/collection.dart'; + +import 'package:pshared/models/payment/currency_pair.dart'; +import 'package:pshared/models/payment/customer.dart'; +import 'package:pshared/models/payment/fx/intent.dart'; +import 'package:pshared/models/payment/fx/side.dart'; +import 'package:pshared/models/payment/kind.dart'; +import 'package:pshared/models/payment/methods/managed_wallet.dart'; +import 'package:pshared/models/payment/methods/type.dart'; +import 'package:pshared/models/payment/money.dart'; +import 'package:pshared/models/payment/settlement_mode.dart'; +import 'package:pshared/models/payment/intent.dart'; +import 'package:pshared/models/recipient/recipient.dart'; +import 'package:pshared/provider/payment/amount.dart'; +import 'package:pshared/provider/payment/flow.dart'; +import 'package:pshared/provider/payment/wallets.dart'; +import 'package:pshared/provider/recipient/provider.dart'; +import 'package:pshared/provider/recipient/pmethods.dart'; +import 'package:pshared/utils/currency.dart'; + + +class QuotationIntentBuilder { + PaymentIntent? build({ + required PaymentAmountProvider payment, + required WalletsProvider wallets, + required PaymentFlowProvider flow, + required RecipientsProvider recipients, + required PaymentMethodsProvider methods, + }) { + final selectedWallet = wallets.selectedWallet; + final method = methods.methods.firstWhereOrNull((m) => m.type == flow.selectedType); + if (selectedWallet == null || method == null) return null; + + final customer = _buildCustomer( + recipient: recipients.currentObject, + method: method, + ); + final amount = Money( + amount: payment.amount.toString(), + // TODO: adapt to possible other sources + currency: currencyCodeToString(selectedWallet.currency), + ); + final fxIntent = FxIntent( + pair: CurrencyPair( + base: currencyCodeToString(selectedWallet.currency), + quote: 'RUB', // TODO: exentd target currencies + ), + side: FxSide.sellBaseBuyQuote, + ); + return PaymentIntent( + kind: PaymentKind.payout, + amount: amount, + destination: method.data, + source: ManagedWalletPaymentMethod( + managedWalletRef: selectedWallet.id, + ), + fx: fxIntent, + settlementMode: payment.payerCoversFee ? SettlementMode.fixReceived : SettlementMode.fixSource, + settlementCurrency: _resolveSettlementCurrency(amount: amount, fx: fxIntent), + customer: customer, + ); + } + + String _resolveSettlementCurrency({ + required Money amount, + required FxIntent? fx, + }) { + final pair = fx?.pair; + if (pair != null) { + switch (fx?.side ?? FxSide.unspecified) { + case FxSide.buyBaseSellQuote: + if (pair.base.isNotEmpty) return pair.base; + break; + case FxSide.sellBaseBuyQuote: + if (pair.quote.isNotEmpty) return pair.quote; + break; + case FxSide.unspecified: + break; + } + if (amount.currency == pair.base && pair.quote.isNotEmpty) return pair.quote; + if (amount.currency == pair.quote && pair.base.isNotEmpty) return pair.base; + if (pair.quote.isNotEmpty) return pair.quote; + if (pair.base.isNotEmpty) return pair.base; + } + return amount.currency; + } + + Customer _buildCustomer({ + required Recipient? recipient, + required PaymentMethod method, + }) { + final name = _resolveCustomerName(method, recipient); + String? firstName; + String? middleName; + String? lastName; + + if (name != null && name.isNotEmpty) { + final parts = name.split(RegExp(r'\s+')); + if (parts.length == 1) { + firstName = parts.first; + } else if (parts.length == 2) { + firstName = parts.first; + lastName = parts.last; + } else { + firstName = parts.first; + lastName = parts.last; + middleName = parts.sublist(1, parts.length - 1).join(' '); + } + } + + return Customer( + id: recipient?.id ?? method.recipientRef, + firstName: firstName, + middleName: middleName, + lastName: lastName, + country: method.cardData?.country, + ); + } + + String? _resolveCustomerName(PaymentMethod method, Recipient? recipient) { + final card = method.cardData; + if (card != null) { + return '${card.firstName} ${card.lastName}'.trim(); + } + + final iban = method.ibanData; + if (iban != null && iban.accountHolder.trim().isNotEmpty) { + return iban.accountHolder.trim(); + } + + final bank = method.bankAccountData; + if (bank != null && bank.recipientName.trim().isNotEmpty) { + return bank.recipientName.trim(); + } + + final recipientName = recipient?.name.trim(); + return recipientName?.isNotEmpty == true ? recipientName : null; + } +} diff --git a/frontend/pshared/lib/provider/payment/quotation/quotation.dart b/frontend/pshared/lib/provider/payment/quotation/quotation.dart new file mode 100644 index 00000000..777e07a6 --- /dev/null +++ b/frontend/pshared/lib/provider/payment/quotation/quotation.dart @@ -0,0 +1,175 @@ +import 'package:flutter/foundation.dart'; + +import 'package:logging/logging.dart'; +import 'package:collection/collection.dart'; + +import 'package:uuid/uuid.dart'; + +import 'package:pshared/api/requests/payment/quote.dart'; +import 'package:pshared/data/mapper/payment/intent/payment.dart'; +import 'package:pshared/models/asset.dart'; +import 'package:pshared/models/payment/quote/status_type.dart'; +import 'package:pshared/models/payment/intent.dart'; +import 'package:pshared/models/payment/quote/quote.dart'; +import 'package:pshared/models/payment/money.dart'; +import 'package:pshared/models/auto_refresh_mode.dart'; +import 'package:pshared/provider/organizations.dart'; +import 'package:pshared/provider/payment/amount.dart'; +import 'package:pshared/provider/payment/flow.dart'; +import 'package:pshared/provider/payment/wallets.dart'; +import 'package:pshared/provider/recipient/provider.dart'; +import 'package:pshared/provider/recipient/pmethods.dart'; +import 'package:pshared/provider/resource.dart'; +import 'package:pshared/provider/payment/quotation/auto_refresh.dart'; +import 'package:pshared/provider/payment/quotation/intent_builder.dart'; +import 'package:pshared/service/payment/quotation.dart'; + + +class QuotationProvider extends ChangeNotifier { + static final _logger = Logger('provider.payment.quotation'); + Resource _quotation = Resource(data: null, isLoading: false, error: null); + late OrganizationsProvider _organizations; + bool _isLoaded = false; + PaymentIntent? _lastIntent; + final QuotationIntentBuilder _intentBuilder = QuotationIntentBuilder(); + final QuotationAutoRefreshController _autoRefreshController = + QuotationAutoRefreshController(); + AutoRefreshMode _autoRefreshMode = AutoRefreshMode.on; + + void update( + OrganizationsProvider venue, + PaymentAmountProvider payment, + WalletsProvider wallets, + PaymentFlowProvider flow, + RecipientsProvider recipients, + PaymentMethodsProvider methods, + ) { + _organizations = venue; + final intent = _intentBuilder.build( + payment: payment, + wallets: wallets, + flow: flow, + recipients: recipients, + methods: methods, + ); + if (intent == null) return; + final lastIntentDto = _lastIntent?.toDTO().toJson(); + final isSameIntent = lastIntentDto != null && + const DeepCollectionEquality().equals(intent.toDTO().toJson(), lastIntentDto); + if (isSameIntent) return; + getQuotation(intent); + } + + PaymentQuote? get quotation => _quotation.data; + bool get isLoading => _quotation.isLoading; + Exception? get error => _quotation.error; + bool get canRefresh => _lastIntent != null; + AutoRefreshMode get autoRefreshMode => _autoRefreshMode; + bool get isReady => _isLoaded && !_quotation.isLoading && _quotation.error == null; + bool get hasLiveQuote => isReady && quotation != null && !isExpired; + + DateTime? get quoteExpiresAt { + final expiresAtUnixMs = quotation?.fxQuote?.expiresAtUnixMs; + if (expiresAtUnixMs == null) return null; + return DateTime.fromMillisecondsSinceEpoch(expiresAtUnixMs, isUtc: true); + } + + Duration? get quoteTimeLeft { + final expiresAt = quoteExpiresAt; + if (expiresAt == null) return null; + return expiresAt.difference(DateTime.now().toUtc()); + } + + bool get isExpired { + final timeLeft = quoteTimeLeft; + if (timeLeft == null) return false; + return timeLeft <= Duration.zero; + } + + QuoteStatusType get quoteStatusType { + if (isLoading) return QuoteStatusType.loading; + if (error != null) return QuoteStatusType.error; + if (quotation == null) return QuoteStatusType.missing; + if (isExpired) return QuoteStatusType.expired; + return QuoteStatusType.active; + } + + Asset? get fee => _assetFromMoney(quotation?.expectedFeeTotal); + Asset? get total => _assetFromMoney(quotation?.debitAmount); + Asset? get recipientGets => _assetFromMoney(quotation?.expectedSettlementAmount); + + Asset? _assetFromMoney(Money? money) { + if (money == null) return null; + return createAsset(money.currency, money.amount); + } + + void _setResource(Resource quotation) { + _quotation = quotation; + _syncAutoRefresh(); + notifyListeners(); + } + + void setAutoRefreshMode(AutoRefreshMode mode) { + if (_autoRefreshMode == mode) return; + _autoRefreshMode = mode; + _syncAutoRefresh(); + notifyListeners(); + } + + Future refreshQuotation() async { + final intent = _lastIntent; + if (intent == null) return null; + return getQuotation(intent); + } + + Future getQuotation(PaymentIntent intent) async { + if (!_organizations.isOrganizationSet) throw StateError('Organization is not set'); + _lastIntent = intent; + try { + _setResource(_quotation.copyWith(isLoading: true, error: null)); + final response = await QuotationService.getQuotation( + _organizations.current.id, + QuotePaymentRequest( + idempotencyKey: Uuid().v4(), + intent: intent.toDTO(), + ), + ); + _isLoaded = true; + _setResource(_quotation.copyWith(data: response, isLoading: false, error: null)); + } catch (e, st) { + _logger.warning('Failed to get quotation', e, st); + _setResource(_quotation.copyWith( + data: null, + error: e is Exception ? e : Exception(e.toString()), + isLoading: false, + )); + } + return _quotation.data; + } + + void reset() { + _isLoaded = false; + _lastIntent = null; + _autoRefreshMode = AutoRefreshMode.off; + _autoRefreshController.reset(); + _setResource(Resource(data: null, isLoading: false, error: null)); + } + + void _syncAutoRefresh() { + final isAutoRefreshEnabled = _autoRefreshMode == AutoRefreshMode.on; + _autoRefreshController.setEnabled(isAutoRefreshEnabled); + final canAutoRefresh = isAutoRefreshEnabled && canRefresh; + _autoRefreshController.sync( + isLoading: isLoading, + canRefresh: canAutoRefresh, + expiresAt: quoteExpiresAt, + onRefresh: refreshQuotation, + ); + } + + @override + void dispose() { + _autoRefreshController.dispose(); + super.dispose(); + } +} diff --git a/frontend/pshared/lib/provider/resource.dart b/frontend/pshared/lib/provider/resource.dart index 6d127f28..321caebc 100644 --- a/frontend/pshared/lib/provider/resource.dart +++ b/frontend/pshared/lib/provider/resource.dart @@ -5,11 +5,14 @@ class Resource { Resource({this.data, this.isLoading = false, this.error}); - Resource copyWith({T? data, bool? isLoading, Exception? error}) { + static const _unset = Object(); + + Resource copyWith({T? data, bool? isLoading, Object? error = _unset}) { return Resource( data: data ?? this.data, isLoading: isLoading ?? this.isLoading, - error: error ?? this.error, + // Distinguish "not provided" from an explicit null to allow clearing error. + error: error == _unset ? this.error : error as Exception?, ); } } diff --git a/frontend/pshared/lib/service/payment/quotation.dart b/frontend/pshared/lib/service/payment/quotation.dart index 99a5155f..9590380d 100644 --- a/frontend/pshared/lib/service/payment/quotation.dart +++ b/frontend/pshared/lib/service/payment/quotation.dart @@ -5,9 +5,9 @@ import 'package:pshared/api/requests/payment/quotes.dart'; import 'package:pshared/api/responses/payment/quotation.dart'; import 'package:pshared/api/responses/payment/quotes.dart'; import 'package:pshared/data/mapper/payment/payment_quote.dart'; -import 'package:pshared/data/mapper/payment/quotes.dart'; -import 'package:pshared/models/payment/quote.dart'; -import 'package:pshared/models/payment/quotes.dart'; +import 'package:pshared/data/mapper/payment/quote/quotes.dart'; +import 'package:pshared/models/payment/quote/quote.dart'; +import 'package:pshared/models/payment/quote/quotes.dart'; import 'package:pshared/service/authorization/service.dart'; import 'package:pshared/service/services.dart'; diff --git a/frontend/pweb/lib/app/router/payout_shell.dart b/frontend/pweb/lib/app/router/payout_shell.dart index 18384c32..45f80b58 100644 --- a/frontend/pweb/lib/app/router/payout_shell.dart +++ b/frontend/pweb/lib/app/router/payout_shell.dart @@ -10,7 +10,7 @@ import 'package:pshared/provider/organizations.dart'; import 'package:pshared/provider/payment/amount.dart'; import 'package:pshared/provider/payment/flow.dart'; import 'package:pshared/provider/payment/provider.dart'; -import 'package:pshared/provider/payment/quotation.dart'; +import 'package:pshared/provider/payment/quotation/quotation.dart'; import 'package:pshared/provider/recipient/provider.dart'; import 'package:pshared/provider/recipient/pmethods.dart'; @@ -66,9 +66,9 @@ RouteBase payoutShellRoute() => ShellRoute( ), ], child: PageSelector( - child: child, - routerState: state, - ), + child: child, + routerState: state, + ), ), routes: [ GoRoute( diff --git a/frontend/pweb/lib/l10n/en.arb b/frontend/pweb/lib/l10n/en.arb index ddae3047..9ccb5802 100644 --- a/frontend/pweb/lib/l10n/en.arb +++ b/frontend/pweb/lib/l10n/en.arb @@ -434,6 +434,29 @@ "payout": "Payout", "sendTo": "Send Payout To", "send": "Send Payout", + "quoteUnavailable": "Waiting for a quote...", + "quoteUpdating": "Refreshing quote...", + "quoteExpiresIn": "Quote expires in {time}", + "@quoteExpiresIn": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "quoteActive": "Quote is active", + "quoteExpired": "Quote expired, request a new one", + "quoteAutoRefresh": "Auto-refresh quote", + "quoteAutoRefreshHint": "Keeps the quote current while you prepare the payout", + "quoteAutoRefreshEnabled": "Auto-refresh is on", + "quoteAutoRefreshDisabled": "Auto-refresh is off", + "quoteEnableAutoRefresh": "Enable auto-refresh", + "quoteDisableAutoRefresh": "Disable auto-refresh", + "quoteRefresh": "Refresh quote", + "quoteRefreshRequired": "Refresh the quote to enable payout", + "quoteErrorGeneric": "Could not refresh quote, try again later", + "toggleOn": "On", + "toggleOff": "Off", "refreshBalance": "Refresh balance", "recipientPaysFee": "Recipient pays the fee", diff --git a/frontend/pweb/lib/l10n/ru.arb b/frontend/pweb/lib/l10n/ru.arb index 69a031de..a659271b 100644 --- a/frontend/pweb/lib/l10n/ru.arb +++ b/frontend/pweb/lib/l10n/ru.arb @@ -434,6 +434,29 @@ "payout": "Выплата", "sendTo": "Отправить выплату", "send": "Отправить выплату", + "quoteUnavailable": "Ожидание котировки...", + "quoteUpdating": "Обновляем котировку...", + "quoteExpiresIn": "Котировка истекает через {time}", + "@quoteExpiresIn": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "quoteActive": "Котировка активна", + "quoteExpired": "Срок котировки истек, запросите новую", + "quoteAutoRefresh": "Автообновление котировки", + "quoteAutoRefreshHint": "Поддерживает котировку актуальной во время подготовки выплаты", + "quoteAutoRefreshEnabled": "Автообновление включено", + "quoteAutoRefreshDisabled": "Автообновление выключено", + "quoteEnableAutoRefresh": "Включить автообновление", + "quoteDisableAutoRefresh": "Выключить автообновление", + "quoteRefresh": "Обновить котировку", + "quoteRefreshRequired": "Обновите котировку, чтобы продолжить выплату", + "quoteErrorGeneric": "Не удалось обновить котировку, повторите позже", + "toggleOn": "Вкл", + "toggleOff": "Выкл", "refreshBalance": "Обновить баланс", "recipientPaysFee": "Получатель оплачивает комиссию", diff --git a/frontend/pweb/lib/main.dart b/frontend/pweb/lib/main.dart index cd36f953..062350a8 100644 --- a/frontend/pweb/lib/main.dart +++ b/frontend/pweb/lib/main.dart @@ -13,7 +13,6 @@ import 'package:pshared/provider/permissions.dart'; import 'package:pshared/provider/account.dart'; import 'package:pshared/provider/organizations.dart'; import 'package:pshared/provider/accounts/employees.dart'; -import 'package:pshared/provider/recipient/pmethods.dart'; import 'package:pshared/provider/recipient/provider.dart'; import 'package:pshared/provider/payment/wallets.dart'; import 'package:pshared/provider/invitations.dart'; @@ -87,10 +86,6 @@ void main() async { create: (_) => InvitationsProvider(), update: (context, organizations, provider) => provider!..updateProviders(organizations), ), - ChangeNotifierProxyProvider2( - create: (_) => PaymentMethodsProvider(), - update: (context, organizations, recipients, provider) => provider!..updateProviders(organizations, recipients), - ), ChangeNotifierProvider( create: (_) => InvitationListViewModel(), ), @@ -109,4 +104,4 @@ void main() async { ), ); -} \ No newline at end of file +} diff --git a/frontend/pweb/lib/pages/dashboard/dashboard.dart b/frontend/pweb/lib/pages/dashboard/dashboard.dart index e1d036b4..ce1bc2c6 100644 --- a/frontend/pweb/lib/pages/dashboard/dashboard.dart +++ b/frontend/pweb/lib/pages/dashboard/dashboard.dart @@ -48,51 +48,54 @@ class _DashboardPageState extends State { } @override - Widget build(BuildContext context) => PageViewLoader( - child: SafeArea( - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - Expanded( - flex: 0, - child: TransactionRefButton( - onTap: () => _setActive(true), - isActive: _showContainerSingle, - label: AppLocalizations.of(context)!.sendSingle, - icon: Icons.person_add, + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return PageViewLoader( + child: SafeArea( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + flex: 0, + child: TransactionRefButton( + onTap: () => _setActive(true), + isActive: _showContainerSingle, + label: l10n.sendSingle, + icon: Icons.person_add, + ), ), - ), - const SizedBox(width: AppSpacing.small), - Expanded( - flex: 0, - child: TransactionRefButton( - onTap: () => _setActive(false), - isActive: _showContainerMultiple, - label: AppLocalizations.of(context)!.sendMultiple, - icon: Icons.group_add, + const SizedBox(width: AppSpacing.small), + Expanded( + flex: 0, + child: TransactionRefButton( + onTap: () => _setActive(false), + isActive: _showContainerMultiple, + label: l10n.sendMultiple, + icon: Icons.group_add, + ), ), - ), - ], - ), - const SizedBox(height: AppSpacing.medium), - BalanceWidget( - onTopUp: widget.onTopUp, - ), - const SizedBox(height: AppSpacing.small), - if (_showContainerMultiple) TitleMultiplePayout(), - const SizedBox(height: AppSpacing.medium), - if (_showContainerSingle) - SinglePayoutForm( - onRecipientSelected: widget.onRecipientSelected, - onGoToPayment: widget.onGoToPaymentWithoutRecipient, + ], ), - if (_showContainerMultiple) MultiplePayoutForm(), - ], + const SizedBox(height: AppSpacing.medium), + BalanceWidget( + onTopUp: widget.onTopUp, + ), + const SizedBox(height: AppSpacing.small), + if (_showContainerMultiple) TitleMultiplePayout(), + const SizedBox(height: AppSpacing.medium), + if (_showContainerSingle) + SinglePayoutForm( + onRecipientSelected: widget.onRecipientSelected, + onGoToPayment: widget.onGoToPaymentWithoutRecipient, + ), + if (_showContainerMultiple) MultiplePayoutForm(), + ], + ), ), ), - ), - ); + ); + } } diff --git a/frontend/pweb/lib/pages/dashboard/payouts/form.dart b/frontend/pweb/lib/pages/dashboard/payouts/form.dart index e076d525..36e1c379 100644 --- a/frontend/pweb/lib/pages/dashboard/payouts/form.dart +++ b/frontend/pweb/lib/pages/dashboard/payouts/form.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:pweb/pages/dashboard/payouts/amount.dart'; import 'package:pweb/pages/dashboard/payouts/fee_payer.dart'; +import 'package:pweb/pages/dashboard/payouts/quote_status/quote_status.dart'; import 'package:pweb/pages/dashboard/payouts/summary/widget.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; @@ -36,8 +37,9 @@ class PaymentFormWidget extends StatelessWidget { const SizedBox(height: _largeSpacing), const PaymentSummary(spacing: _extraSpacing), + const SizedBox(height: _mediumSpacing), + const QuoteStatus(spacing: _smallSpacing), ], ); } } - diff --git a/frontend/pweb/lib/pages/dashboard/payouts/quote_status/quote_status.dart b/frontend/pweb/lib/pages/dashboard/payouts/quote_status/quote_status.dart new file mode 100644 index 00000000..c7685858 --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/payouts/quote_status/quote_status.dart @@ -0,0 +1,93 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:pshared/models/payment/quote/status_type.dart'; +import 'package:pshared/provider/payment/quotation/quotation.dart'; + +import 'package:pweb/pages/dashboard/payouts/quote_status/widgets/body.dart'; +import 'package:pweb/utils/quote_duration_format.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class QuoteStatus extends StatefulWidget { + final double spacing; + + const QuoteStatus({super.key, required this.spacing}); + + @override + State createState() => _QuoteStatusState(); +} + +class _QuoteStatusState extends State { + Timer? _ticker; + + @override + void initState() { + super.initState(); + _ticker = Timer.periodic(const Duration(seconds: 1), (_) { + if (mounted) setState(() {}); + }); + } + + @override + void dispose() { + _ticker?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; + final provider = context.watch(); + final timeLeft = provider.quoteTimeLeft; + final isLoading = provider.isLoading; + final statusType = provider.quoteStatusType; + final autoRefreshMode = provider.autoRefreshMode; + + 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; + helperText = loc.quoteRefreshRequired; + break; + case QuoteStatusType.active: + statusText = timeLeft == null + ? loc.quoteActive + : loc.quoteExpiresIn(formatQuoteDuration(timeLeft)); + break; + } + + final canRefresh = provider.canRefresh && !isLoading; + final showPrimaryRefresh = canRefresh && + (statusType == QuoteStatusType.expired || + statusType == QuoteStatusType.error || + statusType == QuoteStatusType.missing); + + return QuoteStatusBody( + spacing: widget.spacing, + statusType: statusType, + statusText: statusText, + helperText: helperText, + isLoading: isLoading, + canRefresh: canRefresh, + showPrimaryRefresh: showPrimaryRefresh, + autoRefreshMode: autoRefreshMode, + onAutoRefreshModeChanged: provider.setAutoRefreshMode, + onRefresh: provider.refreshQuotation, + ); + } +} diff --git a/frontend/pweb/lib/pages/dashboard/payouts/quote_status/widgets/body.dart b/frontend/pweb/lib/pages/dashboard/payouts/quote_status/widgets/body.dart new file mode 100644 index 00000000..2ac16695 --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/payouts/quote_status/widgets/body.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/models/payment/quote/status_type.dart'; +import 'package:pshared/models/auto_refresh_mode.dart'; + +import 'package:pweb/pages/dashboard/payouts/quote_status/widgets/refresh_section.dart'; +import 'package:pweb/pages/dashboard/payouts/quote_status/widgets/card.dart'; + + +class QuoteStatusBody extends StatelessWidget { + final double spacing; + final QuoteStatusType statusType; + final String statusText; + final String? helperText; + final bool isLoading; + final bool canRefresh; + final bool showPrimaryRefresh; + final AutoRefreshMode autoRefreshMode; + final ValueChanged onAutoRefreshModeChanged; + final VoidCallback onRefresh; + + const QuoteStatusBody({ + super.key, + required this.spacing, + required this.statusType, + required this.statusText, + required this.helperText, + required this.isLoading, + required this.canRefresh, + required this.showPrimaryRefresh, + required this.autoRefreshMode, + required this.onAutoRefreshModeChanged, + required this.onRefresh, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + QuoteStatusCard( + statusType: statusType, + isLoading: isLoading, + statusText: statusText, + helperText: helperText, + canRefresh: canRefresh, + showPrimaryRefresh: showPrimaryRefresh, + onRefresh: onRefresh, + ), + SizedBox(height: spacing), + QuoteAutoRefreshSection( + autoRefreshMode: autoRefreshMode, + canRefresh: canRefresh, + onModeChanged: onAutoRefreshModeChanged, + ), + ], + ); + } +} diff --git a/frontend/pweb/lib/pages/dashboard/payouts/quote_status/widgets/card.dart b/frontend/pweb/lib/pages/dashboard/payouts/quote_status/widgets/card.dart new file mode 100644 index 00000000..e2ed8c55 --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/payouts/quote_status/widgets/card.dart @@ -0,0 +1,140 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/models/payment/quote/status_type.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class QuoteStatusCard extends StatelessWidget { + final QuoteStatusType statusType; + final bool isLoading; + final String statusText; + final String? helperText; + final bool canRefresh; + final bool showPrimaryRefresh; + final VoidCallback onRefresh; + + const QuoteStatusCard({ + super.key, + required this.statusType, + required this.isLoading, + required this.statusText, + required this.helperText, + required this.canRefresh, + required this.showPrimaryRefresh, + required this.onRefresh, + }); + + static const double _cardRadius = 12; + static const double _cardSpacing = 12; + static const double _iconSize = 18; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final foregroundColor = _resolveForegroundColor(theme, statusType); + final statusStyle = theme.textTheme.bodyMedium?.copyWith(color: foregroundColor); + final helperStyle = theme.textTheme.bodySmall?.copyWith( + color: foregroundColor.withValues(alpha: 0.8), + ); + + return Container( + padding: const EdgeInsets.all(_cardSpacing), + decoration: BoxDecoration( + color: _resolveCardColor(theme, statusType), + borderRadius: BorderRadius.circular(_cardRadius), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 2), + child: isLoading + ? SizedBox( + width: _iconSize, + height: _iconSize, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(foregroundColor), + ), + ) + : Icon( + _resolveIcon(statusType), + size: _iconSize, + color: foregroundColor, + ), + ), + const SizedBox(width: _cardSpacing), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(statusText, style: statusStyle), + if (helperText != null) ...[ + const SizedBox(height: 4), + Text(helperText!, style: helperStyle), + ], + ], + ), + ), + if (canRefresh) + Padding( + padding: const EdgeInsets.only(left: _cardSpacing), + child: showPrimaryRefresh + ? ElevatedButton( + onPressed: canRefresh ? onRefresh : null, + child: Text(AppLocalizations.of(context)!.quoteRefresh), + ) + : TextButton( + onPressed: canRefresh ? onRefresh : null, + child: Text(AppLocalizations.of(context)!.quoteRefresh), + ), + ), + ], + ), + ); + } + + Color _resolveCardColor(ThemeData theme, QuoteStatusType status) { + switch (status) { + case QuoteStatusType.loading: + return theme.colorScheme.secondaryContainer; + case QuoteStatusType.error: + case QuoteStatusType.expired: + return theme.colorScheme.errorContainer; + case QuoteStatusType.active: + return theme.colorScheme.primaryContainer; + case QuoteStatusType.missing: + return theme.colorScheme.surfaceContainerHighest; + } + } + + Color _resolveForegroundColor(ThemeData theme, QuoteStatusType status) { + switch (status) { + case QuoteStatusType.loading: + return theme.colorScheme.onSecondaryContainer; + case QuoteStatusType.error: + case QuoteStatusType.expired: + return theme.colorScheme.onErrorContainer; + case QuoteStatusType.active: + return theme.colorScheme.onPrimaryContainer; + case QuoteStatusType.missing: + return theme.colorScheme.onSurfaceVariant; + } + } + + IconData _resolveIcon(QuoteStatusType status) { + switch (status) { + case QuoteStatusType.loading: + return Icons.sync_rounded; + case QuoteStatusType.error: + return Icons.warning_amber_rounded; + case QuoteStatusType.expired: + return Icons.error_outline_rounded; + case QuoteStatusType.active: + return Icons.timer_outlined; + case QuoteStatusType.missing: + return Icons.info_outline_rounded; + } + } +} diff --git a/frontend/pweb/lib/pages/dashboard/payouts/quote_status/widgets/refresh_section.dart b/frontend/pweb/lib/pages/dashboard/payouts/quote_status/widgets/refresh_section.dart new file mode 100644 index 00000000..2c25a715 --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/payouts/quote_status/widgets/refresh_section.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/models/auto_refresh_mode.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class QuoteAutoRefreshSection extends StatelessWidget { + final AutoRefreshMode autoRefreshMode; + final bool canRefresh; + final ValueChanged onModeChanged; + + const QuoteAutoRefreshSection({ + super.key, + required this.autoRefreshMode, + required this.canRefresh, + required this.onModeChanged, + }); + + static const double _autoRefreshSpacing = 8; + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; + final theme = Theme.of(context); + + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + autoRefreshMode == AutoRefreshMode.on + ? loc.quoteAutoRefreshEnabled + : loc.quoteAutoRefreshDisabled, + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 2), + Text( + loc.quoteAutoRefreshHint, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.textTheme.bodySmall?.color?.withValues(alpha: 0.7), + ), + ), + ], + ), + ), + const SizedBox(width: _autoRefreshSpacing), + ToggleButtons( + isSelected: [ + autoRefreshMode == AutoRefreshMode.off, + autoRefreshMode == AutoRefreshMode.on, + ], + onPressed: canRefresh + ? (index) { + final nextMode = + index == 1 ? AutoRefreshMode.on : AutoRefreshMode.off; + if (nextMode == autoRefreshMode) return; + onModeChanged(nextMode); + } + : null, + borderRadius: BorderRadius.circular(999), + constraints: const BoxConstraints(minHeight: 32, minWidth: 56), + selectedColor: theme.colorScheme.onPrimary, + fillColor: theme.colorScheme.primary, + color: theme.colorScheme.onSurface, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Text(loc.toggleOff), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Text(loc.toggleOn), + ), + ], + ), + ], + ); + } +} diff --git a/frontend/pweb/lib/pages/dashboard/payouts/summary/fee.dart b/frontend/pweb/lib/pages/dashboard/payouts/summary/fee.dart index 95f8aa5d..6c1a005f 100644 --- a/frontend/pweb/lib/pages/dashboard/payouts/summary/fee.dart +++ b/frontend/pweb/lib/pages/dashboard/payouts/summary/fee.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'package:pshared/provider/payment/quotation.dart'; +import 'package:pshared/provider/payment/quotation/quotation.dart'; import 'package:pweb/pages/dashboard/payouts/summary/row.dart'; diff --git a/frontend/pweb/lib/pages/dashboard/payouts/summary/recipient_receives.dart b/frontend/pweb/lib/pages/dashboard/payouts/summary/recipient_receives.dart index e2f23a7f..e82c5aed 100644 --- a/frontend/pweb/lib/pages/dashboard/payouts/summary/recipient_receives.dart +++ b/frontend/pweb/lib/pages/dashboard/payouts/summary/recipient_receives.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'package:pshared/provider/payment/quotation.dart'; +import 'package:pshared/provider/payment/quotation/quotation.dart'; import 'package:pweb/pages/dashboard/payouts/summary/row.dart'; diff --git a/frontend/pweb/lib/pages/dashboard/payouts/summary/total.dart b/frontend/pweb/lib/pages/dashboard/payouts/summary/total.dart index 47f1b649..0e6aa7da 100644 --- a/frontend/pweb/lib/pages/dashboard/payouts/summary/total.dart +++ b/frontend/pweb/lib/pages/dashboard/payouts/summary/total.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'package:pshared/provider/payment/quotation.dart'; +import 'package:pshared/provider/payment/quotation/quotation.dart'; import 'package:pweb/pages/dashboard/payouts/summary/row.dart'; diff --git a/frontend/pweb/lib/pages/payment_methods/payment_page/content.dart b/frontend/pweb/lib/pages/payment_methods/payment_page/content.dart index 12b12890..049b84ca 100644 --- a/frontend/pweb/lib/pages/payment_methods/payment_page/content.dart +++ b/frontend/pweb/lib/pages/payment_methods/payment_page/content.dart @@ -4,6 +4,8 @@ import 'package:provider/provider.dart'; import 'package:pshared/models/payment/wallet.dart'; import 'package:pshared/models/recipient/recipient.dart'; +import 'package:pshared/provider/payment/provider.dart'; +import 'package:pshared/provider/payment/quotation/quotation.dart'; import 'package:pshared/provider/payment/wallets.dart'; import 'package:pshared/provider/recipient/provider.dart'; @@ -54,6 +56,9 @@ class PaymentPageContent extends StatelessWidget { Widget build(BuildContext context) { final dimensions = AppDimensions(); final loc = AppLocalizations.of(context)!; + final quotationProvider = context.watch(); + final paymentProvider = context.watch(); + final isSendEnabled = quotationProvider.hasLiveQuote && !paymentProvider.isLoading; return Align( alignment: Alignment.topCenter, @@ -112,7 +117,7 @@ class PaymentPageContent extends StatelessWidget { SizedBox(height: dimensions.paddingLarge), const PaymentFormWidget(), SizedBox(height: dimensions.paddingXXXLarge), - SendButton(onPressed: onSend), + SendButton(onPressed: onSend, isEnabled: isSendEnabled), SizedBox(height: dimensions.paddingLarge), ], ), diff --git a/frontend/pweb/lib/pages/payment_methods/payment_page/page.dart b/frontend/pweb/lib/pages/payment_methods/payment_page/page.dart index c94c3630..11d882dd 100644 --- a/frontend/pweb/lib/pages/payment_methods/payment_page/page.dart +++ b/frontend/pweb/lib/pages/payment_methods/payment_page/page.dart @@ -1,7 +1,11 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + import 'package:pshared/models/payment/wallet.dart'; import 'package:pshared/models/recipient/recipient.dart'; +import 'package:pshared/provider/payment/provider.dart'; +import 'package:pshared/provider/payment/quotation/quotation.dart'; import 'package:pshared/provider/recipient/provider.dart'; import 'package:pweb/pages/dashboard/payouts/form.dart'; @@ -50,6 +54,9 @@ class PaymentPageContent extends StatelessWidget { Widget build(BuildContext context) { final dimensions = AppDimensions(); final loc = AppLocalizations.of(context)!; + final quotationProvider = context.watch(); + final paymentProvider = context.watch(); + final isSendEnabled = quotationProvider.hasLiveQuote && !paymentProvider.isLoading; return Align( alignment: Alignment.topCenter, @@ -95,7 +102,7 @@ class PaymentPageContent extends StatelessWidget { SizedBox(height: dimensions.paddingLarge), const PaymentFormWidget(), SizedBox(height: dimensions.paddingXXXLarge), - SendButton(onPressed: onSend), + SendButton(onPressed: onSend, isEnabled: isSendEnabled), SizedBox(height: dimensions.paddingLarge), ], ), diff --git a/frontend/pweb/lib/pages/payment_methods/payment_page/send_button.dart b/frontend/pweb/lib/pages/payment_methods/payment_page/send_button.dart index 019cb291..e00ba1c5 100644 --- a/frontend/pweb/lib/pages/payment_methods/payment_page/send_button.dart +++ b/frontend/pweb/lib/pages/payment_methods/payment_page/send_button.dart @@ -7,31 +7,43 @@ import 'package:pweb/generated/i18n/app_localizations.dart'; class SendButton extends StatelessWidget { final VoidCallback onPressed; + final bool isEnabled; - const SendButton({super.key, required this.onPressed}); + const SendButton({ + super.key, + required this.onPressed, + this.isEnabled = true, + }); @override Widget build(BuildContext context) { final theme = Theme.of(context); final dimensions = AppDimensions(); + final backgroundColor = isEnabled + ? theme.colorScheme.primary + : theme.colorScheme.onSurface.withValues(alpha: 0.12); + final textColor = isEnabled + ? theme.colorScheme.onSecondary + : theme.colorScheme.onSurface.withValues(alpha: 0.38); + return Center( child: SizedBox( width: dimensions.buttonWidth, height: dimensions.buttonHeight, child: InkWell( borderRadius: BorderRadius.circular(dimensions.borderRadiusSmall), - onTap: onPressed, + onTap: isEnabled ? onPressed : null, child: Container( decoration: BoxDecoration( - color: theme.colorScheme.primary, + color: backgroundColor, borderRadius: BorderRadius.circular(dimensions.borderRadiusSmall), ), child: Center( child: Text( AppLocalizations.of(context)!.send, style: theme.textTheme.bodyLarge?.copyWith( - color: theme.colorScheme.onSecondary, + color: textColor, fontWeight: FontWeight.w600, ), ), @@ -41,4 +53,4 @@ class SendButton extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/frontend/pweb/lib/utils/quote_duration_format.dart b/frontend/pweb/lib/utils/quote_duration_format.dart new file mode 100644 index 00000000..1b756a6b --- /dev/null +++ b/frontend/pweb/lib/utils/quote_duration_format.dart @@ -0,0 +1,11 @@ +String formatQuoteDuration(Duration duration) { + final totalSeconds = duration.inSeconds < 0 ? 0 : duration.inSeconds; + final hours = totalSeconds ~/ 3600; + final minutes = (totalSeconds % 3600) ~/ 60; + final seconds = totalSeconds % 60; + + if (hours > 0) { + return '${hours.toString()}:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}'; + } + return '${minutes.toString()}:${seconds.toString().padLeft(2, '0')}'; +}