From f3ad4c2d4fe7a9245089dbb204b4086bce034856 Mon Sep 17 00:00:00 2001 From: Arseni Date: Mon, 29 Dec 2025 18:38:21 +0300 Subject: [PATCH 1/3] Added quote expiry-aware flows with auto-refresh --- .../lib/data/mapper/payment/payment.dart | 43 ++- .../lib/provider/payment/provider.dart | 2 +- .../lib/provider/payment/quotation.dart | 283 +++++++++++++++--- frontend/pweb/lib/l10n/en.arb | 13 + frontend/pweb/lib/l10n/ru.arb | 13 + frontend/pweb/lib/models/button_state.dart | 1 + .../lib/pages/dashboard/payouts/amount.dart | 14 + .../pweb/lib/pages/payment_methods/page.dart | 15 + .../payment_methods/payment_page/page.dart | 25 +- .../payment_page/quote/actions.dart | 54 ++++ .../payment_page/quote/message.dart | 49 +++ .../payment_page/quote/quote_status.dart | 115 +++++++ .../payment_page/send_button.dart | 45 ++- frontend/pweb/lib/utils/payment/dropdown.dart | 2 +- 14 files changed, 610 insertions(+), 64 deletions(-) create mode 100644 frontend/pweb/lib/models/button_state.dart create mode 100644 frontend/pweb/lib/pages/payment_methods/payment_page/quote/actions.dart create mode 100644 frontend/pweb/lib/pages/payment_methods/payment_page/quote/message.dart create mode 100644 frontend/pweb/lib/pages/payment_methods/payment_page/quote/quote_status.dart diff --git a/frontend/pshared/lib/data/mapper/payment/payment.dart b/frontend/pshared/lib/data/mapper/payment/payment.dart index 6c36078..b28fab7 100644 --- a/frontend/pshared/lib/data/mapper/payment/payment.dart +++ b/frontend/pshared/lib/data/mapper/payment/payment.dart @@ -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/endpoint.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/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/iban.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_token.dart'; import 'package:pshared/models/payment/methods/crypto_address.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/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'; @@ -75,8 +84,27 @@ extension PaymentMethodDataEndpointMapper on PaymentMethodData { ).toJson(), metadata: metadata, ); - default: - throw UnsupportedError('Unsupported payment endpoint type: $type'); + case PaymentType.iban: + 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, metadata: metadata, ); - default: - throw UnsupportedError('Unsupported payment endpoint type: ${paymentTypeFromValue(type)}'); + case PaymentType.iban: + 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(); } } } diff --git a/frontend/pshared/lib/provider/payment/provider.dart b/frontend/pshared/lib/provider/payment/provider.dart index 6afd032..4dd3487 100644 --- a/frontend/pshared/lib/provider/payment/provider.dart +++ b/frontend/pshared/lib/provider/payment/provider.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 index 7141de9..d20680e 100644 --- a/frontend/pshared/lib/provider/payment/quotation.dart +++ b/frontend/pshared/lib/provider/payment/quotation.dart @@ -1,3 +1,6 @@ +import 'dart:async'; +import 'dart:convert'; + import 'package:flutter/material.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/type.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/intent.dart'; import 'package:pshared/models/payment/quote.dart'; @@ -34,9 +38,18 @@ class QuotationProvider extends ChangeNotifier { Resource _quotation = Resource(data: null, isLoading: false, error: null); late OrganizationsProvider _organizations; 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( - OrganizationsProvider venue, + OrganizationsProvider venue, PaymentAmountProvider payment, WalletsProvider wallets, PaymentFlowProvider flow, @@ -44,44 +57,65 @@ class QuotationProvider extends ChangeNotifier { 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, - ); - getQuotation(PaymentIntent( - kind: PaymentKind.payout, - 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, - )); - } + _organizationAttached = true; + _pendingIntent = _buildIntent( + payment: payment, + wallets: wallets, + flow: flow, + recipients: recipients, + methods: methods, + ); + _scheduleQuotationRefresh(); } - 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); - 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); + bool get hasQuoteForCurrentIntent { + if (_pendingIntent == null || _lastRequestSignature == null) return false; + 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({ required Recipient? recipient, @@ -140,19 +174,127 @@ class QuotationProvider extends ChangeNotifier { notifyListeners(); } - Future getQuotation(PaymentIntent intent) async { - if (!_organizations.isOrganizationSet) throw StateError('Organization is not set'); + void refreshNow({bool force = true}) { + _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 _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 { - _quotation = _quotation.copyWith(isLoading: true, error: null); final response = await QuotationService.getQuotation( - _organizations.current.id, + _organizations.current.id, QuotePaymentRequest( idempotencyKey: Uuid().v4(), intent: intent.toDTO(), ), ); _isLoaded = true; + _lastRequestSignature = signature; _setResource(_quotation.copyWith(data: response, isLoading: false, error: null)); + _startExpirationTimer(); } catch (e) { _setResource(_quotation.copyWith( data: null, @@ -160,13 +302,68 @@ class QuotationProvider extends ChangeNotifier { isLoading: false, )); } - notifyListeners(); return _quotation.data; } - void reset() { - _setResource(Resource(data: null, isLoading: false, error: null)); + void _startExpirationTimer() { + _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; - 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(); } } diff --git a/frontend/pweb/lib/l10n/en.arb b/frontend/pweb/lib/l10n/en.arb index 27fbfb9..08bb45a 100644 --- a/frontend/pweb/lib/l10n/en.arb +++ b/frontend/pweb/lib/l10n/en.arb @@ -383,6 +383,19 @@ "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" + } + } + }, + "quoteExpired": "Quote expired, request a new one", + "quoteAutoRefresh": "Auto-refresh quote", + "quoteErrorGeneric": "Could not refresh quote, try again later", "recipientPaysFee": "Recipient pays the fee", "sentAmount": "Sent amount: {amount}", diff --git a/frontend/pweb/lib/l10n/ru.arb b/frontend/pweb/lib/l10n/ru.arb index 7aeb458..a9ede95 100644 --- a/frontend/pweb/lib/l10n/ru.arb +++ b/frontend/pweb/lib/l10n/ru.arb @@ -383,6 +383,19 @@ "payout": "Выплата", "sendTo": "Отправить выплату", "send": "Отправить выплату", + "quoteUnavailable": "Ожидание котировки...", + "quoteUpdating": "Обновляем котировку...", + "quoteExpiresIn": "Котировка истекает через {time}", + "@quoteExpiresIn": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "quoteExpired": "Срок котировки истек, запросите новую", + "quoteAutoRefresh": "Автообновление котировки", + "quoteErrorGeneric": "Не удалось обновить котировку, повторите позже", "recipientPaysFee": "Получатель оплачивает комиссию", "sentAmount": "Отправленная сумма: {amount}", diff --git a/frontend/pweb/lib/models/button_state.dart b/frontend/pweb/lib/models/button_state.dart new file mode 100644 index 0000000..859dd78 --- /dev/null +++ b/frontend/pweb/lib/models/button_state.dart @@ -0,0 +1 @@ +enum ButtonState { enabled, disabled, loading } diff --git a/frontend/pweb/lib/pages/dashboard/payouts/amount.dart b/frontend/pweb/lib/pages/dashboard/payouts/amount.dart index 3e2b2b2..a75ebaa 100644 --- a/frontend/pweb/lib/pages/dashboard/payouts/amount.dart +++ b/frontend/pweb/lib/pages/dashboard/payouts/amount.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:pshared/provider/payment/amount.dart'; +import 'package:pshared/provider/payment/quotation.dart'; import 'package:pshared/utils/currency.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; @@ -17,6 +18,7 @@ class PaymentAmountWidget extends StatefulWidget { class _PaymentAmountWidgetState extends State { late final TextEditingController _controller; + late final FocusNode _focusNode; bool _isSyncingText = false; @override @@ -24,10 +26,13 @@ class _PaymentAmountWidgetState extends State { super.initState(); final initialAmount = context.read().amount; _controller = TextEditingController(text: amountToString(initialAmount)); + _focusNode = FocusNode()..addListener(_handleFocusChange); } @override void dispose() { + _focusNode.removeListener(_handleFocusChange); + _focusNode.dispose(); _controller.dispose(); super.dispose(); } @@ -56,6 +61,14 @@ class _PaymentAmountWidgetState extends State { } } + void _handleFocusChange() { + if (_focusNode.hasFocus) return; + final quotationProvider = context.read(); + if (quotationProvider.canRequestQuote) { + quotationProvider.refreshNow(force: false); + } + } + @override Widget build(BuildContext context) { final amount = context.select((provider) => provider.amount); @@ -63,6 +76,7 @@ class _PaymentAmountWidgetState extends State { return TextField( controller: _controller, + focusNode: _focusNode, keyboardType: const TextInputType.numberWithOptions(decimal: true), decoration: InputDecoration( labelText: AppLocalizations.of(context)!.amount, diff --git a/frontend/pweb/lib/pages/payment_methods/page.dart b/frontend/pweb/lib/pages/payment_methods/page.dart index 5598647..29bad82 100644 --- a/frontend/pweb/lib/pages/payment_methods/page.dart +++ b/frontend/pweb/lib/pages/payment_methods/page.dart @@ -6,6 +6,7 @@ import 'package:pshared/models/payment/type.dart'; import 'package:pshared/models/recipient/recipient.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/recipient/pmethods.dart'; import 'package:pshared/provider/recipient/provider.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/services/posthog.dart'; +import 'package:pweb/generated/i18n/app_localizations.dart'; + class PaymentPage extends StatefulWidget { final ValueChanged? onBack; @@ -81,8 +84,20 @@ class _PaymentPageState extends State { void _handleSendPayment() { final flowProvider = context.read(); final paymentProvider = context.read(); + final quotationProvider = context.read(); + final loc = AppLocalizations.of(context)!; 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((_) { PosthogService.paymentInitiated(method: flowProvider.selectedType); }).catchError((error) { 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 3096cb8..eb0c976 100644 --- a/frontend/pweb/lib/pages/payment_methods/payment_page/page.dart +++ b/frontend/pweb/lib/pages/payment_methods/payment_page/page.dart @@ -1,13 +1,19 @@ 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.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/header.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/widgets/payment_info_section.dart'; import 'package:pweb/pages/payment_methods/widgets/recipient_section.dart'; @@ -94,8 +100,21 @@ class PaymentPageContent extends StatelessWidget { PaymentInfoSection(dimensions: dimensions), SizedBox(height: dimensions.paddingLarge), const PaymentFormWidget(), - SizedBox(height: dimensions.paddingXXXLarge), - SendButton(onPressed: onSend), + SizedBox(height: dimensions.paddingLarge), + const QuoteStatus(), + SizedBox(height: dimensions.paddingXXLarge), + Consumer2( + 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), ], ), diff --git a/frontend/pweb/lib/pages/payment_methods/payment_page/quote/actions.dart b/frontend/pweb/lib/pages/payment_methods/payment_page/quote/actions.dart new file mode 100644 index 0000000..b55beeb --- /dev/null +++ b/frontend/pweb/lib/pages/payment_methods/payment_page/quote/actions.dart @@ -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? 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), + ), + ], + ); +} diff --git a/frontend/pweb/lib/pages/payment_methods/payment_page/quote/message.dart b/frontend/pweb/lib/pages/payment_methods/payment_page/quote/message.dart new file mode 100644 index 0000000..9000212 --- /dev/null +++ b/frontend/pweb/lib/pages/payment_methods/payment_page/quote/message.dart @@ -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, + ), + ], + ], + ], + ); +} diff --git a/frontend/pweb/lib/pages/payment_methods/payment_page/quote/quote_status.dart b/frontend/pweb/lib/pages/payment_methods/payment_page/quote/quote_status.dart new file mode 100644 index 0000000..b921fed --- /dev/null +++ b/frontend/pweb/lib/pages/payment_methods/payment_page/quote/quote_status.dart @@ -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 createState() => _QuoteStatusState(); +} + +class _QuoteStatusState extends State { + bool _showDetails = false; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final dimensions = AppDimensions(); + final loc = AppLocalizations.of(context)!; + + return Consumer( + 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().setAutoRefresh(value) + : null, + onRefresh: canRefresh ? () => context.read().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'; + } +} 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 019cb29..13216f1 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 @@ -1,19 +1,31 @@ import 'package:flutter/material.dart'; +import 'package:pweb/models/button_state.dart'; import 'package:pweb/utils/dimensions.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; 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 Widget build(BuildContext context) { final theme = Theme.of(context); 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( child: SizedBox( @@ -21,24 +33,33 @@ class SendButton extends StatelessWidget { height: dimensions.buttonHeight, child: InkWell( borderRadius: BorderRadius.circular(dimensions.borderRadiusSmall), - onTap: onPressed, + onTap: isActive ? 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, - fontWeight: FontWeight.w600, - ), - ), + child: isLoading + ? SizedBox( + height: dimensions.iconSizeSmall, + width: dimensions.iconSizeSmall, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(textColor), + ), + ) + : Text( + AppLocalizations.of(context)!.send, + style: theme.textTheme.bodyLarge?.copyWith( + color: textColor, + fontWeight: FontWeight.w600, + ), + ), ), ), ), ), ); } -} \ No newline at end of file +} diff --git a/frontend/pweb/lib/utils/payment/dropdown.dart b/frontend/pweb/lib/utils/payment/dropdown.dart index eb237d0..291b30c 100644 --- a/frontend/pweb/lib/utils/payment/dropdown.dart +++ b/frontend/pweb/lib/utils/payment/dropdown.dart @@ -23,7 +23,7 @@ class PaymentMethodDropdown extends StatelessWidget { @override Widget build(BuildContext context) => DropdownButtonFormField( dropdownColor: Theme.of(context).colorScheme.onSecondary, - value: _getSelectedMethod(), + initialValue: _getSelectedMethod(), decoration: InputDecoration( labelText: AppLocalizations.of(context)!.whereGetMoney, border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), -- 2.49.1 From c3ec50c8e4405bf7f6e97c01e40311d601423cc8 Mon Sep 17 00:00:00 2001 From: Arseni Date: Tue, 30 Dec 2025 17:56:15 +0300 Subject: [PATCH 2/3] Fixes --- .../lib/provider/payment/quotation.dart | 16 ++- .../pweb/lib/pages/payment_methods/page.dart | 6 -- .../payment_methods/payment_page/page.dart | 18 +--- .../payment_page/send_button.dart | 101 ++++++++++-------- 4 files changed, 64 insertions(+), 77 deletions(-) diff --git a/frontend/pshared/lib/provider/payment/quotation.dart b/frontend/pshared/lib/provider/payment/quotation.dart index d20680e..21aeec4 100644 --- a/frontend/pshared/lib/provider/payment/quotation.dart +++ b/frontend/pshared/lib/provider/payment/quotation.dart @@ -84,12 +84,9 @@ class QuotationProvider extends ChangeNotifier { return _lastRequestSignature == _signature(_pendingIntent!); } - bool get hasLiveQuote => - _isLoaded && - !_isExpired && - !_quotation.isLoading && - _quotation.error == null && - quotation != null; + bool get isReady => _isLoaded && !_isExpired && !_quotation.isLoading && _quotation.error == null; + + bool get hasLiveQuote => isReady && quotation != null; Duration? get timeToExpire { final expiresAt = _quoteExpiry; @@ -176,9 +173,10 @@ class QuotationProvider extends ChangeNotifier { void refreshNow({bool force = true}) { _debounceTimer?.cancel(); - if (!_organizationAttached) return; - if (_pendingIntent == null) { - _reset(); + if (!canRequestQuote) { + if (_pendingIntent == null) { + _reset(); + } return; } unawaited(_requestQuotation(_pendingIntent!, force: force)); diff --git a/frontend/pweb/lib/pages/payment_methods/page.dart b/frontend/pweb/lib/pages/payment_methods/page.dart index 29bad82..0a64404 100644 --- a/frontend/pweb/lib/pages/payment_methods/page.dart +++ b/frontend/pweb/lib/pages/payment_methods/page.dart @@ -15,8 +15,6 @@ import 'package:pweb/pages/payment_methods/payment_page/body.dart'; import 'package:pweb/widgets/sidebar/destinations.dart'; import 'package:pweb/services/posthog.dart'; -import 'package:pweb/generated/i18n/app_localizations.dart'; - class PaymentPage extends StatefulWidget { final ValueChanged? onBack; @@ -85,16 +83,12 @@ class _PaymentPageState extends State { final flowProvider = context.read(); final paymentProvider = context.read(); final quotationProvider = context.read(); - final loc = AppLocalizations.of(context)!; 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; } 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 eb0c976..70fb650 100644 --- a/frontend/pweb/lib/pages/payment_methods/payment_page/page.dart +++ b/frontend/pweb/lib/pages/payment_methods/payment_page/page.dart @@ -1,14 +1,9 @@ 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.dart'; import 'package:pshared/provider/recipient/provider.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/header.dart'; @@ -103,18 +98,7 @@ class PaymentPageContent extends StatelessWidget { SizedBox(height: dimensions.paddingLarge), const QuoteStatus(), SizedBox(height: dimensions.paddingXXLarge), - Consumer2( - 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, - ); - }, - ), + SendButton(onPressed: onSend), 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 13216f1..b8d47f5 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 @@ -1,5 +1,10 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import 'package:pshared/provider/payment/provider.dart'; +import 'package:pshared/provider/payment/quotation.dart'; + import 'package:pweb/models/button_state.dart'; import 'package:pweb/utils/dimensions.dart'; @@ -8,58 +13,64 @@ import 'package:pweb/generated/i18n/app_localizations.dart'; class SendButton extends StatelessWidget { final VoidCallback? onPressed; - final ButtonState state; const SendButton({ super.key, required this.onPressed, - this.state = ButtonState.enabled, }); @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - 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); + Widget build(BuildContext context) => Consumer2( + builder: (context, quotation, payment, _) { + final theme = Theme.of(context); + final dimensions = AppDimensions(); - return Center( - child: SizedBox( - width: dimensions.buttonWidth, - height: dimensions.buttonHeight, - child: InkWell( - borderRadius: BorderRadius.circular(dimensions.borderRadiusSmall), - onTap: isActive ? onPressed : null, - child: Container( - decoration: BoxDecoration( - color: backgroundColor, - borderRadius: BorderRadius.circular(dimensions.borderRadiusSmall), + final canSend = quotation.hasLiveQuote && !payment.isLoading; + final state = payment.isLoading + ? ButtonState.loading + : (canSend ? ButtonState.enabled : ButtonState.disabled); + final isLoading = state == ButtonState.loading; + final isActive = state == ButtonState.enabled && onPressed != null; + + 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( + child: SizedBox( + width: dimensions.buttonWidth, + height: dimensions.buttonHeight, + child: InkWell( + borderRadius: BorderRadius.circular(dimensions.borderRadiusSmall), + onTap: isActive ? onPressed : null, + child: Container( + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(dimensions.borderRadiusSmall), + ), + child: Center( + child: isLoading + ? SizedBox( + height: dimensions.iconSizeSmall, + width: dimensions.iconSizeSmall, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(textColor), + ), + ) + : Text( + AppLocalizations.of(context)!.send, + style: theme.textTheme.bodyLarge?.copyWith( + color: textColor, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), ), - child: Center( - child: isLoading - ? SizedBox( - height: dimensions.iconSizeSmall, - width: dimensions.iconSizeSmall, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(textColor), - ), - ) - : Text( - AppLocalizations.of(context)!.send, - style: theme.textTheme.bodyLarge?.copyWith( - color: textColor, - fontWeight: FontWeight.w600, - ), - ), - ), - ), - ), - ), - ); - } + ); + }, + ); } -- 2.49.1 From d9a605ce2180a474989715e252fe6dd35aeabd17 Mon Sep 17 00:00:00 2001 From: Arseni Date: Tue, 30 Dec 2025 19:08:53 +0300 Subject: [PATCH 3/3] quote requests are paused while the payout amount is being edited --- .../pshared/lib/provider/payment/amount.dart | 8 ++++++++ .../lib/provider/payment/quotation.dart | 20 +++++++++++++++++++ .../lib/pages/dashboard/payouts/amount.dart | 12 +++++++---- 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/frontend/pshared/lib/provider/payment/amount.dart b/frontend/pshared/lib/provider/payment/amount.dart index f2cde54..2ddcfc1 100644 --- a/frontend/pshared/lib/provider/payment/amount.dart +++ b/frontend/pshared/lib/provider/payment/amount.dart @@ -4,9 +4,11 @@ import 'package:flutter/material.dart'; class PaymentAmountProvider with ChangeNotifier { double _amount = 10.0; bool _payerCoversFee = true; + bool _isEditing = false; double get amount => _amount; bool get payerCoversFee => _payerCoversFee; + bool get isEditing => _isEditing; void setAmount(double value) { _amount = value; @@ -17,4 +19,10 @@ class PaymentAmountProvider with ChangeNotifier { _payerCoversFee = value; notifyListeners(); } + + void setEditing(bool value) { + if (_isEditing == value) return; + _isEditing = value; + notifyListeners(); + } } diff --git a/frontend/pshared/lib/provider/payment/quotation.dart b/frontend/pshared/lib/provider/payment/quotation.dart index 21aeec4..9bad7e6 100644 --- a/frontend/pshared/lib/provider/payment/quotation.dart +++ b/frontend/pshared/lib/provider/payment/quotation.dart @@ -44,6 +44,7 @@ class QuotationProvider extends ChangeNotifier { Timer? _debounceTimer; Timer? _expirationTimer; bool _autoRefreshEnabled = true; + bool _amountEditing = false; static const _inputDebounce = Duration(milliseconds: 500); static const _expiryGracePeriod = Duration(seconds: 1); @@ -58,6 +59,9 @@ class QuotationProvider extends ChangeNotifier { ) { _organizations = venue; _organizationAttached = true; + final wasEditing = _amountEditing; + _amountEditing = payment.isEditing; + final editingJustEnded = wasEditing && !_amountEditing; _pendingIntent = _buildIntent( payment: payment, wallets: wallets, @@ -65,6 +69,22 @@ class QuotationProvider extends ChangeNotifier { recipients: recipients, methods: methods, ); + + if (_pendingIntent == null) { + _reset(); + return; + } + + if (_amountEditing) { + _debounceTimer?.cancel(); + return; + } + + if (editingJustEnded) { + refreshNow(force: false); + return; + } + _scheduleQuotationRefresh(); } diff --git a/frontend/pweb/lib/pages/dashboard/payouts/amount.dart b/frontend/pweb/lib/pages/dashboard/payouts/amount.dart index a75ebaa..f05b933 100644 --- a/frontend/pweb/lib/pages/dashboard/payouts/amount.dart +++ b/frontend/pweb/lib/pages/dashboard/payouts/amount.dart @@ -33,6 +33,7 @@ class _PaymentAmountWidgetState extends State { void dispose() { _focusNode.removeListener(_handleFocusChange); _focusNode.dispose(); + context.read().setEditing(false); _controller.dispose(); super.dispose(); } @@ -62,11 +63,13 @@ class _PaymentAmountWidgetState extends State { } void _handleFocusChange() { - if (_focusNode.hasFocus) return; - final quotationProvider = context.read(); - if (quotationProvider.canRequestQuote) { - quotationProvider.refreshNow(force: false); + final amountProvider = context.read(); + if (_focusNode.hasFocus) { + amountProvider.setEditing(true); + return; } + + amountProvider.setEditing(false); } @override @@ -83,6 +86,7 @@ class _PaymentAmountWidgetState extends State { border: const OutlineInputBorder(), ), onChanged: _onChanged, + onEditingComplete: () => _focusNode.unfocus(), ); } } -- 2.49.1