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/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/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..9bad7e6 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,19 @@ 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; + bool _amountEditing = false; + + 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 +58,81 @@ 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; + final wasEditing = _amountEditing; + _amountEditing = payment.isEditing; + final editingJustEnded = wasEditing && !_amountEditing; + _pendingIntent = _buildIntent( + payment: payment, + wallets: wallets, + flow: flow, + recipients: recipients, + methods: methods, + ); + + if (_pendingIntent == null) { + _reset(); + return; } + + if (_amountEditing) { + _debounceTimer?.cancel(); + return; + } + + if (editingJustEnded) { + refreshNow(force: false); + return; + } + + _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 isReady => _isLoaded && !_isExpired && !_quotation.isLoading && _quotation.error == null; + + bool get hasLiveQuote => isReady && 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 +191,128 @@ 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 (!canRequestQuote) { + 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 +320,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..f05b933 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,14 @@ 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(); + context.read().setEditing(false); _controller.dispose(); super.dispose(); } @@ -56,6 +62,16 @@ class _PaymentAmountWidgetState extends State { } } + void _handleFocusChange() { + final amountProvider = context.read(); + if (_focusNode.hasFocus) { + amountProvider.setEditing(true); + return; + } + + amountProvider.setEditing(false); + } + @override Widget build(BuildContext context) { final amount = context.select((provider) => provider.amount); @@ -63,12 +79,14 @@ class _PaymentAmountWidgetState extends State { return TextField( controller: _controller, + focusNode: _focusNode, keyboardType: const TextInputType.numberWithOptions(decimal: true), decoration: InputDecoration( labelText: AppLocalizations.of(context)!.amount, border: const OutlineInputBorder(), ), onChanged: _onChanged, + onEditingComplete: () => _focusNode.unfocus(), ); } } diff --git a/frontend/pweb/lib/pages/payment_methods/page.dart b/frontend/pweb/lib/pages/payment_methods/page.dart index 5598647..0a64404 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'; @@ -81,8 +82,16 @@ class _PaymentPageState extends State { void _handleSendPayment() { final flowProvider = context.read(); final paymentProvider = context.read(); + final quotationProvider = context.read(); if (paymentProvider.isLoading) return; + if (!quotationProvider.hasLiveQuote) { + if (quotationProvider.canRequestQuote) { + quotationProvider.refreshNow(); + } + 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..70fb650 100644 --- a/frontend/pweb/lib/pages/payment_methods/payment_page/page.dart +++ b/frontend/pweb/lib/pages/payment_methods/payment_page/page.dart @@ -3,11 +3,12 @@ import 'package:flutter/material.dart'; import 'package:pshared/models/payment/wallet.dart'; import 'package:pshared/models/recipient/recipient.dart'; import 'package:pshared/provider/recipient/provider.dart'; -import 'package:pweb/pages/dashboard/payouts/form.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,7 +95,9 @@ class PaymentPageContent extends StatelessWidget { PaymentInfoSection(dimensions: dimensions), SizedBox(height: dimensions.paddingLarge), const PaymentFormWidget(), - SizedBox(height: dimensions.paddingXXXLarge), + SizedBox(height: dimensions.paddingLarge), + const QuoteStatus(), + SizedBox(height: dimensions.paddingXXLarge), SendButton(onPressed: onSend), 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..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,44 +1,76 @@ 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'; import 'package:pweb/generated/i18n/app_localizations.dart'; class SendButton extends StatelessWidget { - final VoidCallback onPressed; + final VoidCallback? onPressed; - const SendButton({super.key, required this.onPressed}); + const SendButton({ + super.key, + required this.onPressed, + }); @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final dimensions = AppDimensions(); + 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: onPressed, - child: Container( - decoration: BoxDecoration( - color: theme.colorScheme.primary, - 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, + 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, + ), + ), + ), ), ), ), - ), - ), - ), - ); - } -} \ 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)),