Compare commits
5 Commits
f5bf8cf6d0
...
SEND021
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9a605ce21 | ||
|
|
c3ec50c8e4 | ||
|
|
f3ad4c2d4f | ||
| 4aeb06fd31 | |||
|
|
d1786dc5d9 |
@@ -22,7 +22,7 @@ require (
|
||||
|
||||
require (
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251225023818-8886bb81c549 // indirect
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251229120209-a0d175451f7b // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bits-and-blooms/bitset v1.24.4 // indirect
|
||||
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
|
||||
|
||||
@@ -6,8 +6,8 @@ github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ=
|
||||
github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251225023818-8886bb81c549 h1:NERDcANvDCnspxdMEMLXOMnuITWIWrTQvvhEA8ewBBM=
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251225023818-8886bb81c549/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251229120209-a0d175451f7b h1:g/wCbvJGhOAqfGBjWnqtD6CVsXdr3G4GCbjLR6z9kNw=
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251229120209-a0d175451f7b/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
|
||||
github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0=
|
||||
github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
|
||||
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
|
||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||
)
|
||||
@@ -75,7 +76,8 @@ type paymentQuotesResponse struct {
|
||||
|
||||
type paymentsResponse struct {
|
||||
authResponse `json:",inline"`
|
||||
Payments []Payment `json:"payments"`
|
||||
Payments []Payment `json:"payments"`
|
||||
Page *paginationv1.CursorPageResponse `json:"page,omitempty"`
|
||||
}
|
||||
|
||||
type paymentResponse struct {
|
||||
@@ -107,6 +109,15 @@ func PaymentsResponse(logger mlogger.Logger, payments []*orchestratorv1.Payment,
|
||||
})
|
||||
}
|
||||
|
||||
// PaymentsList wraps a list of payments with refreshed access token and pagination data.
|
||||
func PaymentsListResponse(logger mlogger.Logger, resp *orchestratorv1.ListPaymentsResponse, token *TokenData) http.HandlerFunc {
|
||||
return response.Ok(logger, paymentsResponse{
|
||||
Payments: toPayments(resp.GetPayments()),
|
||||
Page: resp.GetPage(),
|
||||
authResponse: authResponse{AccessToken: *token},
|
||||
})
|
||||
}
|
||||
|
||||
// Payment wraps a payment with refreshed access token.
|
||||
func PaymentResponse(logger mlogger.Logger, payment *orchestratorv1.Payment, token *TokenData) http.HandlerFunc {
|
||||
return response.Ok(logger, paymentResponse{
|
||||
|
||||
153
api/server/internal/server/paymentapiimp/list.go
Normal file
153
api/server/internal/server/paymentapiimp/list.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package paymentapiimp
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/pkg/mutil/mzap"
|
||||
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
|
||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||
"github.com/tech/sendico/server/interface/api/sresponse"
|
||||
mutil "github.com/tech/sendico/server/internal/mutil/param"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const maxInt32 = int64(1<<31 - 1)
|
||||
|
||||
func (a *PaymentAPI) listPayments(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc {
|
||||
orgRef, err := a.oph.GetRef(r)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to parse organization reference for payments list", zap.Error(err), mutil.PLog(a.oph, r))
|
||||
return response.BadReference(a.logger, a.Name(), a.oph.Name(), a.oph.GetID(r), err)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
allowed, err := a.enf.Enforce(ctx, a.permissionRef, account.ID, orgRef, primitive.NilObjectID, model.ActionRead)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to check payments access permissions", zap.Error(err), mutil.PLog(a.oph, r))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
if !allowed {
|
||||
a.logger.Debug("Access denied when listing payments", mutil.PLog(a.oph, r))
|
||||
return response.AccessDenied(a.logger, a.Name(), "payments read permission denied")
|
||||
}
|
||||
|
||||
req := &orchestratorv1.ListPaymentsRequest{
|
||||
Meta: &orchestratorv1.RequestMeta{
|
||||
OrganizationRef: orgRef.Hex(),
|
||||
},
|
||||
}
|
||||
|
||||
if page, err := listPaymentsPage(r); err != nil {
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
} else if page != nil {
|
||||
req.Page = page
|
||||
}
|
||||
|
||||
query := r.URL.Query()
|
||||
if sourceRef := strings.TrimSpace(query.Get("source_ref")); sourceRef != "" {
|
||||
req.SourceRef = sourceRef
|
||||
}
|
||||
if destinationRef := strings.TrimSpace(query.Get("destination_ref")); destinationRef != "" {
|
||||
req.DestinationRef = destinationRef
|
||||
}
|
||||
|
||||
if states, err := parsePaymentStateFilters(r); err != nil {
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
} else if len(states) > 0 {
|
||||
req.FilterStates = states
|
||||
}
|
||||
|
||||
resp, err := a.client.ListPayments(ctx, req)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to list payments", zap.Error(err), mzap.ObjRef("organization_ref", orgRef))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
return sresponse.PaymentsListResponse(a.logger, resp, token)
|
||||
}
|
||||
|
||||
func listPaymentsPage(r *http.Request) (*paginationv1.CursorPageRequest, error) {
|
||||
query := r.URL.Query()
|
||||
cursor := strings.TrimSpace(query.Get("cursor"))
|
||||
limitRaw := strings.TrimSpace(query.Get("limit"))
|
||||
|
||||
var limit int64
|
||||
hasLimit := false
|
||||
if limitRaw != "" {
|
||||
parsed, err := strconv.ParseInt(limitRaw, 10, 32)
|
||||
if err != nil {
|
||||
return nil, merrors.InvalidArgument("invalid limit", "limit")
|
||||
}
|
||||
limit = parsed
|
||||
hasLimit = true
|
||||
}
|
||||
|
||||
if cursor == "" && !hasLimit {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
page := &paginationv1.CursorPageRequest{
|
||||
Cursor: cursor,
|
||||
}
|
||||
if hasLimit {
|
||||
if limit < 0 {
|
||||
limit = 0
|
||||
} else if limit > maxInt32 {
|
||||
limit = maxInt32
|
||||
}
|
||||
page.Limit = int32(limit)
|
||||
}
|
||||
|
||||
return page, nil
|
||||
}
|
||||
|
||||
func parsePaymentStateFilters(r *http.Request) ([]orchestratorv1.PaymentState, error) {
|
||||
query := r.URL.Query()
|
||||
values := append([]string{}, query["state"]...)
|
||||
values = append(values, query["states"]...)
|
||||
values = append(values, query["filter_states"]...)
|
||||
if len(values) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
states := make([]orchestratorv1.PaymentState, 0, len(values))
|
||||
for _, raw := range values {
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
trimmed := strings.TrimSpace(part)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
state, ok := paymentStateFromString(trimmed)
|
||||
if !ok {
|
||||
return nil, merrors.InvalidArgument("unsupported payment state: "+trimmed, "state")
|
||||
}
|
||||
states = append(states, state)
|
||||
}
|
||||
}
|
||||
|
||||
if len(states) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return states, nil
|
||||
}
|
||||
|
||||
func paymentStateFromString(value string) (orchestratorv1.PaymentState, bool) {
|
||||
upper := strings.ToUpper(strings.TrimSpace(value))
|
||||
if upper == "" {
|
||||
return 0, false
|
||||
}
|
||||
if !strings.HasPrefix(upper, "PAYMENT_STATE_") {
|
||||
upper = "PAYMENT_STATE_" + upper
|
||||
}
|
||||
enumValue, ok := orchestratorv1.PaymentState_value[upper]
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
return orchestratorv1.PaymentState(enumValue), true
|
||||
}
|
||||
@@ -25,6 +25,7 @@ type paymentClient interface {
|
||||
QuotePayments(ctx context.Context, req *orchestratorv1.QuotePaymentsRequest) (*orchestratorv1.QuotePaymentsResponse, error)
|
||||
InitiatePayments(ctx context.Context, req *orchestratorv1.InitiatePaymentsRequest) (*orchestratorv1.InitiatePaymentsResponse, error)
|
||||
InitiatePayment(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error)
|
||||
ListPayments(ctx context.Context, req *orchestratorv1.ListPaymentsRequest) (*orchestratorv1.ListPaymentsResponse, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
@@ -72,6 +73,7 @@ func CreateAPI(apiCtx eapi.API) (*PaymentAPI, error) {
|
||||
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/immediate"), api.Post, p.initiateImmediate)
|
||||
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/by-quote"), api.Post, p.initiateByQuote)
|
||||
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/by-multiquote"), api.Post, p.initiatePaymentsByQuote)
|
||||
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/"), api.Get, p.listPayments)
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
20
frontend/pshared/lib/api/responses/payment/payments.dart
Normal file
20
frontend/pshared/lib/api/responses/payment/payments.dart
Normal file
@@ -0,0 +1,20 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
import 'package:pshared/api/responses/base.dart';
|
||||
import 'package:pshared/api/responses/token.dart';
|
||||
import 'package:pshared/data/dto/payment/payment.dart';
|
||||
|
||||
part 'payments.g.dart';
|
||||
|
||||
|
||||
@JsonSerializable(explicitToJson: true)
|
||||
class PaymentsResponse extends BaseAuthorizedResponse {
|
||||
|
||||
final List<PaymentDTO> payments;
|
||||
|
||||
const PaymentsResponse({required super.accessToken, required this.payments});
|
||||
|
||||
factory PaymentsResponse.fromJson(Map<String, dynamic> json) => _$PaymentsResponseFromJson(json);
|
||||
@override
|
||||
Map<String, dynamic> toJson() => _$PaymentsResponseToJson(this);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
_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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import 'package:pshared/api/requests/payment/initiate.dart';
|
||||
import 'package:pshared/api/responses/payment/payment.dart';
|
||||
import 'package:pshared/api/responses/payment/payments.dart';
|
||||
import 'package:pshared/data/mapper/payment/payment_response.dart';
|
||||
import 'package:pshared/models/payment/payment.dart';
|
||||
import 'package:pshared/service/authorization/service.dart';
|
||||
@@ -13,6 +15,40 @@ class PaymentService {
|
||||
static final _logger = Logger('service.payment');
|
||||
static const String _objectType = Services.payments;
|
||||
|
||||
static Future<List<Payment>> list(
|
||||
String organizationRef, {
|
||||
int? limit,
|
||||
String? cursor,
|
||||
String? sourceRef,
|
||||
String? destinationRef,
|
||||
List<String>? states,
|
||||
}) async {
|
||||
_logger.fine('Listing payments for organization $organizationRef');
|
||||
final queryParams = <String, String>{};
|
||||
if (limit != null) {
|
||||
queryParams['limit'] = limit.toString();
|
||||
}
|
||||
if (cursor != null && cursor.isNotEmpty) {
|
||||
queryParams['cursor'] = cursor;
|
||||
}
|
||||
if (sourceRef != null && sourceRef.isNotEmpty) {
|
||||
queryParams['source_ref'] = sourceRef;
|
||||
}
|
||||
if (destinationRef != null && destinationRef.isNotEmpty) {
|
||||
queryParams['destination_ref'] = destinationRef;
|
||||
}
|
||||
if (states != null && states.isNotEmpty) {
|
||||
queryParams['state'] = states.join(',');
|
||||
}
|
||||
|
||||
final path = '/$organizationRef';
|
||||
final url = queryParams.isEmpty
|
||||
? path
|
||||
: Uri(path: path, queryParameters: queryParams).toString();
|
||||
final response = await AuthorizationService.getGETResponse(_objectType, url);
|
||||
return PaymentsResponse.fromJson(response).payments.map((payment) => payment.toDomain()).toList();
|
||||
}
|
||||
|
||||
static Future<Payment> pay(
|
||||
String organizationRef,
|
||||
String quotationRef, {
|
||||
|
||||
@@ -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
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) {
|
||||
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((_) {
|
||||
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,
|
||||
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) {
|
||||
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;
|
||||
|
||||
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)),
|
||||
|
||||
Reference in New Issue
Block a user