Added quote expiry-aware flows with auto-refresh #211
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ class PaymentProvider extends ChangeNotifier {
|
||||
|
||||
Future<Payment?> pay({String? idempotencyKey, Map<String, String>? 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');
|
||||
|
||||
@@ -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<PaymentQuote> _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;
|
||||
}
|
||||
|
||||
|
tech
commented
часть выражения можно заменить галкой isReady и не повторять ее содержимое внутри этой галки часть выражения можно заменить галкой isReady и не повторять ее содержимое внутри этой галки
|
||||
_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<PaymentQuote?> 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<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 {
|
||||
_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();
|
||||
}
|
||||
});
|
||||
|
tech
commented
а почему так? разве toDTO может выбрасывать исключения? Если может - то это скорее ошибка программирования где-то. а почему так? разве toDTO может выбрасывать исключения? Если может - то это скорее ошибка программирования где-то.
protuberanets
commented
Перехват в _signature нужен, чтобы падение intent.toDTO().toJson() не убило провайдер и всё равно дать подпись. В норме toDTO кидать не должен. Могу еще добавить лог Перехват в _signature нужен, чтобы падение intent.toDTO().toJson() не убило провайдер и всё равно дать подпись. В норме toDTO кидать не должен. Могу еще добавить лог
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -383,6 +383,19 @@
|
||||
"payout": "Выплата",
|
||||
"sendTo": "Отправить выплату",
|
||||
"send": "Отправить выплату",
|
||||
"quoteUnavailable": "Ожидание котировки...",
|
||||
"quoteUpdating": "Обновляем котировку...",
|
||||
"quoteExpiresIn": "Котировка истекает через {time}",
|
||||
"@quoteExpiresIn": {
|
||||
"placeholders": {
|
||||
"time": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"quoteExpired": "Срок котировки истек, запросите новую",
|
||||
"quoteAutoRefresh": "Автообновление котировки",
|
||||
"quoteErrorGeneric": "Не удалось обновить котировку, повторите позже",
|
||||
"recipientPaysFee": "Получатель оплачивает комиссию",
|
||||
|
||||
"sentAmount": "Отправленная сумма: {amount}",
|
||||
|
||||
1
frontend/pweb/lib/models/button_state.dart
Normal file
@@ -0,0 +1 @@
|
||||
enum ButtonState { enabled, disabled, loading }
|
||||
@@ -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<PaymentAmountWidget> {
|
||||
late final TextEditingController _controller;
|
||||
late final FocusNode _focusNode;
|
||||
bool _isSyncingText = false;
|
||||
|
||||
@override
|
||||
@@ -24,10 +26,14 @@ class _PaymentAmountWidgetState extends State<PaymentAmountWidget> {
|
||||
super.initState();
|
||||
final initialAmount = context.read<PaymentAmountProvider>().amount;
|
||||
_controller = TextEditingController(text: amountToString(initialAmount));
|
||||
_focusNode = FocusNode()..addListener(_handleFocusChange);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_focusNode.removeListener(_handleFocusChange);
|
||||
_focusNode.dispose();
|
||||
context.read<PaymentAmountProvider>().setEditing(false);
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -56,6 +62,16 @@ class _PaymentAmountWidgetState extends State<PaymentAmountWidget> {
|
||||
}
|
||||
}
|
||||
|
||||
void _handleFocusChange() {
|
||||
final amountProvider = context.read<PaymentAmountProvider>();
|
||||
if (_focusNode.hasFocus) {
|
||||
|
tech
commented
У тебя canRequestQuote всегда соседствует с вызовом refreshNow. Это означает, что последний должен включать в себя проверку первого, чтобы просто не копипастить код. У тебя canRequestQuote всегда соседствует с вызовом refreshNow. Это означает, что последний должен включать в себя проверку первого, чтобы просто не копипастить код.
|
||||
amountProvider.setEditing(true);
|
||||
return;
|
||||
}
|
||||
|
||||
amountProvider.setEditing(false);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final amount = context.select<PaymentAmountProvider, double>((provider) => provider.amount);
|
||||
@@ -63,12 +79,14 @@ class _PaymentAmountWidgetState extends State<PaymentAmountWidget> {
|
||||
|
||||
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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<PaymentPage> {
|
||||
void _handleSendPayment() {
|
||||
final flowProvider = context.read<PaymentFlowProvider>();
|
||||
final paymentProvider = context.read<PaymentProvider>();
|
||||
final quotationProvider = context.read<QuotationProvider>();
|
||||
if (paymentProvider.isLoading) return;
|
||||
|
||||
if (!quotationProvider.hasLiveQuote) {
|
||||
if (quotationProvider.canRequestQuote) {
|
||||
quotationProvider.refreshNow();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
paymentProvider.pay().then((_) {
|
||||
|
tech
commented
нужно это сообщение? Да еще в snackbar? нужно это сообщение? Да еще в snackbar?
|
||||
PosthogService.paymentInitiated(method: flowProvider.selectedType);
|
||||
}).catchError((error) {
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
|
tech
commented
проверь, плиз, новшество на deprecation проверь, плиз, новшество на deprecation
protuberanets
commented
Все гуд Все гуд
|
||||
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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
|
tech
commented
логику не улавливаю. expired, когда есть котировка для текущего платежа? логику не улавливаю. expired, когда есть котировка для текущего платежа?
protuberanets
commented
quoteExpired показывается, когда для текущего платежа котировка есть, но её срок уже истёк. Пока котировка свежая — видишь таймер quoteExpiresIn, время вышло - quoteExpired quoteExpired показывается, когда для текущего платежа котировка есть, но её срок уже истёк. Пока котировка свежая — видишь таймер quoteExpiresIn, время вышло - quoteExpired
|
||||
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';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
tech
commented
а кнопка из провайдера не может состояние свое прочитать? нужно управление снаружи? а кнопка из провайдера не может состояние свое прочитать? нужно управление снаружи?
|
||||
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<QuotationProvider, PaymentProvider>(
|
||||
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<Color>(textColor),
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
AppLocalizations.of(context)!.send,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: textColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ class PaymentMethodDropdown extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => DropdownButtonFormField<Wallet>(
|
||||
dropdownColor: Theme.of(context).colorScheme.onSecondary,
|
||||
value: _getSelectedMethod(),
|
||||
initialValue: _getSelectedMethod(),
|
||||
decoration: InputDecoration(
|
||||
labelText: AppLocalizations.of(context)!.whereGetMoney,
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||
|
||||
ваще-то при hasQuoteForCurrentIntent _quotation.data железобетонно должно быть null. Реально флажок этот нужен?
_lastRequestSignature ставится только когда успешный ответ получен, но само поле _quotation.data потом может стать null (например, при ошибке обновления котировки мы делаем copyWith(data: null, error: e) и _lastRequestSignature остаётся прежним)