Added quote expiry-aware flows with auto-refresh

This commit is contained in:
Arseni
2025-12-29 18:38:21 +03:00
parent 4aeb06fd31
commit f3ad4c2d4f
14 changed files with 610 additions and 64 deletions

View File

@@ -2,16 +2,25 @@ import 'package:pshared/data/dto/payment/card.dart';
import 'package:pshared/data/dto/payment/card_token.dart'; import 'package:pshared/data/dto/payment/card_token.dart';
import 'package:pshared/data/dto/payment/endpoint.dart'; import 'package:pshared/data/dto/payment/endpoint.dart';
import 'package:pshared/data/dto/payment/external_chain.dart'; import 'package:pshared/data/dto/payment/external_chain.dart';
import 'package:pshared/data/dto/payment/iban.dart';
import 'package:pshared/data/dto/payment/ledger.dart'; import 'package:pshared/data/dto/payment/ledger.dart';
import 'package:pshared/data/dto/payment/managed_wallet.dart'; import 'package:pshared/data/dto/payment/managed_wallet.dart';
import 'package:pshared/data/dto/payment/russian_bank.dart';
import 'package:pshared/data/dto/payment/wallet.dart';
import 'package:pshared/data/mapper/payment/asset.dart'; import 'package:pshared/data/mapper/payment/asset.dart';
import 'package:pshared/data/mapper/payment/iban.dart';
import 'package:pshared/data/mapper/payment/type.dart'; import 'package:pshared/data/mapper/payment/type.dart';
import 'package:pshared/data/mapper/payment/russian_bank.dart';
import 'package:pshared/data/mapper/payment/wallet.dart';
import 'package:pshared/models/payment/methods/card.dart'; import 'package:pshared/models/payment/methods/card.dart';
import 'package:pshared/models/payment/methods/card_token.dart'; import 'package:pshared/models/payment/methods/card_token.dart';
import 'package:pshared/models/payment/methods/crypto_address.dart'; import 'package:pshared/models/payment/methods/crypto_address.dart';
import 'package:pshared/models/payment/methods/data.dart'; import 'package:pshared/models/payment/methods/data.dart';
import 'package:pshared/models/payment/methods/iban.dart';
import 'package:pshared/models/payment/methods/ledger.dart'; import 'package:pshared/models/payment/methods/ledger.dart';
import 'package:pshared/models/payment/methods/managed_wallet.dart'; import 'package:pshared/models/payment/methods/managed_wallet.dart';
import 'package:pshared/models/payment/methods/russian_bank.dart';
import 'package:pshared/models/payment/methods/wallet.dart';
import 'package:pshared/models/payment/type.dart'; import 'package:pshared/models/payment/type.dart';
@@ -75,8 +84,27 @@ extension PaymentMethodDataEndpointMapper on PaymentMethodData {
).toJson(), ).toJson(),
metadata: metadata, metadata: metadata,
); );
default: case PaymentType.iban:
throw UnsupportedError('Unsupported payment endpoint type: $type'); final payload = this as IbanPaymentMethod;
return PaymentEndpointDTO(
type: paymentTypeToValue(type),
data: payload.toDTO().toJson(),
metadata: metadata,
);
case PaymentType.bankAccount:
final payload = this as RussianBankAccountPaymentMethod;
return PaymentEndpointDTO(
type: paymentTypeToValue(type),
data: payload.toDTO().toJson(),
metadata: metadata,
);
case PaymentType.wallet:
final payload = this as WalletPaymentMethod;
return PaymentEndpointDTO(
type: paymentTypeToValue(type),
data: payload.toDTO().toJson(),
metadata: metadata,
);
} }
} }
} }
@@ -126,8 +154,15 @@ extension PaymentEndpointDTOMapper on PaymentEndpointDTO {
maskedPan: payload.maskedPan, maskedPan: payload.maskedPan,
metadata: metadata, metadata: metadata,
); );
default: case PaymentType.iban:
throw UnsupportedError('Unsupported payment endpoint type: ${paymentTypeFromValue(type)}'); final payload = IbanPaymentDataDTO.fromJson(data);
return payload.toDomain();
case PaymentType.bankAccount:
final payload = RussianBankAccountPaymentDataDTO.fromJson(data);
return payload.toDomain();
case PaymentType.wallet:
final payload = WalletPaymentDataDTO.fromJson(data);
return payload.toDomain();
} }
} }
} }

View File

@@ -31,7 +31,7 @@ class PaymentProvider extends ChangeNotifier {
Future<Payment?> pay({String? idempotencyKey, Map<String, String>? metadata}) async { Future<Payment?> pay({String? idempotencyKey, Map<String, String>? metadata}) async {
if (!_organization.isOrganizationSet) throw StateError('Organization is not set'); 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; final quoteRef = _quotation.quotation?.quoteRef;
if (quoteRef == null || quoteRef.isEmpty) { if (quoteRef == null || quoteRef.isEmpty) {
throw StateError('Quotation reference is not set'); throw StateError('Quotation reference is not set');

View File

@@ -1,3 +1,6 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
@@ -15,6 +18,7 @@ import 'package:pshared/models/payment/kind.dart';
import 'package:pshared/models/payment/methods/managed_wallet.dart'; import 'package:pshared/models/payment/methods/managed_wallet.dart';
import 'package:pshared/models/payment/methods/type.dart'; import 'package:pshared/models/payment/methods/type.dart';
import 'package:pshared/models/payment/money.dart'; import 'package:pshared/models/payment/money.dart';
import 'package:pshared/models/payment/type.dart';
import 'package:pshared/models/payment/settlement_mode.dart'; import 'package:pshared/models/payment/settlement_mode.dart';
import 'package:pshared/models/payment/intent.dart'; import 'package:pshared/models/payment/intent.dart';
import 'package:pshared/models/payment/quote.dart'; import 'package:pshared/models/payment/quote.dart';
@@ -34,9 +38,18 @@ class QuotationProvider extends ChangeNotifier {
Resource<PaymentQuote> _quotation = Resource(data: null, isLoading: false, error: null); Resource<PaymentQuote> _quotation = Resource(data: null, isLoading: false, error: null);
late OrganizationsProvider _organizations; late OrganizationsProvider _organizations;
bool _isLoaded = false; bool _isLoaded = false;
bool _organizationAttached = false;
PaymentIntent? _pendingIntent;
String? _lastRequestSignature;
Timer? _debounceTimer;
Timer? _expirationTimer;
bool _autoRefreshEnabled = true;
static const _inputDebounce = Duration(milliseconds: 500);
static const _expiryGracePeriod = Duration(seconds: 1);
void update( void update(
OrganizationsProvider venue, OrganizationsProvider venue,
PaymentAmountProvider payment, PaymentAmountProvider payment,
WalletsProvider wallets, WalletsProvider wallets,
PaymentFlowProvider flow, PaymentFlowProvider flow,
@@ -44,44 +57,65 @@ class QuotationProvider extends ChangeNotifier {
PaymentMethodsProvider methods, PaymentMethodsProvider methods,
) { ) {
_organizations = venue; _organizations = venue;
final t = flow.selectedType; _organizationAttached = true;
final method = methods.methods.firstWhereOrNull((m) => m.type == t); _pendingIntent = _buildIntent(
if ((wallets.selectedWallet != null) && (method != null)) { payment: payment,
final customer = _buildCustomer( wallets: wallets,
recipient: recipients.currentObject, flow: flow,
method: method, recipients: recipients,
); methods: methods,
getQuotation(PaymentIntent( );
kind: PaymentKind.payout, _scheduleQuotationRefresh();
amount: Money(
amount: payment.amount.toString(),
// TODO: adapt to possible other sources
currency: currencyCodeToString(wallets.selectedWallet!.currency),
),
destination: method.data,
source: ManagedWalletPaymentMethod(
managedWalletRef: wallets.selectedWallet!.id,
),
fx: FxIntent(
pair: CurrencyPair(
base: currencyCodeToString(wallets.selectedWallet!.currency),
quote: 'RUB', // TODO: exentd target currencies
),
side: FxSide.sellBaseBuyQuote,
),
settlementMode: payment.payerCoversFee ? SettlementMode.fixReceived : SettlementMode.fixSource,
customer: customer,
));
}
} }
PaymentQuote? get quotation => _quotation.data; PaymentQuote? get quotation => hasQuoteForCurrentIntent ? _quotation.data : null;
bool get isLoading => _quotation.isLoading;
Exception? get error => _quotation.error;
bool get autoRefreshEnabled => _autoRefreshEnabled;
bool get canRequestQuote => _organizationAttached && _pendingIntent != null && _organizations.isOrganizationSet;
bool get isReady => _isLoaded && !_quotation.isLoading && _quotation.error == null; bool get _isExpired {
final remaining = timeToExpire;
return remaining != null && remaining <= Duration.zero;
}
Asset? get fee => quotation == null ? null : createAsset(quotation!.expectedFeeTotal!.currency, quotation!.expectedFeeTotal!.amount); bool get hasQuoteForCurrentIntent {
Asset? get total => quotation == null ? null : createAsset(quotation!.debitAmount!.currency, quotation!.debitAmount!.amount); if (_pendingIntent == null || _lastRequestSignature == null) return false;
Asset? get recipientGets => quotation == null ? null : createAsset(quotation!.expectedSettlementAmount!.currency, quotation!.expectedSettlementAmount!.amount); return _lastRequestSignature == _signature(_pendingIntent!);
}
bool get hasLiveQuote =>
_isLoaded &&
!_isExpired &&
!_quotation.isLoading &&
_quotation.error == null &&
quotation != null;
Duration? get timeToExpire {
final expiresAt = _quoteExpiry;
if (expiresAt == null) return null;
final diff = expiresAt.difference(DateTime.now().toUtc());
return diff.isNegative ? Duration.zero : diff;
}
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,
);
Customer _buildCustomer({ Customer _buildCustomer({
required Recipient? recipient, required Recipient? recipient,
@@ -140,19 +174,127 @@ class QuotationProvider extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
Future<PaymentQuote?> getQuotation(PaymentIntent intent) async { void refreshNow({bool force = true}) {
if (!_organizations.isOrganizationSet) throw StateError('Organization is not set'); _debounceTimer?.cancel();
if (!_organizationAttached) return;
if (_pendingIntent == null) {
_reset();
return;
}
unawaited(_requestQuotation(_pendingIntent!, force: force));
}
void setAutoRefresh(bool enabled) {
if (!_organizationAttached) return;
if (_autoRefreshEnabled == enabled) return;
_autoRefreshEnabled = enabled;
if (_autoRefreshEnabled && (!hasLiveQuote || _isExpired) && _pendingIntent != null) {
unawaited(_requestQuotation(_pendingIntent!, force: true));
} else {
_startExpirationTimer();
notifyListeners();
}
}
@override
void dispose() {
_debounceTimer?.cancel();
_expirationTimer?.cancel();
super.dispose();
}
PaymentIntent? _buildIntent({
required PaymentAmountProvider payment,
required WalletsProvider wallets,
required PaymentFlowProvider flow,
required RecipientsProvider recipients,
required PaymentMethodsProvider methods,
}) {
if (!_organizationAttached || !_organizations.isOrganizationSet) return null;
final type = flow.selectedType;
final method = methods.methods.firstWhereOrNull((m) => m.type == type);
final wallet = wallets.selectedWallet;
if (wallet == null || method == null) return null;
final customer = _buildCustomer(
recipient: recipients.currentObject,
method: method,
);
return PaymentIntent(
kind: PaymentKind.payout,
amount: Money(
amount: payment.amount.toString(),
// TODO: adapt to possible other sources
currency: currencyCodeToString(wallet.currency),
),
destination: method.data,
source: ManagedWalletPaymentMethod(
managedWalletRef: wallet.id,
),
fx: FxIntent(
pair: CurrencyPair(
base: currencyCodeToString(wallet.currency),
quote: 'RUB', // TODO: exentd target currencies
),
side: FxSide.sellBaseBuyQuote,
),
settlementMode: payment.payerCoversFee ? SettlementMode.fixReceived : SettlementMode.fixSource,
customer: customer,
);
}
void _scheduleQuotationRefresh() {
_debounceTimer?.cancel();
if (_pendingIntent == null) {
_reset();
return;
}
_debounceTimer = Timer(_inputDebounce, () {
unawaited(_requestQuotation(_pendingIntent!, force: false));
});
}
Future<PaymentQuote?> _requestQuotation(PaymentIntent intent, {required bool force}) async {
if (!_organizationAttached || !_organizations.isOrganizationSet) {
_reset();
return null;
}
final destinationType = intent.destination?.type;
if (destinationType == PaymentType.bankAccount) {
_setResource(
_quotation.copyWith(
data: null,
isLoading: false,
error: Exception('Unsupported payment endpoint type: $destinationType'),
),
);
return null;
}
final signature = _signature(intent);
final isSameIntent = _lastRequestSignature == signature;
if (!force && isSameIntent && hasLiveQuote) {
_startExpirationTimer();
return _quotation.data;
}
_setResource(_quotation.copyWith(isLoading: true, error: null));
try { try {
_quotation = _quotation.copyWith(isLoading: true, error: null);
final response = await QuotationService.getQuotation( final response = await QuotationService.getQuotation(
_organizations.current.id, _organizations.current.id,
QuotePaymentRequest( QuotePaymentRequest(
idempotencyKey: Uuid().v4(), idempotencyKey: Uuid().v4(),
intent: intent.toDTO(), intent: intent.toDTO(),
), ),
); );
_isLoaded = true; _isLoaded = true;
_lastRequestSignature = signature;
_setResource(_quotation.copyWith(data: response, isLoading: false, error: null)); _setResource(_quotation.copyWith(data: response, isLoading: false, error: null));
_startExpirationTimer();
} catch (e) { } catch (e) {
_setResource(_quotation.copyWith( _setResource(_quotation.copyWith(
data: null, data: null,
@@ -160,13 +302,68 @@ class QuotationProvider extends ChangeNotifier {
isLoading: false, isLoading: false,
)); ));
} }
notifyListeners();
return _quotation.data; return _quotation.data;
} }
void reset() { void _startExpirationTimer() {
_setResource(Resource(data: null, isLoading: false, error: null)); _expirationTimer?.cancel();
final remaining = timeToExpire;
if (remaining == null) return;
final triggerOffset = _autoRefreshEnabled ? _expiryGracePeriod : Duration.zero;
final duration = remaining > triggerOffset ? remaining - triggerOffset : Duration.zero;
_expirationTimer = Timer(duration, () {
if (_autoRefreshEnabled && _pendingIntent != null) {
unawaited(_requestQuotation(_pendingIntent!, force: true));
} else {
notifyListeners();
}
});
}
void _reset() {
_debounceTimer?.cancel();
_expirationTimer?.cancel();
_pendingIntent = null;
_lastRequestSignature = null;
_isLoaded = false; _isLoaded = false;
notifyListeners(); _setResource(Resource(data: null, isLoading: false, error: null));
}
DateTime? get _quoteExpiry {
final expiresAt = quotation?.fxQuote?.expiresAtUnixMs;
if (expiresAt == null) return null;
return DateTime.fromMillisecondsSinceEpoch(expiresAt, isUtc: true);
}
String _signature(PaymentIntent intent) {
try {
return jsonEncode(intent.toDTO().toJson());
} catch (_) {
return jsonEncode({
'kind': intent.kind.toString(),
'source': intent.source?.type.toString(),
'destination': intent.destination?.type.toString(),
'amount': {
'value': intent.amount?.amount,
'currency': intent.amount?.currency,
},
'fx': intent.fx == null
? null
: {
'pair': {
'base': intent.fx?.pair?.base,
'quote': intent.fx?.pair?.quote,
},
'side': intent.fx?.side.toString(),
},
'settlementMode': intent.settlementMode.toString(),
'customer': intent.customer?.id,
});
}
}
void reset() {
_reset();
} }
} }

View File

@@ -383,6 +383,19 @@
"payout": "Payout", "payout": "Payout",
"sendTo": "Send Payout To", "sendTo": "Send Payout To",
"send": "Send Payout", "send": "Send Payout",
"quoteUnavailable": "Waiting for a quote...",
"quoteUpdating": "Refreshing quote...",
"quoteExpiresIn": "Quote expires in {time}",
"@quoteExpiresIn": {
"placeholders": {
"time": {
"type": "String"
}
}
},
"quoteExpired": "Quote expired, request a new one",
"quoteAutoRefresh": "Auto-refresh quote",
"quoteErrorGeneric": "Could not refresh quote, try again later",
"recipientPaysFee": "Recipient pays the fee", "recipientPaysFee": "Recipient pays the fee",
"sentAmount": "Sent amount: {amount}", "sentAmount": "Sent amount: {amount}",

View File

@@ -383,6 +383,19 @@
"payout": "Выплата", "payout": "Выплата",
"sendTo": "Отправить выплату", "sendTo": "Отправить выплату",
"send": "Отправить выплату", "send": "Отправить выплату",
"quoteUnavailable": "Ожидание котировки...",
"quoteUpdating": "Обновляем котировку...",
"quoteExpiresIn": "Котировка истекает через {time}",
"@quoteExpiresIn": {
"placeholders": {
"time": {
"type": "String"
}
}
},
"quoteExpired": "Срок котировки истек, запросите новую",
"quoteAutoRefresh": "Автообновление котировки",
"quoteErrorGeneric": "Не удалось обновить котировку, повторите позже",
"recipientPaysFee": "Получатель оплачивает комиссию", "recipientPaysFee": "Получатель оплачивает комиссию",
"sentAmount": "Отправленная сумма: {amount}", "sentAmount": "Отправленная сумма: {amount}",

View File

@@ -0,0 +1 @@
enum ButtonState { enabled, disabled, loading }

View File

@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:pshared/provider/payment/amount.dart'; import 'package:pshared/provider/payment/amount.dart';
import 'package:pshared/provider/payment/quotation.dart';
import 'package:pshared/utils/currency.dart'; import 'package:pshared/utils/currency.dart';
import 'package:pweb/generated/i18n/app_localizations.dart'; import 'package:pweb/generated/i18n/app_localizations.dart';
@@ -17,6 +18,7 @@ class PaymentAmountWidget extends StatefulWidget {
class _PaymentAmountWidgetState extends State<PaymentAmountWidget> { class _PaymentAmountWidgetState extends State<PaymentAmountWidget> {
late final TextEditingController _controller; late final TextEditingController _controller;
late final FocusNode _focusNode;
bool _isSyncingText = false; bool _isSyncingText = false;
@override @override
@@ -24,10 +26,13 @@ class _PaymentAmountWidgetState extends State<PaymentAmountWidget> {
super.initState(); super.initState();
final initialAmount = context.read<PaymentAmountProvider>().amount; final initialAmount = context.read<PaymentAmountProvider>().amount;
_controller = TextEditingController(text: amountToString(initialAmount)); _controller = TextEditingController(text: amountToString(initialAmount));
_focusNode = FocusNode()..addListener(_handleFocusChange);
} }
@override @override
void dispose() { void dispose() {
_focusNode.removeListener(_handleFocusChange);
_focusNode.dispose();
_controller.dispose(); _controller.dispose();
super.dispose(); super.dispose();
} }
@@ -56,6 +61,14 @@ class _PaymentAmountWidgetState extends State<PaymentAmountWidget> {
} }
} }
void _handleFocusChange() {
if (_focusNode.hasFocus) return;
final quotationProvider = context.read<QuotationProvider>();
if (quotationProvider.canRequestQuote) {
quotationProvider.refreshNow(force: false);
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final amount = context.select<PaymentAmountProvider, double>((provider) => provider.amount); final amount = context.select<PaymentAmountProvider, double>((provider) => provider.amount);
@@ -63,6 +76,7 @@ class _PaymentAmountWidgetState extends State<PaymentAmountWidget> {
return TextField( return TextField(
controller: _controller, controller: _controller,
focusNode: _focusNode,
keyboardType: const TextInputType.numberWithOptions(decimal: true), keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: InputDecoration( decoration: InputDecoration(
labelText: AppLocalizations.of(context)!.amount, labelText: AppLocalizations.of(context)!.amount,

View File

@@ -6,6 +6,7 @@ import 'package:pshared/models/payment/type.dart';
import 'package:pshared/models/recipient/recipient.dart'; import 'package:pshared/models/recipient/recipient.dart';
import 'package:pshared/provider/payment/flow.dart'; import 'package:pshared/provider/payment/flow.dart';
import 'package:pshared/provider/payment/provider.dart'; import 'package:pshared/provider/payment/provider.dart';
import 'package:pshared/provider/payment/quotation.dart';
import 'package:pshared/provider/recipient/pmethods.dart'; import 'package:pshared/provider/recipient/pmethods.dart';
import 'package:pshared/provider/recipient/provider.dart'; import 'package:pshared/provider/recipient/provider.dart';
import 'package:pshared/provider/payment/wallets.dart'; import 'package:pshared/provider/payment/wallets.dart';
@@ -14,6 +15,8 @@ import 'package:pweb/pages/payment_methods/payment_page/body.dart';
import 'package:pweb/widgets/sidebar/destinations.dart'; import 'package:pweb/widgets/sidebar/destinations.dart';
import 'package:pweb/services/posthog.dart'; import 'package:pweb/services/posthog.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class PaymentPage extends StatefulWidget { class PaymentPage extends StatefulWidget {
final ValueChanged<Recipient?>? onBack; final ValueChanged<Recipient?>? onBack;
@@ -81,8 +84,20 @@ class _PaymentPageState extends State<PaymentPage> {
void _handleSendPayment() { void _handleSendPayment() {
final flowProvider = context.read<PaymentFlowProvider>(); final flowProvider = context.read<PaymentFlowProvider>();
final paymentProvider = context.read<PaymentProvider>(); final paymentProvider = context.read<PaymentProvider>();
final quotationProvider = context.read<QuotationProvider>();
final loc = AppLocalizations.of(context)!;
if (paymentProvider.isLoading) return; if (paymentProvider.isLoading) return;
if (!quotationProvider.hasLiveQuote) {
if (quotationProvider.canRequestQuote) {
quotationProvider.refreshNow();
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(quotationProvider.canRequestQuote ? loc.quoteExpired : loc.quoteUnavailable)),
);
return;
}
paymentProvider.pay().then((_) { paymentProvider.pay().then((_) {
PosthogService.paymentInitiated(method: flowProvider.selectedType); PosthogService.paymentInitiated(method: flowProvider.selectedType);
}).catchError((error) { }).catchError((error) {

View File

@@ -1,13 +1,19 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/models/payment/wallet.dart'; import 'package:pshared/models/payment/wallet.dart';
import 'package:pshared/models/recipient/recipient.dart'; import 'package:pshared/models/recipient/recipient.dart';
import 'package:pshared/provider/payment/provider.dart';
import 'package:pshared/provider/payment/quotation.dart';
import 'package:pshared/provider/recipient/provider.dart'; import 'package:pshared/provider/recipient/provider.dart';
import 'package:pweb/pages/dashboard/payouts/form.dart';
import 'package:pweb/models/button_state.dart';
import 'package:pweb/pages/dashboard/payouts/form.dart';
import 'package:pweb/pages/payment_methods/payment_page/back_button.dart'; import 'package:pweb/pages/payment_methods/payment_page/back_button.dart';
import 'package:pweb/pages/payment_methods/payment_page/header.dart'; import 'package:pweb/pages/payment_methods/payment_page/header.dart';
import 'package:pweb/pages/payment_methods/payment_page/method_selector.dart'; import 'package:pweb/pages/payment_methods/payment_page/method_selector.dart';
import 'package:pweb/pages/payment_methods/payment_page/quote/quote_status.dart';
import 'package:pweb/pages/payment_methods/payment_page/send_button.dart'; import 'package:pweb/pages/payment_methods/payment_page/send_button.dart';
import 'package:pweb/pages/payment_methods/widgets/payment_info_section.dart'; import 'package:pweb/pages/payment_methods/widgets/payment_info_section.dart';
import 'package:pweb/pages/payment_methods/widgets/recipient_section.dart'; import 'package:pweb/pages/payment_methods/widgets/recipient_section.dart';
@@ -94,8 +100,21 @@ class PaymentPageContent extends StatelessWidget {
PaymentInfoSection(dimensions: dimensions), PaymentInfoSection(dimensions: dimensions),
SizedBox(height: dimensions.paddingLarge), SizedBox(height: dimensions.paddingLarge),
const PaymentFormWidget(), const PaymentFormWidget(),
SizedBox(height: dimensions.paddingXXXLarge), SizedBox(height: dimensions.paddingLarge),
SendButton(onPressed: onSend), const QuoteStatus(),
SizedBox(height: dimensions.paddingXXLarge),
Consumer2<QuotationProvider, PaymentProvider>(
builder: (context, quotation, payment, _) {
final canSend = quotation.hasLiveQuote && !payment.isLoading;
final state = payment.isLoading
? ButtonState.loading
: (canSend ? ButtonState.enabled : ButtonState.disabled);
return SendButton(
onPressed: canSend ? onSend : null,
state: state,
);
},
),
SizedBox(height: dimensions.paddingLarge), SizedBox(height: dimensions.paddingLarge),
], ],
), ),

View File

@@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
import 'package:pweb/utils/dimensions.dart';
class QuoteStatusActions extends StatelessWidget {
final bool isLoading;
final bool canRefresh;
final bool autoRefreshEnabled;
final ValueChanged<bool>? onToggleAutoRefresh;
final VoidCallback? onRefresh;
final String autoRefreshLabel;
final String refreshLabel;
final AppDimensions dimensions;
final TextTheme theme;
const QuoteStatusActions({
super.key,
required this.isLoading,
required this.canRefresh,
required this.autoRefreshEnabled,
required this.onToggleAutoRefresh,
required this.onRefresh,
required this.autoRefreshLabel,
required this.refreshLabel,
required this.dimensions,
required this.theme,
});
@override
Widget build(BuildContext context) => Row(
children: [
Expanded(
child: SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
value: autoRefreshEnabled,
title: Text(autoRefreshLabel, style: theme.bodyMedium),
onChanged: onToggleAutoRefresh,
),
),
TextButton.icon(
onPressed: canRefresh ? onRefresh : null,
icon: isLoading
? SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh),
label: Text(refreshLabel),
),
],
);
}

View File

@@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
import 'package:pweb/utils/dimensions.dart';
class QuoteStatusMessage extends StatelessWidget {
final String statusText;
final String? errorText;
final bool showDetails;
final VoidCallback onToggleDetails;
final AppDimensions dimensions;
final TextTheme theme;
final String showLabel;
final String hideLabel;
const QuoteStatusMessage({
super.key,
required this.statusText,
required this.errorText,
required this.showDetails,
required this.onToggleDetails,
required this.dimensions,
required this.theme,
required this.showLabel,
required this.hideLabel,
});
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(statusText, style: theme.bodyMedium),
if (errorText != null) ...[
SizedBox(height: dimensions.paddingSmall),
TextButton(
onPressed: onToggleDetails,
child: Text(showDetails ? hideLabel : showLabel),
),
if (showDetails) ...[
SizedBox(height: dimensions.paddingSmall),
Text(
errorText!,
style: theme.bodySmall,
),
],
],
],
);
}

View File

@@ -0,0 +1,115 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/provider/payment/quotation.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
import 'package:pweb/pages/payment_methods/payment_page/quote/actions.dart';
import 'package:pweb/pages/payment_methods/payment_page/quote/message.dart';
import 'package:pweb/utils/dimensions.dart';
class QuoteStatus extends StatefulWidget {
const QuoteStatus({super.key});
@override
State<QuoteStatus> createState() => _QuoteStatusState();
}
class _QuoteStatusState extends State<QuoteStatus> {
bool _showDetails = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final dimensions = AppDimensions();
final loc = AppLocalizations.of(context)!;
return Consumer<QuotationProvider>(
builder: (context, provider, _) {
final statusText = _statusText(provider, loc);
final canRefresh = provider.canRequestQuote && !provider.isLoading;
final refreshLabel = provider.isLoading ? loc.quoteUpdating : loc.retry;
final error = provider.error;
final backgroundColor = theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.3);
return Container(
width: double.infinity,
padding: EdgeInsets.all(dimensions.paddingMedium),
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(dimensions.borderRadiusSmall),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
QuoteStatusMessage(
statusText: statusText,
errorText: error?.toString(),
showDetails: _showDetails,
onToggleDetails: () => setState(() => _showDetails = !_showDetails),
dimensions: dimensions,
theme: theme.textTheme,
showLabel: loc.showDetails,
hideLabel: loc.hideDetails,
),
SizedBox(height: dimensions.paddingSmall),
QuoteStatusActions(
isLoading: provider.isLoading,
canRefresh: canRefresh,
autoRefreshEnabled: provider.autoRefreshEnabled,
onToggleAutoRefresh: provider.canRequestQuote
? (value) => context.read<QuotationProvider>().setAutoRefresh(value)
: null,
onRefresh: canRefresh ? () => context.read<QuotationProvider>().refreshNow() : null,
autoRefreshLabel: loc.quoteAutoRefresh,
refreshLabel: refreshLabel,
dimensions: dimensions,
theme: theme.textTheme,
),
],
),
);
},
);
}
String _statusText(QuotationProvider provider, AppLocalizations loc) {
if (provider.error != null) {
return loc.quoteErrorGeneric;
}
if (!provider.canRequestQuote) {
return loc.quoteUnavailable;
}
if (provider.isLoading) {
return loc.quoteUpdating;
}
if (provider.hasLiveQuote) {
final remaining = provider.timeToExpire;
if (remaining != null) {
return loc.quoteExpiresIn(_formatDuration(remaining));
}
}
if (provider.hasQuoteForCurrentIntent) {
return loc.quoteExpired;
}
return loc.quoteUnavailable;
}
String _formatDuration(Duration duration) {
final minutes = duration.inMinutes;
final seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0');
if (duration.inHours > 0) {
final hours = duration.inHours;
final mins = minutes.remainder(60).toString().padLeft(2, '0');
return '$hours:$mins:$seconds';
}
return '$minutes:$seconds';
}
}

View File

@@ -1,19 +1,31 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:pweb/models/button_state.dart';
import 'package:pweb/utils/dimensions.dart'; import 'package:pweb/utils/dimensions.dart';
import 'package:pweb/generated/i18n/app_localizations.dart'; import 'package:pweb/generated/i18n/app_localizations.dart';
class SendButton extends StatelessWidget { class SendButton extends StatelessWidget {
final VoidCallback onPressed; final VoidCallback? onPressed;
final ButtonState state;
const SendButton({super.key, required this.onPressed}); const SendButton({
super.key,
required this.onPressed,
this.state = ButtonState.enabled,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final dimensions = AppDimensions(); final dimensions = AppDimensions();
final isLoading = state == ButtonState.loading;
final isActive = state == ButtonState.enabled && onPressed != null && !isLoading;
final backgroundColor = isActive
? theme.colorScheme.primary
: theme.colorScheme.primary.withValues(alpha: 0.5);
final textColor = theme.colorScheme.onSecondary.withValues(alpha: isActive ? 1 : 0.7);
return Center( return Center(
child: SizedBox( child: SizedBox(
@@ -21,24 +33,33 @@ class SendButton extends StatelessWidget {
height: dimensions.buttonHeight, height: dimensions.buttonHeight,
child: InkWell( child: InkWell(
borderRadius: BorderRadius.circular(dimensions.borderRadiusSmall), borderRadius: BorderRadius.circular(dimensions.borderRadiusSmall),
onTap: onPressed, onTap: isActive ? onPressed : null,
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.colorScheme.primary, color: backgroundColor,
borderRadius: BorderRadius.circular(dimensions.borderRadiusSmall), borderRadius: BorderRadius.circular(dimensions.borderRadiusSmall),
), ),
child: Center( child: Center(
child: Text( child: isLoading
AppLocalizations.of(context)!.send, ? SizedBox(
style: theme.textTheme.bodyLarge?.copyWith( height: dimensions.iconSizeSmall,
color: theme.colorScheme.onSecondary, width: dimensions.iconSizeSmall,
fontWeight: FontWeight.w600, child: CircularProgressIndicator(
), strokeWidth: 2,
), valueColor: AlwaysStoppedAnimation<Color>(textColor),
),
)
: Text(
AppLocalizations.of(context)!.send,
style: theme.textTheme.bodyLarge?.copyWith(
color: textColor,
fontWeight: FontWeight.w600,
),
),
), ),
), ),
), ),
), ),
); );
} }
} }

View File

@@ -23,7 +23,7 @@ class PaymentMethodDropdown extends StatelessWidget {
@override @override
Widget build(BuildContext context) => DropdownButtonFormField<Wallet>( Widget build(BuildContext context) => DropdownButtonFormField<Wallet>(
dropdownColor: Theme.of(context).colorScheme.onSecondary, dropdownColor: Theme.of(context).colorScheme.onSecondary,
value: _getSelectedMethod(), initialValue: _getSelectedMethod(),
decoration: InputDecoration( decoration: InputDecoration(
labelText: AppLocalizations.of(context)!.whereGetMoney, labelText: AppLocalizations.of(context)!.whereGetMoney,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),