redesigned payment page + a lot of fixes

This commit is contained in:
Arseni
2026-02-21 21:55:20 +03:00
parent a68aa2abff
commit 0c6fa03aba
208 changed files with 4062 additions and 2217 deletions

View File

@@ -1,9 +1,15 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:share_plus/share_plus.dart';
import 'package:pshared/config/constants.dart';
import 'package:pshared/models/organization/organization.dart';
import 'package:pshared/models/auth/state.dart';
import 'package:pshared/provider/account.dart';
import 'package:pshared/provider/resource.dart';
import 'package:pshared/service/organization.dart';
import 'package:pshared/service/secure_storage.dart';
@@ -16,6 +22,7 @@ class OrganizationsProvider extends ChangeNotifier {
List<Organization> get organizations => _resource.data ?? [];
String? _currentOrg;
AccountProvider? _accountProvider;
Organization get current => isOrganizationSet ? _current! : throw StateError('Organization is not set');
@@ -26,6 +33,13 @@ class OrganizationsProvider extends ChangeNotifier {
bool get isLoading => _resource.isLoading;
Object? get error => _resource.error;
void updateAccount(AccountProvider accountProvider) {
if (!identical(_accountProvider, accountProvider)) {
_accountProvider = accountProvider;
}
_triggerLoadIfNeeded(accountProvider);
}
void _setResource(Resource<List<Organization>> newResource) {
_resource = newResource;
notifyListeners();
@@ -52,6 +66,14 @@ class OrganizationsProvider extends ChangeNotifier {
}
}
void _triggerLoadIfNeeded(AccountProvider accountProvider) {
if (accountProvider.authState != AuthState.ready) return;
if (accountProvider.account == null) return;
if (isLoading || isOrganizationSet) return;
if (error != null) return;
unawaited(load());
}
Future<Organization> loadByInvitation(String invitationRef) async {
_setResource(_resource.copyWith(isLoading: true, error: null));
try {
@@ -88,4 +110,55 @@ class OrganizationsProvider extends ChangeNotifier {
// Best-effort cleanup of stored selection to avoid using stale org on next login.
await SecureStorageService.delete(Constants.currentOrgKey);
}
Future<Organization> uploadLogo(XFile logoFile) async {
if (!isOrganizationSet) {
throw StateError('Organization is not set');
}
_setResource(_resource.copyWith(isLoading: true, error: null));
try {
final updated = await OrganizationService.uploadLogoAndUpdate(current, logoFile);
final updatedList = organizations
.map((org) => org.id == updated.id ? updated : org)
.toList(growable: false);
_setResource(Resource(data: updatedList, isLoading: false));
_currentOrg = updated.id;
return updated;
} catch (e) {
_setResource(_resource.copyWith(isLoading: false, error: toException(e)));
rethrow;
}
}
Future<Organization> updateCurrent({
String? name,
String? description,
String? timeZone,
String? logoUrl,
}) async {
if (!isOrganizationSet) {
throw StateError('Organization is not set');
}
_setResource(_resource.copyWith(isLoading: true, error: null));
try {
final updated = await OrganizationService.updateSettings(
current,
name: name,
description: description,
timeZone: timeZone,
logoUrl: logoUrl,
);
final updatedList = organizations
.map((org) => org.id == updated.id ? updated : org)
.toList(growable: false);
_setResource(Resource(data: updatedList, isLoading: false));
_currentOrg = updated.id;
return updated;
} catch (e) {
_setResource(_resource.copyWith(isLoading: false, error: toException(e)));
rethrow;
}
}
}

View File

@@ -0,0 +1,78 @@
import 'dart:async';
class AutoRefreshScheduler {
bool _enabled = true;
Timer? _timer;
DateTime? _scheduledAt;
DateTime? _triggeredAt;
void setEnabled(bool enabled) {
if (_enabled == enabled) return;
_enabled = enabled;
if (!enabled) {
_clear();
}
}
void sync({
required bool isLoading,
required bool canRefresh,
required DateTime? scheduledAt,
required Future<void> Function() onRefresh,
}) {
if (!_enabled || isLoading || !canRefresh) {
_clear();
return;
}
if (scheduledAt == null) {
_clear();
return;
}
final delay = scheduledAt.difference(DateTime.now().toUtc());
if (delay <= Duration.zero) {
if (_triggeredAt != null && _triggeredAt!.isAtSameMomentAs(scheduledAt)) {
return;
}
_triggeredAt = scheduledAt;
_clearTimer();
onRefresh();
return;
}
if (_scheduledAt != null &&
_scheduledAt!.isAtSameMomentAs(scheduledAt) &&
_timer?.isActive == true) {
return;
}
_triggeredAt = null;
_clearTimer();
_scheduledAt = scheduledAt;
_timer = Timer(delay, () {
onRefresh();
});
}
void reset() {
_enabled = false;
_clear();
}
void dispose() {
_clear();
}
void _clear() {
_scheduledAt = null;
_triggeredAt = null;
_clearTimer();
}
void _clearTimer() {
_timer?.cancel();
_timer = null;
}
}

View File

@@ -7,16 +7,20 @@ import 'package:pshared/data/mapper/payment/intent/payment.dart';
import 'package:pshared/models/payment/intent.dart';
import 'package:pshared/models/payment/quote/quotes.dart';
import 'package:pshared/provider/organizations.dart';
import 'package:pshared/provider/payment/auto_refresh.dart';
import 'package:pshared/provider/resource.dart';
import 'package:pshared/service/payment/multiple.dart';
import 'package:pshared/utils/exception.dart';
class MultiQuotationProvider extends ChangeNotifier {
static const Duration _autoRefreshLead = Duration(seconds: 5);
OrganizationsProvider? _organizations;
String? _loadedOrganizationRef;
Resource<PaymentQuotes> _quotation = Resource(data: null);
final AutoRefreshScheduler _autoRefresh = AutoRefreshScheduler();
List<PaymentIntent>? _lastIntents;
bool _lastPreviewOnly = false;
@@ -125,12 +129,32 @@ class MultiQuotationProvider extends ChangeNotifier {
_lastPreviewOnly = false;
_lastMetadata = null;
_quotation = Resource(data: null);
_syncAutoRefresh();
notifyListeners();
}
void _setResource(Resource<PaymentQuotes> quotation) {
_quotation = quotation;
_syncAutoRefresh();
notifyListeners();
}
void _syncAutoRefresh() {
final scheduledAt = quoteExpiresAt?.subtract(_autoRefreshLead);
_autoRefresh.setEnabled(true);
_autoRefresh.sync(
isLoading: isLoading,
canRefresh: canRefresh,
scheduledAt: scheduledAt,
onRefresh: () async {
await refreshQuotation();
},
);
}
@override
void dispose() {
_autoRefresh.dispose();
super.dispose();
}
}

View File

@@ -10,6 +10,8 @@ import 'package:pshared/utils/exception.dart';
class PaymentsProvider with ChangeNotifier {
static const Duration _pendingRefreshInterval = Duration(seconds: 10);
OrganizationsProvider? _organizations;
String? _loadedOrganizationRef;
@@ -17,15 +19,14 @@ class PaymentsProvider with ChangeNotifier {
bool _isLoaded = false;
bool _isLoadingMore = false;
String? _nextCursor;
Timer? _autoRefreshTimer;
int _autoRefreshRefs = 0;
Duration _autoRefreshInterval = const Duration(seconds: 15);
int? _limit;
String? _sourceRef;
String? _destinationRef;
List<String>? _states;
int _opSeq = 0;
Timer? _pendingRefreshTimer;
bool _isPendingRefreshInFlight = false;
Resource<List<Payment>> get resource => _resource;
List<Payment> get payments => _resource.data ?? [];
@@ -37,23 +38,6 @@ class PaymentsProvider with ChangeNotifier {
String? get nextCursor => _nextCursor;
bool get canLoadMore => _nextCursor != null && _nextCursor!.isNotEmpty;
void beginAutoRefresh({Duration interval = const Duration(seconds: 15)}) {
_autoRefreshRefs += 1;
if (interval < _autoRefreshInterval) {
_autoRefreshInterval = interval;
_restartAutoRefreshTimer();
}
_ensureAutoRefreshTimer();
}
void endAutoRefresh() {
if (_autoRefreshRefs == 0) return;
_autoRefreshRefs -= 1;
if (_autoRefreshRefs == 0) {
_stopAutoRefreshTimer();
}
}
void update(OrganizationsProvider organizations) {
_organizations = organizations;
if (!organizations.isOrganizationSet) {
@@ -61,10 +45,6 @@ class PaymentsProvider with ChangeNotifier {
return;
}
if (_autoRefreshRefs > 0) {
_ensureAutoRefreshTimer();
}
final orgRef = organizations.current.id;
if (_loadedOrganizationRef != orgRef) {
_loadedOrganizationRef = orgRef;
@@ -104,6 +84,30 @@ class PaymentsProvider with ChangeNotifier {
);
}
void mergePayments(List<Payment> incoming) {
if (incoming.isEmpty) return;
final existing = List<Payment>.from(_resource.data ?? const []);
final combined = <Payment>[
...incoming,
...existing,
];
final seen = <String>{};
final merged = <Payment>[];
for (final payment in combined) {
final key = _paymentKey(payment);
if (key == null || key.isEmpty) {
merged.add(payment);
continue;
}
if (seen.contains(key)) continue;
seen.add(key);
merged.add(payment);
}
_applyResource(_resource.copyWith(data: merged), notify: true);
}
Future<void> _refresh({
int? limit,
String? sourceRef,
@@ -224,12 +228,13 @@ class PaymentsProvider with ChangeNotifier {
_destinationRef = null;
_states = null;
_resource = Resource(data: []);
_pauseAutoRefreshTimer();
_stopPendingRefreshTimer();
notifyListeners();
}
void _applyResource(Resource<List<Payment>> newResource, {required bool notify}) {
_resource = newResource;
_syncPendingRefresh();
if (notify) notifyListeners();
}
@@ -239,6 +244,12 @@ class PaymentsProvider with ChangeNotifier {
return trimmed;
}
String? _paymentKey(Payment payment) {
final ref = _normalize(payment.paymentRef);
if (ref != null) return ref;
return _normalize(payment.idempotencyKey);
}
List<String>? _normalizeStates(List<String>? states) {
if (states == null || states.isEmpty) return null;
final normalized = states
@@ -249,31 +260,70 @@ class PaymentsProvider with ChangeNotifier {
return normalized;
}
void _ensureAutoRefreshTimer() {
if (_autoRefreshTimer != null) return;
_autoRefreshTimer = Timer.periodic(_autoRefreshInterval, (_) {
if (_resource.isLoading || _isLoadingMore) return;
unawaited(refreshSilently());
});
void _syncPendingRefresh() {
final hasPending = payments.any(_isPending);
if (!hasPending) {
_stopPendingRefreshTimer();
return;
}
_ensurePendingRefreshTimer();
}
void _restartAutoRefreshTimer() {
if (_autoRefreshTimer == null) return;
_autoRefreshTimer?.cancel();
_autoRefreshTimer = null;
_ensureAutoRefreshTimer();
void _ensurePendingRefreshTimer() {
if (_pendingRefreshTimer != null) return;
_pendingRefreshTimer = Timer.periodic(
_pendingRefreshInterval,
(_) => _refreshPending(),
);
_refreshPending();
}
void _stopAutoRefreshTimer() {
_autoRefreshTimer?.cancel();
_autoRefreshTimer = null;
_autoRefreshRefs = 0;
_autoRefreshInterval = const Duration(seconds: 15);
Future<void> _refreshPending() async {
if (_isPendingRefreshInFlight) return;
if (isLoading || isLoadingMore) return;
_isPendingRefreshInFlight = true;
try {
await refreshSilently(
limit: _limit,
sourceRef: _sourceRef,
destinationRef: _destinationRef,
states: _states,
);
} finally {
_isPendingRefreshInFlight = false;
}
}
void _pauseAutoRefreshTimer() {
_autoRefreshTimer?.cancel();
_autoRefreshTimer = null;
void _stopPendingRefreshTimer() {
_pendingRefreshTimer?.cancel();
_pendingRefreshTimer = null;
_isPendingRefreshInFlight = false;
}
bool _isPending(Payment payment) {
final raw = payment.state;
final trimmed = (raw ?? '').trim().toUpperCase();
final normalized = trimmed.startsWith('PAYMENT_STATE_')
? trimmed.substring('PAYMENT_STATE_'.length)
: trimmed;
switch (normalized) {
case 'SUCCESS':
case 'FAILED':
case 'CANCELLED':
return false;
case 'PROCESSING':
return true;
default:
return true;
}
}
@override
void dispose() {
_stopPendingRefreshTimer();
super.dispose();
}
}

View File

@@ -13,9 +13,11 @@ import 'package:pshared/models/asset.dart';
import 'package:pshared/models/payment/intent.dart';
import 'package:pshared/models/payment/quote/quote.dart';
import 'package:pshared/models/money.dart';
import 'package:pshared/models/auto_refresh_mode.dart';
import 'package:pshared/provider/organizations.dart';
import 'package:pshared/provider/payment/amount.dart';
import 'package:pshared/provider/payment/flow.dart';
import 'package:pshared/provider/payment/auto_refresh.dart';
import 'package:pshared/provider/recipient/provider.dart';
import 'package:pshared/provider/recipient/pmethods.dart';
import 'package:pshared/provider/resource.dart';
@@ -31,6 +33,8 @@ class QuotationProvider extends ChangeNotifier {
bool _isLoaded = false;
PaymentIntent? _lastIntent;
final QuotationIntentBuilder _intentBuilder = QuotationIntentBuilder();
final AutoRefreshScheduler _autoRefresh = AutoRefreshScheduler();
AutoRefreshMode _autoRefreshMode = AutoRefreshMode.on;
void update(
OrganizationsProvider venue,
@@ -59,6 +63,7 @@ class QuotationProvider extends ChangeNotifier {
Exception? get error => _quotation.error;
bool get canRefresh => _lastIntent != null;
bool get isReady => _isLoaded && !_quotation.isLoading && _quotation.error == null;
AutoRefreshMode get autoRefreshMode => _autoRefreshMode;
DateTime? get quoteExpiresAt {
final expiresAtUnixMs = quotation?.fxQuote?.expiresAtUnixMs;
@@ -78,6 +83,14 @@ class QuotationProvider extends ChangeNotifier {
void _setResource(Resource<PaymentQuote> quotation) {
_quotation = quotation;
_syncAutoRefresh();
notifyListeners();
}
void setAutoRefreshMode(AutoRefreshMode mode) {
if (_autoRefreshMode == mode) return;
_autoRefreshMode = mode;
_syncAutoRefresh();
notifyListeners();
}
@@ -122,4 +135,24 @@ class QuotationProvider extends ChangeNotifier {
final payload = jsonEncode(intent.toDTO().toJson());
return Uuid().v5(Namespace.url.value, 'quote:$payload');
}
void _syncAutoRefresh() {
final isEnabled = _autoRefreshMode == AutoRefreshMode.on;
_autoRefresh.setEnabled(isEnabled);
final canAutoRefresh = isEnabled && canRefresh;
_autoRefresh.sync(
isLoading: isLoading,
canRefresh: canAutoRefresh,
scheduledAt: quoteExpiresAt,
onRefresh: () async {
await refreshQuotation();
},
);
}
@override
void dispose() {
_autoRefresh.dispose();
super.dispose();
}
}

View File

@@ -0,0 +1,96 @@
import 'package:flutter/foundation.dart';
import 'package:pshared/models/payment/payment.dart';
import 'package:pshared/provider/payment/multiple/provider.dart';
import 'package:pshared/provider/payment/payments.dart';
import 'package:pshared/provider/payment/provider.dart';
class PaymentsUpdatesProvider extends ChangeNotifier {
PaymentsProvider? _payments;
PaymentProvider? _paymentProvider;
MultiPaymentProvider? _multiPaymentProvider;
Set<String> _knownKeys = {};
void update({
required PaymentsProvider paymentsProvider,
required PaymentProvider paymentProvider,
required MultiPaymentProvider multiPaymentProvider,
}) {
if (!identical(_payments, paymentsProvider)) {
_payments = paymentsProvider;
_syncKnownKeys();
}
if (!identical(_paymentProvider, paymentProvider)) {
_paymentProvider?.removeListener(_onSinglePaymentChanged);
_paymentProvider = paymentProvider;
_paymentProvider?.addListener(_onSinglePaymentChanged);
}
if (!identical(_multiPaymentProvider, multiPaymentProvider)) {
_multiPaymentProvider?.removeListener(_onMultiPaymentChanged);
_multiPaymentProvider = multiPaymentProvider;
_multiPaymentProvider?.addListener(_onMultiPaymentChanged);
}
_syncKnownKeys();
}
void _syncKnownKeys() {
_knownKeys = {
for (final payment in _payments?.payments ?? const <Payment>[])
..._keyFor(payment),
};
}
void _onSinglePaymentChanged() {
final payment = _paymentProvider?.payment;
if (payment == null) return;
final key = _key(payment);
if (key != null && _knownKeys.contains(key)) return;
_merge([payment]);
}
void _onMultiPaymentChanged() {
final payments = _multiPaymentProvider?.payments ?? const <Payment>[];
if (payments.isEmpty) return;
final incoming = <Payment>[];
for (final payment in payments) {
final key = _key(payment);
if (key == null || !_knownKeys.contains(key)) {
incoming.add(payment);
}
}
if (incoming.isEmpty) return;
_merge(incoming);
}
void _merge(List<Payment> incoming) {
if (incoming.isEmpty) return;
_payments?.mergePayments(incoming);
for (final payment in incoming) {
final key = _key(payment);
if (key != null) {
_knownKeys.add(key);
}
}
}
String? _key(Payment payment) {
final ref = payment.paymentRef?.trim();
if (ref != null && ref.isNotEmpty) return ref;
final idempotency = payment.idempotencyKey?.trim();
if (idempotency != null && idempotency.isNotEmpty) return idempotency;
return null;
}
Iterable<String> _keyFor(Payment payment) sync* {
final key = _key(payment);
if (key != null) yield key;
}
@override
void dispose() {
_paymentProvider?.removeListener(_onSinglePaymentChanged);
_multiPaymentProvider?.removeListener(_onMultiPaymentChanged);
super.dispose();
}
}