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/card_token.dart';
|
||||||
import 'package:pshared/data/dto/payment/endpoint.dart';
|
import 'package:pshared/data/dto/payment/endpoint.dart';
|
||||||
import 'package:pshared/data/dto/payment/external_chain.dart';
|
import 'package:pshared/data/dto/payment/external_chain.dart';
|
||||||
|
import 'package:pshared/data/dto/payment/iban.dart';
|
||||||
import 'package:pshared/data/dto/payment/ledger.dart';
|
import 'package:pshared/data/dto/payment/ledger.dart';
|
||||||
import 'package:pshared/data/dto/payment/managed_wallet.dart';
|
import 'package:pshared/data/dto/payment/managed_wallet.dart';
|
||||||
|
import 'package:pshared/data/dto/payment/russian_bank.dart';
|
||||||
|
import 'package:pshared/data/dto/payment/wallet.dart';
|
||||||
import 'package:pshared/data/mapper/payment/asset.dart';
|
import 'package:pshared/data/mapper/payment/asset.dart';
|
||||||
|
import 'package:pshared/data/mapper/payment/iban.dart';
|
||||||
import 'package:pshared/data/mapper/payment/type.dart';
|
import 'package:pshared/data/mapper/payment/type.dart';
|
||||||
|
import 'package:pshared/data/mapper/payment/russian_bank.dart';
|
||||||
|
import 'package:pshared/data/mapper/payment/wallet.dart';
|
||||||
import 'package:pshared/models/payment/methods/card.dart';
|
import 'package:pshared/models/payment/methods/card.dart';
|
||||||
import 'package:pshared/models/payment/methods/card_token.dart';
|
import 'package:pshared/models/payment/methods/card_token.dart';
|
||||||
import 'package:pshared/models/payment/methods/crypto_address.dart';
|
import 'package:pshared/models/payment/methods/crypto_address.dart';
|
||||||
import 'package:pshared/models/payment/methods/data.dart';
|
import 'package:pshared/models/payment/methods/data.dart';
|
||||||
|
import 'package:pshared/models/payment/methods/iban.dart';
|
||||||
import 'package:pshared/models/payment/methods/ledger.dart';
|
import 'package:pshared/models/payment/methods/ledger.dart';
|
||||||
import 'package:pshared/models/payment/methods/managed_wallet.dart';
|
import 'package:pshared/models/payment/methods/managed_wallet.dart';
|
||||||
|
import 'package:pshared/models/payment/methods/russian_bank.dart';
|
||||||
|
import 'package:pshared/models/payment/methods/wallet.dart';
|
||||||
import 'package:pshared/models/payment/type.dart';
|
import 'package:pshared/models/payment/type.dart';
|
||||||
|
|
||||||
|
|
||||||
@@ -75,8 +84,27 @@ extension PaymentMethodDataEndpointMapper on PaymentMethodData {
|
|||||||
).toJson(),
|
).toJson(),
|
||||||
metadata: metadata,
|
metadata: metadata,
|
||||||
);
|
);
|
||||||
default:
|
case PaymentType.iban:
|
||||||
throw UnsupportedError('Unsupported payment endpoint type: $type');
|
final payload = this as IbanPaymentMethod;
|
||||||
|
return PaymentEndpointDTO(
|
||||||
|
type: paymentTypeToValue(type),
|
||||||
|
data: payload.toDTO().toJson(),
|
||||||
|
metadata: metadata,
|
||||||
|
);
|
||||||
|
case PaymentType.bankAccount:
|
||||||
|
final payload = this as RussianBankAccountPaymentMethod;
|
||||||
|
return PaymentEndpointDTO(
|
||||||
|
type: paymentTypeToValue(type),
|
||||||
|
data: payload.toDTO().toJson(),
|
||||||
|
metadata: metadata,
|
||||||
|
);
|
||||||
|
case PaymentType.wallet:
|
||||||
|
final payload = this as WalletPaymentMethod;
|
||||||
|
return PaymentEndpointDTO(
|
||||||
|
type: paymentTypeToValue(type),
|
||||||
|
data: payload.toDTO().toJson(),
|
||||||
|
metadata: metadata,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -126,8 +154,15 @@ extension PaymentEndpointDTOMapper on PaymentEndpointDTO {
|
|||||||
maskedPan: payload.maskedPan,
|
maskedPan: payload.maskedPan,
|
||||||
metadata: metadata,
|
metadata: metadata,
|
||||||
);
|
);
|
||||||
default:
|
case PaymentType.iban:
|
||||||
throw UnsupportedError('Unsupported payment endpoint type: ${paymentTypeFromValue(type)}');
|
final payload = IbanPaymentDataDTO.fromJson(data);
|
||||||
|
return payload.toDomain();
|
||||||
|
case PaymentType.bankAccount:
|
||||||
|
final payload = RussianBankAccountPaymentDataDTO.fromJson(data);
|
||||||
|
return payload.toDomain();
|
||||||
|
case PaymentType.wallet:
|
||||||
|
final payload = WalletPaymentDataDTO.fromJson(data);
|
||||||
|
return payload.toDomain();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import 'package:flutter/material.dart';
|
|||||||
class PaymentAmountProvider with ChangeNotifier {
|
class PaymentAmountProvider with ChangeNotifier {
|
||||||
double _amount = 10.0;
|
double _amount = 10.0;
|
||||||
bool _payerCoversFee = true;
|
bool _payerCoversFee = true;
|
||||||
|
bool _isEditing = false;
|
||||||
|
|
||||||
double get amount => _amount;
|
double get amount => _amount;
|
||||||
bool get payerCoversFee => _payerCoversFee;
|
bool get payerCoversFee => _payerCoversFee;
|
||||||
|
bool get isEditing => _isEditing;
|
||||||
|
|
||||||
void setAmount(double value) {
|
void setAmount(double value) {
|
||||||
_amount = value;
|
_amount = value;
|
||||||
@@ -17,4 +19,10 @@ class PaymentAmountProvider with ChangeNotifier {
|
|||||||
_payerCoversFee = value;
|
_payerCoversFee = value;
|
||||||
notifyListeners();
|
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 {
|
Future<Payment?> pay({String? idempotencyKey, Map<String, String>? metadata}) async {
|
||||||
if (!_organization.isOrganizationSet) throw StateError('Organization is not set');
|
if (!_organization.isOrganizationSet) throw StateError('Organization is not set');
|
||||||
if (!_quotation.isReady) throw StateError('Quotation is not ready');
|
if (!_quotation.hasLiveQuote) throw StateError('Quotation is not ready');
|
||||||
final quoteRef = _quotation.quotation?.quoteRef;
|
final quoteRef = _quotation.quotation?.quoteRef;
|
||||||
if (quoteRef == null || quoteRef.isEmpty) {
|
if (quoteRef == null || quoteRef.isEmpty) {
|
||||||
throw StateError('Quotation reference is not set');
|
throw StateError('Quotation reference is not set');
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
@@ -15,6 +18,7 @@ import 'package:pshared/models/payment/kind.dart';
|
|||||||
import 'package:pshared/models/payment/methods/managed_wallet.dart';
|
import 'package:pshared/models/payment/methods/managed_wallet.dart';
|
||||||
import 'package:pshared/models/payment/methods/type.dart';
|
import 'package:pshared/models/payment/methods/type.dart';
|
||||||
import 'package:pshared/models/payment/money.dart';
|
import 'package:pshared/models/payment/money.dart';
|
||||||
|
import 'package:pshared/models/payment/type.dart';
|
||||||
import 'package:pshared/models/payment/settlement_mode.dart';
|
import 'package:pshared/models/payment/settlement_mode.dart';
|
||||||
import 'package:pshared/models/payment/intent.dart';
|
import 'package:pshared/models/payment/intent.dart';
|
||||||
import 'package:pshared/models/payment/quote.dart';
|
import 'package:pshared/models/payment/quote.dart';
|
||||||
@@ -34,9 +38,19 @@ class QuotationProvider extends ChangeNotifier {
|
|||||||
Resource<PaymentQuote> _quotation = Resource(data: null, isLoading: false, error: null);
|
Resource<PaymentQuote> _quotation = Resource(data: null, isLoading: false, error: null);
|
||||||
late OrganizationsProvider _organizations;
|
late OrganizationsProvider _organizations;
|
||||||
bool _isLoaded = false;
|
bool _isLoaded = false;
|
||||||
|
bool _organizationAttached = false;
|
||||||
|
PaymentIntent? _pendingIntent;
|
||||||
|
String? _lastRequestSignature;
|
||||||
|
Timer? _debounceTimer;
|
||||||
|
Timer? _expirationTimer;
|
||||||
|
bool _autoRefreshEnabled = true;
|
||||||
|
bool _amountEditing = false;
|
||||||
|
|
||||||
|
static const _inputDebounce = Duration(milliseconds: 500);
|
||||||
|
static const _expiryGracePeriod = Duration(seconds: 1);
|
||||||
|
|
||||||
void update(
|
void update(
|
||||||
OrganizationsProvider venue,
|
OrganizationsProvider venue,
|
||||||
PaymentAmountProvider payment,
|
PaymentAmountProvider payment,
|
||||||
WalletsProvider wallets,
|
WalletsProvider wallets,
|
||||||
PaymentFlowProvider flow,
|
PaymentFlowProvider flow,
|
||||||
@@ -44,44 +58,81 @@ class QuotationProvider extends ChangeNotifier {
|
|||||||
PaymentMethodsProvider methods,
|
PaymentMethodsProvider methods,
|
||||||
) {
|
) {
|
||||||
_organizations = venue;
|
_organizations = venue;
|
||||||
final t = flow.selectedType;
|
_organizationAttached = true;
|
||||||
final method = methods.methods.firstWhereOrNull((m) => m.type == t);
|
final wasEditing = _amountEditing;
|
||||||
if ((wallets.selectedWallet != null) && (method != null)) {
|
_amountEditing = payment.isEditing;
|
||||||
final customer = _buildCustomer(
|
final editingJustEnded = wasEditing && !_amountEditing;
|
||||||
recipient: recipients.currentObject,
|
_pendingIntent = _buildIntent(
|
||||||
method: method,
|
payment: payment,
|
||||||
);
|
wallets: wallets,
|
||||||
getQuotation(PaymentIntent(
|
flow: flow,
|
||||||
kind: PaymentKind.payout,
|
recipients: recipients,
|
||||||
amount: Money(
|
methods: methods,
|
||||||
amount: payment.amount.toString(),
|
);
|
||||||
|
|
|||||||
// TODO: adapt to possible other sources
|
|
||||||
currency: currencyCodeToString(wallets.selectedWallet!.currency),
|
if (_pendingIntent == null) {
|
||||||
),
|
_reset();
|
||||||
destination: method.data,
|
return;
|
||||||
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,
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
bool get hasQuoteForCurrentIntent {
|
||||||
Asset? get total => quotation == null ? null : createAsset(quotation!.debitAmount!.currency, quotation!.debitAmount!.amount);
|
if (_pendingIntent == null || _lastRequestSignature == null) return false;
|
||||||
Asset? get recipientGets => quotation == null ? null : createAsset(quotation!.expectedSettlementAmount!.currency, quotation!.expectedSettlementAmount!.amount);
|
return _lastRequestSignature == _signature(_pendingIntent!);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get 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({
|
Customer _buildCustomer({
|
||||||
required Recipient? recipient,
|
required Recipient? recipient,
|
||||||
@@ -140,19 +191,128 @@ class QuotationProvider extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<PaymentQuote?> getQuotation(PaymentIntent intent) async {
|
void refreshNow({bool force = true}) {
|
||||||
if (!_organizations.isOrganizationSet) throw StateError('Organization is not set');
|
_debounceTimer?.cancel();
|
||||||
|
if (!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 {
|
try {
|
||||||
_quotation = _quotation.copyWith(isLoading: true, error: null);
|
|
||||||
final response = await QuotationService.getQuotation(
|
final response = await QuotationService.getQuotation(
|
||||||
_organizations.current.id,
|
_organizations.current.id,
|
||||||
QuotePaymentRequest(
|
QuotePaymentRequest(
|
||||||
idempotencyKey: Uuid().v4(),
|
idempotencyKey: Uuid().v4(),
|
||||||
intent: intent.toDTO(),
|
intent: intent.toDTO(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
_isLoaded = true;
|
_isLoaded = true;
|
||||||
|
_lastRequestSignature = signature;
|
||||||
_setResource(_quotation.copyWith(data: response, isLoading: false, error: null));
|
_setResource(_quotation.copyWith(data: response, isLoading: false, error: null));
|
||||||
|
_startExpirationTimer();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_setResource(_quotation.copyWith(
|
_setResource(_quotation.copyWith(
|
||||||
data: null,
|
data: null,
|
||||||
@@ -160,13 +320,68 @@ class QuotationProvider extends ChangeNotifier {
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
notifyListeners();
|
|
||||||
return _quotation.data;
|
return _quotation.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
void reset() {
|
void _startExpirationTimer() {
|
||||||
_setResource(Resource(data: null, isLoading: false, error: null));
|
_expirationTimer?.cancel();
|
||||||
|
final remaining = timeToExpire;
|
||||||
|
if (remaining == null) return;
|
||||||
|
|
||||||
|
final triggerOffset = _autoRefreshEnabled ? _expiryGracePeriod : Duration.zero;
|
||||||
|
final duration = remaining > triggerOffset ? remaining - triggerOffset : Duration.zero;
|
||||||
|
_expirationTimer = Timer(duration, () {
|
||||||
|
if (_autoRefreshEnabled && _pendingIntent != null) {
|
||||||
|
unawaited(_requestQuotation(_pendingIntent!, force: true));
|
||||||
|
} else {
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
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;
|
_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",
|
"payout": "Payout",
|
||||||
"sendTo": "Send Payout To",
|
"sendTo": "Send Payout To",
|
||||||
"send": "Send Payout",
|
"send": "Send Payout",
|
||||||
|
"quoteUnavailable": "Waiting for a quote...",
|
||||||
|
"quoteUpdating": "Refreshing quote...",
|
||||||
|
"quoteExpiresIn": "Quote expires in {time}",
|
||||||
|
"@quoteExpiresIn": {
|
||||||
|
"placeholders": {
|
||||||
|
"time": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"quoteExpired": "Quote expired, request a new one",
|
||||||
|
"quoteAutoRefresh": "Auto-refresh quote",
|
||||||
|
"quoteErrorGeneric": "Could not refresh quote, try again later",
|
||||||
"recipientPaysFee": "Recipient pays the fee",
|
"recipientPaysFee": "Recipient pays the fee",
|
||||||
|
|
||||||
"sentAmount": "Sent amount: {amount}",
|
"sentAmount": "Sent amount: {amount}",
|
||||||
|
|||||||
@@ -383,6 +383,19 @@
|
|||||||
"payout": "Выплата",
|
"payout": "Выплата",
|
||||||
"sendTo": "Отправить выплату",
|
"sendTo": "Отправить выплату",
|
||||||
"send": "Отправить выплату",
|
"send": "Отправить выплату",
|
||||||
|
"quoteUnavailable": "Ожидание котировки...",
|
||||||
|
"quoteUpdating": "Обновляем котировку...",
|
||||||
|
"quoteExpiresIn": "Котировка истекает через {time}",
|
||||||
|
"@quoteExpiresIn": {
|
||||||
|
"placeholders": {
|
||||||
|
"time": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"quoteExpired": "Срок котировки истек, запросите новую",
|
||||||
|
"quoteAutoRefresh": "Автообновление котировки",
|
||||||
|
"quoteErrorGeneric": "Не удалось обновить котировку, повторите позже",
|
||||||
"recipientPaysFee": "Получатель оплачивает комиссию",
|
"recipientPaysFee": "Получатель оплачивает комиссию",
|
||||||
|
|
||||||
"sentAmount": "Отправленная сумма: {amount}",
|
"sentAmount": "Отправленная сумма: {amount}",
|
||||||
|
|||||||
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:provider/provider.dart';
|
||||||
|
|
||||||
import 'package:pshared/provider/payment/amount.dart';
|
import 'package:pshared/provider/payment/amount.dart';
|
||||||
|
import 'package:pshared/provider/payment/quotation.dart';
|
||||||
import 'package:pshared/utils/currency.dart';
|
import 'package:pshared/utils/currency.dart';
|
||||||
|
|
||||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||||
@@ -17,6 +18,7 @@ class PaymentAmountWidget extends StatefulWidget {
|
|||||||
|
|
||||||
class _PaymentAmountWidgetState extends State<PaymentAmountWidget> {
|
class _PaymentAmountWidgetState extends State<PaymentAmountWidget> {
|
||||||
late final TextEditingController _controller;
|
late final TextEditingController _controller;
|
||||||
|
late final FocusNode _focusNode;
|
||||||
bool _isSyncingText = false;
|
bool _isSyncingText = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -24,10 +26,14 @@ class _PaymentAmountWidgetState extends State<PaymentAmountWidget> {
|
|||||||
super.initState();
|
super.initState();
|
||||||
final initialAmount = context.read<PaymentAmountProvider>().amount;
|
final initialAmount = context.read<PaymentAmountProvider>().amount;
|
||||||
_controller = TextEditingController(text: amountToString(initialAmount));
|
_controller = TextEditingController(text: amountToString(initialAmount));
|
||||||
|
_focusNode = FocusNode()..addListener(_handleFocusChange);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_focusNode.removeListener(_handleFocusChange);
|
||||||
|
_focusNode.dispose();
|
||||||
|
context.read<PaymentAmountProvider>().setEditing(false);
|
||||||
_controller.dispose();
|
_controller.dispose();
|
||||||
super.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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final amount = context.select<PaymentAmountProvider, double>((provider) => provider.amount);
|
final amount = context.select<PaymentAmountProvider, double>((provider) => provider.amount);
|
||||||
@@ -63,12 +79,14 @@ class _PaymentAmountWidgetState extends State<PaymentAmountWidget> {
|
|||||||
|
|
||||||
return TextField(
|
return TextField(
|
||||||
controller: _controller,
|
controller: _controller,
|
||||||
|
focusNode: _focusNode,
|
||||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: AppLocalizations.of(context)!.amount,
|
labelText: AppLocalizations.of(context)!.amount,
|
||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
onChanged: _onChanged,
|
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/models/recipient/recipient.dart';
|
||||||
import 'package:pshared/provider/payment/flow.dart';
|
import 'package:pshared/provider/payment/flow.dart';
|
||||||
import 'package:pshared/provider/payment/provider.dart';
|
import 'package:pshared/provider/payment/provider.dart';
|
||||||
|
import 'package:pshared/provider/payment/quotation.dart';
|
||||||
import 'package:pshared/provider/recipient/pmethods.dart';
|
import 'package:pshared/provider/recipient/pmethods.dart';
|
||||||
import 'package:pshared/provider/recipient/provider.dart';
|
import 'package:pshared/provider/recipient/provider.dart';
|
||||||
import 'package:pshared/provider/payment/wallets.dart';
|
import 'package:pshared/provider/payment/wallets.dart';
|
||||||
@@ -81,8 +82,16 @@ class _PaymentPageState extends State<PaymentPage> {
|
|||||||
void _handleSendPayment() {
|
void _handleSendPayment() {
|
||||||
final flowProvider = context.read<PaymentFlowProvider>();
|
final flowProvider = context.read<PaymentFlowProvider>();
|
||||||
final paymentProvider = context.read<PaymentProvider>();
|
final paymentProvider = context.read<PaymentProvider>();
|
||||||
|
final quotationProvider = context.read<QuotationProvider>();
|
||||||
if (paymentProvider.isLoading) return;
|
if (paymentProvider.isLoading) return;
|
||||||
|
|
||||||
|
if (!quotationProvider.hasLiveQuote) {
|
||||||
|
if (quotationProvider.canRequestQuote) {
|
||||||
|
quotationProvider.refreshNow();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
paymentProvider.pay().then((_) {
|
paymentProvider.pay().then((_) {
|
||||||
|
tech
commented
нужно это сообщение? Да еще в snackbar? нужно это сообщение? Да еще в snackbar?
|
|||||||
PosthogService.paymentInitiated(method: flowProvider.selectedType);
|
PosthogService.paymentInitiated(method: flowProvider.selectedType);
|
||||||
}).catchError((error) {
|
}).catchError((error) {
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:pshared/models/payment/wallet.dart';
|
import 'package:pshared/models/payment/wallet.dart';
|
||||||
import 'package:pshared/models/recipient/recipient.dart';
|
import 'package:pshared/models/recipient/recipient.dart';
|
||||||
import 'package:pshared/provider/recipient/provider.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/back_button.dart';
|
||||||
import 'package:pweb/pages/payment_methods/payment_page/header.dart';
|
import 'package:pweb/pages/payment_methods/payment_page/header.dart';
|
||||||
import 'package:pweb/pages/payment_methods/payment_page/method_selector.dart';
|
import 'package:pweb/pages/payment_methods/payment_page/method_selector.dart';
|
||||||
|
import 'package:pweb/pages/payment_methods/payment_page/quote/quote_status.dart';
|
||||||
import 'package:pweb/pages/payment_methods/payment_page/send_button.dart';
|
import 'package:pweb/pages/payment_methods/payment_page/send_button.dart';
|
||||||
import 'package:pweb/pages/payment_methods/widgets/payment_info_section.dart';
|
import 'package:pweb/pages/payment_methods/widgets/payment_info_section.dart';
|
||||||
import 'package:pweb/pages/payment_methods/widgets/recipient_section.dart';
|
import 'package:pweb/pages/payment_methods/widgets/recipient_section.dart';
|
||||||
@@ -94,7 +95,9 @@ class PaymentPageContent extends StatelessWidget {
|
|||||||
PaymentInfoSection(dimensions: dimensions),
|
PaymentInfoSection(dimensions: dimensions),
|
||||||
SizedBox(height: dimensions.paddingLarge),
|
SizedBox(height: dimensions.paddingLarge),
|
||||||
const PaymentFormWidget(),
|
const PaymentFormWidget(),
|
||||||
SizedBox(height: dimensions.paddingXXXLarge),
|
SizedBox(height: dimensions.paddingLarge),
|
||||||
|
const QuoteStatus(),
|
||||||
|
SizedBox(height: dimensions.paddingXXLarge),
|
||||||
SendButton(onPressed: onSend),
|
SendButton(onPressed: onSend),
|
||||||
SizedBox(height: dimensions.paddingLarge),
|
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: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/utils/dimensions.dart';
|
||||||
|
|
||||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
|
||||||
class SendButton extends StatelessWidget {
|
class SendButton extends StatelessWidget {
|
||||||
final VoidCallback onPressed;
|
final VoidCallback? onPressed;
|
||||||
|
|
||||||
|
tech
commented
а кнопка из провайдера не может состояние свое прочитать? нужно управление снаружи? а кнопка из провайдера не может состояние свое прочитать? нужно управление снаружи?
|
|||||||
const SendButton({super.key, required this.onPressed});
|
const SendButton({
|
||||||
|
super.key,
|
||||||
|
required this.onPressed,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) => Consumer2<QuotationProvider, PaymentProvider>(
|
||||||
final theme = Theme.of(context);
|
builder: (context, quotation, payment, _) {
|
||||||
final dimensions = AppDimensions();
|
final theme = Theme.of(context);
|
||||||
|
final dimensions = AppDimensions();
|
||||||
|
|
||||||
return Center(
|
final canSend = quotation.hasLiveQuote && !payment.isLoading;
|
||||||
child: SizedBox(
|
final state = payment.isLoading
|
||||||
width: dimensions.buttonWidth,
|
? ButtonState.loading
|
||||||
height: dimensions.buttonHeight,
|
: (canSend ? ButtonState.enabled : ButtonState.disabled);
|
||||||
child: InkWell(
|
final isLoading = state == ButtonState.loading;
|
||||||
borderRadius: BorderRadius.circular(dimensions.borderRadiusSmall),
|
final isActive = state == ButtonState.enabled && onPressed != null;
|
||||||
onTap: onPressed,
|
|
||||||
child: Container(
|
final backgroundColor = isActive
|
||||||
decoration: BoxDecoration(
|
? theme.colorScheme.primary
|
||||||
color: theme.colorScheme.primary,
|
: theme.colorScheme.primary.withValues(alpha: 0.5);
|
||||||
borderRadius: BorderRadius.circular(dimensions.borderRadiusSmall),
|
final textColor = theme.colorScheme.onSecondary.withValues(alpha: isActive ? 1 : 0.7);
|
||||||
),
|
|
||||||
child: Center(
|
return Center(
|
||||||
child: Text(
|
child: SizedBox(
|
||||||
AppLocalizations.of(context)!.send,
|
width: dimensions.buttonWidth,
|
||||||
style: theme.textTheme.bodyLarge?.copyWith(
|
height: dimensions.buttonHeight,
|
||||||
color: theme.colorScheme.onSecondary,
|
child: InkWell(
|
||||||
fontWeight: FontWeight.w600,
|
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
|
@override
|
||||||
Widget build(BuildContext context) => DropdownButtonFormField<Wallet>(
|
Widget build(BuildContext context) => DropdownButtonFormField<Wallet>(
|
||||||
dropdownColor: Theme.of(context).colorScheme.onSecondary,
|
dropdownColor: Theme.of(context).colorScheme.onSecondary,
|
||||||
value: _getSelectedMethod(),
|
initialValue: _getSelectedMethod(),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: AppLocalizations.of(context)!.whereGetMoney,
|
labelText: AppLocalizations.of(context)!.whereGetMoney,
|
||||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
|||||||
ваще-то при hasQuoteForCurrentIntent _quotation.data железобетонно должно быть null. Реально флажок этот нужен?
_lastRequestSignature ставится только когда успешный ответ получен, но само поле _quotation.data потом может стать null (например, при ошибке обновления котировки мы делаем copyWith(data: null, error: e) и _lastRequestSignature остаётся прежним)