Merge pull request 'redesigned payment page + a lot of fix' (#546) from SEND055 into main
All checks were successful
ci/woodpecker/push/frontend Pipeline was successful
All checks were successful
ci/woodpecker/push/frontend Pipeline was successful
Reviewed-on: #546 Reviewed-by: tech <tech.sendico@proton.me>
This commit was merged in pull request #546.
This commit is contained in:
@@ -1,7 +1,5 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
|
||||
import 'package:pshared/models/payment/wallet.dart';
|
||||
import 'package:pshared/provider/payment/wallets.dart';
|
||||
|
||||
@@ -15,6 +13,9 @@ class WalletsController with ChangeNotifier {
|
||||
final Set<String> _maskedBalanceWalletRefs = <String>{};
|
||||
Set<String> _knownWalletRefs = <String>{};
|
||||
|
||||
List<Wallet> _walletsList = <Wallet>[];
|
||||
Map<String, Wallet> _walletsById = <String, Wallet>{};
|
||||
|
||||
String? _selectedWalletRef;
|
||||
|
||||
bool get isLoading => _wallets.isLoading;
|
||||
@@ -33,8 +34,11 @@ class WalletsController with ChangeNotifier {
|
||||
_selectedWalletRef = null;
|
||||
}
|
||||
|
||||
_walletsById = _uniqueWalletsById(wallets.wallets);
|
||||
_walletsList = _walletsById.values.toList(growable: false);
|
||||
|
||||
// Remove ids that no longer exist
|
||||
final ids = wallets.wallets.map((w) => w.id).toSet();
|
||||
final ids = _walletsById.keys.toSet();
|
||||
_maskedBalanceWalletRefs.removeWhere((id) => !ids.contains(id));
|
||||
|
||||
final newIds = ids.difference(_knownWalletRefs);
|
||||
@@ -45,13 +49,13 @@ class WalletsController with ChangeNotifier {
|
||||
|
||||
_selectedWalletRef = _resolveSelectedId(
|
||||
currentRef: _selectedWalletRef,
|
||||
wallets: wallets.wallets,
|
||||
wallets: _walletsList,
|
||||
);
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
List<Wallet> get wallets => _wallets.wallets;
|
||||
List<Wallet> get wallets => _walletsList;
|
||||
|
||||
bool isBalanceMasked(String walletRef) => _maskedBalanceWalletRefs.contains(walletRef);
|
||||
bool isBalanceVisible(String walletRef) => !isBalanceMasked(walletRef);
|
||||
@@ -62,7 +66,7 @@ class WalletsController with ChangeNotifier {
|
||||
Wallet? get selectedWallet {
|
||||
final id = _selectedWalletRef;
|
||||
if (id == null) return null;
|
||||
return wallets.firstWhereOrNull((w) => w.id == id);
|
||||
return _walletsById[id];
|
||||
}
|
||||
|
||||
String? get selectedWalletRef => _selectedWalletRef;
|
||||
@@ -103,4 +107,12 @@ class WalletsController with ChangeNotifier {
|
||||
// Fallback to the first wallet
|
||||
return wallets.first.id;
|
||||
}
|
||||
|
||||
Map<String, Wallet> _uniqueWalletsById(List<Wallet> wallets) {
|
||||
final result = <String, Wallet>{};
|
||||
for (final wallet in wallets) {
|
||||
result.putIfAbsent(wallet.id, () => wallet);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,20 @@ import 'package:pshared/models/permissions/bound.dart';
|
||||
import 'package:pshared/models/storable.dart';
|
||||
|
||||
|
||||
extension PaymentMethodDataJsonMapper on PaymentMethodData {
|
||||
Map<String, dynamic> toJsonMap() => switch (this) {
|
||||
CardPaymentMethod card => card.toDTO().toJson(),
|
||||
CardTokenPaymentMethod cardToken => cardToken.toDTO().toJson(),
|
||||
IbanPaymentMethod iban => iban.toDTO().toJson(),
|
||||
RussianBankAccountPaymentMethod bankAccount => bankAccount.toDTO().toJson(),
|
||||
WalletPaymentMethod wallet => wallet.toDTO().toJson(),
|
||||
CryptoAddressPaymentMethod crypto => crypto.toDTO().toJson(),
|
||||
LedgerPaymentMethod ledger => ledger.toDTO().toJson(),
|
||||
ManagedWalletPaymentMethod managedWallet => managedWallet.toDTO().toJson(),
|
||||
_ => throw UnsupportedError('Unsupported payment method data: $runtimeType'),
|
||||
};
|
||||
}
|
||||
|
||||
extension PaymentMethodMapper on PaymentMethod {
|
||||
PaymentMethodDTO toDTO() => PaymentMethodDTO(
|
||||
id: storable.id,
|
||||
@@ -49,17 +63,7 @@ extension PaymentMethodMapper on PaymentMethod {
|
||||
isMain: isMain,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _dataToJson(PaymentMethodData data) => switch (data) {
|
||||
CardPaymentMethod card => card.toDTO().toJson(),
|
||||
CardTokenPaymentMethod cardToken => cardToken.toDTO().toJson(),
|
||||
IbanPaymentMethod iban => iban.toDTO().toJson(),
|
||||
RussianBankAccountPaymentMethod bankAccount => bankAccount.toDTO().toJson(),
|
||||
WalletPaymentMethod wallet => wallet.toDTO().toJson(),
|
||||
CryptoAddressPaymentMethod crypto => crypto.toDTO().toJson(),
|
||||
LedgerPaymentMethod ledger => ledger.toDTO().toJson(),
|
||||
ManagedWalletPaymentMethod managedWallet => managedWallet.toDTO().toJson(),
|
||||
_ => throw UnsupportedError('Unsupported payment method data: ${data.runtimeType}'),
|
||||
};
|
||||
Map<String, dynamic> _dataToJson(PaymentMethodData data) => data.toJsonMap();
|
||||
}
|
||||
|
||||
extension PaymentMethodDTOMapper on PaymentMethodDTO {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'dart:async';
|
||||
|
||||
|
||||
class QuotationAutoRefreshController {
|
||||
class AutoRefreshScheduler {
|
||||
bool _enabled = true;
|
||||
Timer? _timer;
|
||||
DateTime? _scheduledAt;
|
||||
@@ -10,48 +10,47 @@ class QuotationAutoRefreshController {
|
||||
void setEnabled(bool enabled) {
|
||||
if (_enabled == enabled) return;
|
||||
_enabled = enabled;
|
||||
if (!enabled) {
|
||||
_clear();
|
||||
}
|
||||
}
|
||||
|
||||
void sync({
|
||||
required bool isLoading,
|
||||
required bool canRefresh,
|
||||
required DateTime? expiresAt,
|
||||
required DateTime? scheduledAt,
|
||||
required Future<void> Function() onRefresh,
|
||||
}) {
|
||||
if (!_enabled || isLoading || !canRefresh) {
|
||||
_clearTimer();
|
||||
_scheduledAt = null;
|
||||
_triggeredAt = null;
|
||||
_clear();
|
||||
return;
|
||||
}
|
||||
|
||||
if (expiresAt == null) {
|
||||
_clearTimer();
|
||||
_scheduledAt = null;
|
||||
_triggeredAt = null;
|
||||
if (scheduledAt == null) {
|
||||
_clear();
|
||||
return;
|
||||
}
|
||||
|
||||
final delay = expiresAt.difference(DateTime.now().toUtc());
|
||||
final delay = scheduledAt.difference(DateTime.now().toUtc());
|
||||
if (delay <= Duration.zero) {
|
||||
if (_triggeredAt != null && _triggeredAt!.isAtSameMomentAs(expiresAt)) {
|
||||
if (_triggeredAt != null && _triggeredAt!.isAtSameMomentAs(scheduledAt)) {
|
||||
return;
|
||||
}
|
||||
_triggeredAt = expiresAt;
|
||||
_triggeredAt = scheduledAt;
|
||||
_clearTimer();
|
||||
onRefresh();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_scheduledAt != null &&
|
||||
_scheduledAt!.isAtSameMomentAs(expiresAt) &&
|
||||
_scheduledAt!.isAtSameMomentAs(scheduledAt) &&
|
||||
_timer?.isActive == true) {
|
||||
return;
|
||||
}
|
||||
|
||||
_triggeredAt = null;
|
||||
_clearTimer();
|
||||
_scheduledAt = expiresAt;
|
||||
_scheduledAt = scheduledAt;
|
||||
_timer = Timer(delay, () {
|
||||
onRefresh();
|
||||
});
|
||||
@@ -59,12 +58,16 @@ class QuotationAutoRefreshController {
|
||||
|
||||
void reset() {
|
||||
_enabled = false;
|
||||
_scheduledAt = null;
|
||||
_triggeredAt = null;
|
||||
_clearTimer();
|
||||
_clear();
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_clear();
|
||||
}
|
||||
|
||||
void _clear() {
|
||||
_scheduledAt = null;
|
||||
_triggeredAt = null;
|
||||
_clearTimer();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
96
frontend/pshared/lib/provider/payment/updates.dart
Normal file
96
frontend/pshared/lib/provider/payment/updates.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import 'package:share_plus/share_plus.dart';
|
||||
|
||||
import 'package:pshared/api/errors/unauthorized.dart';
|
||||
import 'package:pshared/api/responses/organization.dart';
|
||||
import 'package:pshared/models/describable.dart';
|
||||
import 'package:pshared/models/organization/organization.dart';
|
||||
import 'package:pshared/data/mapper/organization.dart';
|
||||
import 'package:pshared/service/authorization/service.dart';
|
||||
@@ -41,6 +42,40 @@ class OrganizationService {
|
||||
);
|
||||
}
|
||||
|
||||
static Future<Organization> updateSettings(
|
||||
Organization organization, {
|
||||
String? name,
|
||||
String? description,
|
||||
String? timeZone,
|
||||
String? logoUrl,
|
||||
}) async {
|
||||
_logger.fine('Updating organization settings ${organization.id}');
|
||||
final updatedDescribable = (name != null || description != null)
|
||||
? organization.describable.copyWith(
|
||||
name: name,
|
||||
description: description != null ? () => description : null,
|
||||
)
|
||||
: organization.describable;
|
||||
|
||||
final updatedOrg = organization.copyWith(
|
||||
describable: updatedDescribable,
|
||||
timeZone: timeZone,
|
||||
logoUrl: logoUrl != null ? () => logoUrl : null,
|
||||
);
|
||||
|
||||
final updated = await update(updatedOrg);
|
||||
return updated.firstWhere(
|
||||
(org) => org.id == organization.id,
|
||||
orElse: () => updated.first,
|
||||
);
|
||||
}
|
||||
|
||||
static Future<Organization> uploadLogoAndUpdate(Organization organization, XFile logoFile) async {
|
||||
_logger.fine('Uploading logo for organization ${organization.id}');
|
||||
final url = await uploadLogo(organization.id, logoFile);
|
||||
return updateSettings(organization, logoUrl: url);
|
||||
}
|
||||
|
||||
static Future<String> uploadLogo(String organizationRef, XFile logoFile) async {
|
||||
_logger.fine('Uploading logo');
|
||||
return FilesService.uploadImage(_objectType, organizationRef, logoFile);
|
||||
|
||||
@@ -15,6 +15,7 @@ class PayoutRoutes {
|
||||
static const recipients = 'payout-recipients';
|
||||
static const invitations = 'payout-invitations';
|
||||
static const addRecipient = 'payout-add-recipient';
|
||||
static const editRecipient = 'payout-edit-recipient';
|
||||
static const payment = 'payout-payment';
|
||||
static const settings = 'payout-settings';
|
||||
static const reports = 'payout-reports';
|
||||
@@ -24,20 +25,21 @@ class PayoutRoutes {
|
||||
static const walletTopUp = 'payout-wallet-top-up';
|
||||
|
||||
static const paymentTypeQuery = 'paymentType';
|
||||
static const returnToQuery = 'returnTo';
|
||||
static const reportPaymentIdQuery = 'paymentId';
|
||||
|
||||
static const dashboardPath = '/dashboard';
|
||||
static const recipientsPath = '/dashboard/recipients';
|
||||
static const invitationsPath = '/dashboard/invitations';
|
||||
static const addRecipientPath = '/dashboard/recipients/add';
|
||||
static const paymentPath = '/dashboard/payment';
|
||||
static const settingsPath = '/dashboard/settings';
|
||||
static const reportsPath = '/dashboard/reports';
|
||||
static const reportPaymentPath = '/dashboard/reports/payment';
|
||||
static const methodsPath = '/dashboard/methods';
|
||||
static const editWalletPath = '/dashboard/methods/edit';
|
||||
static const walletTopUpPath = '/dashboard/wallet/top-up';
|
||||
static const recipientsPath = '/recipients';
|
||||
static const invitationsPath = '/invitations';
|
||||
static const addRecipientPath = '/recipients/add';
|
||||
static const editRecipientPath = '/recipients/edit';
|
||||
static const paymentPath = '/payment';
|
||||
static const settingsPath = '/settings';
|
||||
static const reportsPath = '/reports';
|
||||
static const reportPaymentPath = '/reports/payment';
|
||||
static const methodsPath = '/methods';
|
||||
static const editWalletPath = '/methods/edit';
|
||||
static const walletTopUpPath = '/wallet/top-up';
|
||||
|
||||
|
||||
static String nameFor(PayoutDestination destination) {
|
||||
switch (destination) {
|
||||
@@ -105,6 +107,8 @@ class PayoutRoutes {
|
||||
return PayoutDestination.invitations;
|
||||
case addRecipient:
|
||||
return PayoutDestination.addrecipient;
|
||||
case editRecipient:
|
||||
return PayoutDestination.addrecipient;
|
||||
case settings:
|
||||
return PayoutDestination.settings;
|
||||
case reports:
|
||||
@@ -122,11 +126,9 @@ class PayoutRoutes {
|
||||
|
||||
static Map<String, String> buildQueryParameters({
|
||||
PaymentType? paymentType,
|
||||
PayoutDestination? returnTo,
|
||||
}) {
|
||||
final params = <String, String>{
|
||||
if (paymentType != null) paymentTypeQuery: paymentType.name,
|
||||
if (returnTo != null) returnToQuery: nameFor(returnTo),
|
||||
};
|
||||
return params;
|
||||
}
|
||||
@@ -138,13 +140,6 @@ class PayoutRoutes {
|
||||
? null
|
||||
: PaymentType.values.firstWhereOrNull((type) => type.name == raw);
|
||||
|
||||
static PayoutDestination fallbackFromState(
|
||||
GoRouterState state, {
|
||||
PayoutDestination defaultDestination = PayoutDestination.dashboard,
|
||||
}) {
|
||||
final raw = state.uri.queryParameters[returnToQuery];
|
||||
return destinationFor(raw) ?? defaultDestination;
|
||||
}
|
||||
}
|
||||
|
||||
extension PayoutNavigation on BuildContext {
|
||||
@@ -154,25 +149,11 @@ extension PayoutNavigation on BuildContext {
|
||||
|
||||
void goToPayment({
|
||||
PaymentType? paymentType,
|
||||
PayoutDestination? returnTo,
|
||||
}) =>
|
||||
goNamed(
|
||||
PayoutRoutes.payment,
|
||||
queryParameters: PayoutRoutes.buildQueryParameters(
|
||||
paymentType: paymentType,
|
||||
returnTo: returnTo,
|
||||
),
|
||||
);
|
||||
|
||||
void pushToPayment({
|
||||
PaymentType? paymentType,
|
||||
PayoutDestination? returnTo,
|
||||
}) =>
|
||||
pushNamed(
|
||||
PayoutRoutes.payment,
|
||||
queryParameters: PayoutRoutes.buildQueryParameters(
|
||||
paymentType: paymentType,
|
||||
returnTo: returnTo,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -190,13 +171,7 @@ extension PayoutNavigation on BuildContext {
|
||||
},
|
||||
);
|
||||
|
||||
void pushToWalletTopUp({PayoutDestination? returnTo}) => pushNamed(
|
||||
PayoutRoutes.walletTopUp,
|
||||
queryParameters: PayoutRoutes.buildQueryParameters(returnTo: returnTo),
|
||||
);
|
||||
void pushToWalletTopUp() => pushNamed(PayoutRoutes.walletTopUp);
|
||||
|
||||
void pushToEditWallet({PayoutDestination? returnTo}) => pushNamed(
|
||||
PayoutRoutes.editWallet,
|
||||
queryParameters: PayoutRoutes.buildQueryParameters(returnTo: returnTo),
|
||||
);
|
||||
void pushToEditWallet() => pushNamed(PayoutRoutes.editWallet);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import 'package:pshared/provider/payment/multiple/quotation.dart';
|
||||
import 'package:pshared/provider/payment/payments.dart';
|
||||
import 'package:pshared/provider/payment/provider.dart';
|
||||
import 'package:pshared/provider/payment/quotation/quotation.dart';
|
||||
import 'package:pshared/provider/payment/updates.dart';
|
||||
import 'package:pshared/provider/payout_verification.dart';
|
||||
import 'package:pshared/provider/recipient/provider.dart';
|
||||
import 'package:pshared/provider/recipient/methods_cache.dart';
|
||||
@@ -22,18 +23,18 @@ import 'package:pshared/provider/recipient/pmethods.dart';
|
||||
|
||||
import 'package:pweb/app/router/pages.dart';
|
||||
import 'package:pweb/app/router/payout_routes.dart';
|
||||
import 'package:pweb/controllers/multiple_payouts.dart';
|
||||
import 'package:pweb/controllers/payment_page.dart';
|
||||
import 'package:pweb/controllers/payout_verification.dart';
|
||||
import 'package:pweb/controllers/payouts/multiple_payouts.dart';
|
||||
import 'package:pweb/controllers/payouts/multi_quotation.dart';
|
||||
import 'package:pweb/controllers/payments/page.dart';
|
||||
import 'package:pweb/controllers/payouts/payout_verification.dart';
|
||||
import 'package:pweb/pages/invitations/page/page.dart';
|
||||
import 'package:pweb/providers/multiple_payouts.dart';
|
||||
import 'package:pweb/controllers/multi_quotation.dart';
|
||||
import 'package:pweb/providers/quotation/quotation.dart';
|
||||
import 'package:pweb/controllers/payouts/quotation.dart';
|
||||
import 'package:pshared/models/payment/wallet.dart';
|
||||
import 'package:pweb/pages/address_book/form/page.dart';
|
||||
import 'package:pweb/pages/address_book/page/page.dart';
|
||||
import 'package:pweb/pages/dashboard/dashboard.dart';
|
||||
import 'package:pweb/pages/invitations/page.dart';
|
||||
import 'package:pweb/pages/payment_methods/page.dart';
|
||||
import 'package:pweb/pages/payout_page/send/page.dart';
|
||||
import 'package:pweb/pages/payout_page/wallet/edit/page.dart';
|
||||
import 'package:pweb/pages/report/details/page.dart';
|
||||
import 'package:pweb/pages/report/page.dart';
|
||||
@@ -169,14 +170,28 @@ RouteBase payoutShellRoute() => ShellRoute(
|
||||
provider!..update(organization, quotation),
|
||||
),
|
||||
ChangeNotifierProxyProvider3<
|
||||
PaymentsProvider,
|
||||
PaymentProvider,
|
||||
MultiPaymentProvider,
|
||||
PaymentsUpdatesProvider
|
||||
>(
|
||||
create: (_) => PaymentsUpdatesProvider(),
|
||||
lazy: false,
|
||||
update: (_, payments, payment, multiPayment, controller) =>
|
||||
controller!..update(
|
||||
paymentsProvider: payments,
|
||||
paymentProvider: payment,
|
||||
multiPaymentProvider: multiPayment,
|
||||
),
|
||||
),
|
||||
ChangeNotifierProxyProvider2<
|
||||
MultiQuotationProvider,
|
||||
MultiPaymentProvider,
|
||||
PaymentsProvider,
|
||||
MultiplePayoutsProvider
|
||||
>(
|
||||
create: (_) => MultiplePayoutsProvider(),
|
||||
update: (context, quotation, payment, payments, provider) =>
|
||||
provider!..update(quotation, payment, payments),
|
||||
update: (context, quotation, payment, provider) =>
|
||||
provider!..update(quotation, payment),
|
||||
),
|
||||
ChangeNotifierProxyProvider2<
|
||||
MultiplePayoutsProvider,
|
||||
@@ -200,23 +215,19 @@ RouteBase payoutShellRoute() => ShellRoute(
|
||||
onRecipientSelected: (recipient) => _startPayment(
|
||||
context,
|
||||
recipient: recipient,
|
||||
returnTo: PayoutDestination.dashboard,
|
||||
),
|
||||
onGoToPaymentWithoutRecipient: (type) => _startPayment(
|
||||
context,
|
||||
recipient: null,
|
||||
paymentType: type,
|
||||
returnTo: PayoutDestination.dashboard,
|
||||
),
|
||||
onTopUp: (wallet) => _openWalletTopUp(
|
||||
context,
|
||||
wallet,
|
||||
returnTo: PayoutDestination.dashboard,
|
||||
),
|
||||
onWalletTap: (wallet) => _openWalletEdit(
|
||||
context,
|
||||
wallet,
|
||||
returnTo: PayoutDestination.dashboard,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -231,11 +242,10 @@ RouteBase payoutShellRoute() => ShellRoute(
|
||||
onRecipientSelected: (recipient) => _startPayment(
|
||||
context,
|
||||
recipient: recipient,
|
||||
returnTo: PayoutDestination.recipients,
|
||||
),
|
||||
onAddRecipient: () => _openAddRecipient(context),
|
||||
onEditRecipient: (recipient) =>
|
||||
_openAddRecipient(context, recipient: recipient),
|
||||
_openEditRecipient(context, recipient: recipient),
|
||||
onDeleteRecipient: (recipient) async {
|
||||
final confirmed = await showConfirmationDialog(
|
||||
context: context,
|
||||
@@ -269,7 +279,20 @@ RouteBase payoutShellRoute() => ShellRoute(
|
||||
return NoTransitionPage(
|
||||
child: AddressBookRecipientForm(
|
||||
recipient: recipient,
|
||||
onSaved: (_) => context.goToPayout(PayoutDestination.recipients),
|
||||
onSaved: (_) => _popOrGo(context),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
name: PayoutRoutes.editRecipient,
|
||||
path: PayoutRoutes.editRecipientPath,
|
||||
pageBuilder: (context, _) {
|
||||
final recipient = context.read<RecipientsProvider>().currentObject;
|
||||
return NoTransitionPage(
|
||||
child: AddressBookRecipientForm(
|
||||
recipient: recipient,
|
||||
onSaved: (_) => _popOrGo(context),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -278,14 +301,11 @@ RouteBase payoutShellRoute() => ShellRoute(
|
||||
name: PayoutRoutes.payment,
|
||||
path: PayoutRoutes.paymentPath,
|
||||
pageBuilder: (context, state) {
|
||||
final fallbackDestination = PayoutRoutes.fallbackFromState(
|
||||
state,
|
||||
defaultDestination: PayoutDestination.dashboard,
|
||||
);
|
||||
final fallbackDestination = PayoutDestination.dashboard;
|
||||
|
||||
return NoTransitionPage(
|
||||
child: PaymentPage(
|
||||
onBack: (_) => _popOrGo(context, fallbackDestination),
|
||||
onBack: (_) => _popOrGo(context),
|
||||
initialPaymentType: PayoutRoutes.paymentTypeFromState(state),
|
||||
fallbackDestination: fallbackDestination,
|
||||
),
|
||||
@@ -327,15 +347,11 @@ RouteBase payoutShellRoute() => ShellRoute(
|
||||
final walletsProvider = context.read<WalletsController>();
|
||||
final wallet = walletsProvider.selectedWallet;
|
||||
final loc = AppLocalizations.of(context)!;
|
||||
final fallbackDestination = PayoutRoutes.fallbackFromState(
|
||||
state,
|
||||
defaultDestination: PayoutDestination.methods,
|
||||
);
|
||||
|
||||
return NoTransitionPage(
|
||||
child: wallet != null
|
||||
? WalletEditPage(
|
||||
onBack: () => _popOrGo(context, fallbackDestination),
|
||||
onBack: () => _popOrGo(context),
|
||||
)
|
||||
: Center(child: Text(loc.noWalletSelected)),
|
||||
);
|
||||
@@ -345,14 +361,10 @@ RouteBase payoutShellRoute() => ShellRoute(
|
||||
name: PayoutRoutes.walletTopUp,
|
||||
path: PayoutRoutes.walletTopUpPath,
|
||||
pageBuilder: (context, state) {
|
||||
final fallbackDestination = PayoutRoutes.fallbackFromState(
|
||||
state,
|
||||
defaultDestination: PayoutDestination.dashboard,
|
||||
);
|
||||
|
||||
return NoTransitionPage(
|
||||
child: WalletTopUpPage(
|
||||
onBack: () => _popOrGo(context, fallbackDestination),
|
||||
onBack: () => _popOrGo(context),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -364,10 +376,14 @@ void _startPayment(
|
||||
BuildContext context, {
|
||||
Recipient? recipient,
|
||||
PaymentType? paymentType,
|
||||
required PayoutDestination returnTo,
|
||||
}) {
|
||||
context.read<RecipientsProvider>().setCurrentObject(recipient?.id);
|
||||
context.pushToPayment(paymentType: paymentType, returnTo: returnTo);
|
||||
context.pushNamed(
|
||||
PayoutRoutes.payment,
|
||||
queryParameters: PayoutRoutes.buildQueryParameters(
|
||||
paymentType: paymentType,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _openAddRecipient(BuildContext context, {Recipient? recipient}) {
|
||||
@@ -375,28 +391,32 @@ void _openAddRecipient(BuildContext context, {Recipient? recipient}) {
|
||||
context.pushNamed(PayoutRoutes.addRecipient);
|
||||
}
|
||||
|
||||
void _openEditRecipient(BuildContext context, {required Recipient recipient}) {
|
||||
context.read<RecipientsProvider>().setCurrentObject(recipient.id);
|
||||
context.pushNamed(PayoutRoutes.editRecipient);
|
||||
}
|
||||
|
||||
void _openWalletEdit(
|
||||
BuildContext context,
|
||||
Wallet wallet, {
|
||||
required PayoutDestination returnTo,
|
||||
}) {
|
||||
Wallet wallet,
|
||||
) {
|
||||
context.read<WalletsController>().selectWallet(wallet);
|
||||
context.pushToEditWallet(returnTo: returnTo);
|
||||
context.pushToEditWallet();
|
||||
}
|
||||
|
||||
void _openWalletTopUp(
|
||||
BuildContext context,
|
||||
Wallet wallet, {
|
||||
required PayoutDestination returnTo,
|
||||
}) {
|
||||
Wallet wallet,
|
||||
) {
|
||||
context.read<WalletsController>().selectWallet(wallet);
|
||||
context.pushToWalletTopUp(returnTo: returnTo);
|
||||
context.pushToWalletTopUp();
|
||||
}
|
||||
|
||||
void _popOrGo(BuildContext context, PayoutDestination destination) {
|
||||
if (Navigator.of(context).canPop()) {
|
||||
Navigator.of(context).pop();
|
||||
void _popOrGo(BuildContext context) {
|
||||
final router = GoRouter.of(context);
|
||||
if (router.canPop()) {
|
||||
router.pop();
|
||||
} else {
|
||||
context.goToPayout(destination);
|
||||
context.goToPayout(PayoutDestination.dashboard);
|
||||
}
|
||||
}
|
||||
|
||||
63
frontend/pweb/lib/controllers/auth/account_loader.dart
Normal file
63
frontend/pweb/lib/controllers/auth/account_loader.dart
Normal file
@@ -0,0 +1,63 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:pshared/models/auth/state.dart';
|
||||
import 'package:pshared/provider/account.dart';
|
||||
|
||||
import 'package:pweb/models/account/account_loader.dart';
|
||||
|
||||
|
||||
class AccountLoaderController extends ChangeNotifier {
|
||||
AccountProvider? _provider;
|
||||
AuthState? _handledState;
|
||||
AccountLoaderAction? _action;
|
||||
Object? _error;
|
||||
|
||||
AccountLoaderAction? get action => _action;
|
||||
Object? get error => _error;
|
||||
|
||||
void update(AccountProvider provider) {
|
||||
if (identical(_provider, provider)) return;
|
||||
_provider?.removeListener(_handleProviderChanged);
|
||||
_provider = provider;
|
||||
_provider?.addListener(_handleProviderChanged);
|
||||
_evaluate(provider);
|
||||
}
|
||||
|
||||
AccountLoaderAction? consumeAction() {
|
||||
final action = _action;
|
||||
_action = null;
|
||||
return action;
|
||||
}
|
||||
|
||||
void _handleProviderChanged() {
|
||||
final provider = _provider;
|
||||
if (provider == null) return;
|
||||
_evaluate(provider);
|
||||
}
|
||||
|
||||
void _evaluate(AccountProvider provider) {
|
||||
if (_handledState == provider.authState) return;
|
||||
_handledState = provider.authState;
|
||||
|
||||
switch (provider.authState) {
|
||||
case AuthState.error:
|
||||
_error = provider.error ?? Exception('Authorization failed');
|
||||
_action = AccountLoaderAction.showErrorAndGoToLogin;
|
||||
notifyListeners();
|
||||
break;
|
||||
case AuthState.empty:
|
||||
_error = null;
|
||||
_action = AccountLoaderAction.goToLogin;
|
||||
notifyListeners();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_provider?.removeListener(_handleProviderChanged);
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -2,21 +2,22 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pshared/provider/account.dart';
|
||||
|
||||
import 'package:pweb/models/edit_state.dart';
|
||||
import 'package:pweb/models/state/edit_state.dart';
|
||||
|
||||
|
||||
class AccountNameState extends ChangeNotifier {
|
||||
AccountNameState({
|
||||
class AccountNameController extends ChangeNotifier {
|
||||
AccountNameController({
|
||||
required this.initialFirstName,
|
||||
required this.initialLastName,
|
||||
required this.errorMessage,
|
||||
required AccountProvider accountProvider,
|
||||
}) : _accountProvider = accountProvider {
|
||||
}) {
|
||||
_firstNameController = TextEditingController(text: initialFirstName);
|
||||
_lastNameController = TextEditingController(text: initialLastName);
|
||||
_lastSyncedFirstName = initialFirstName;
|
||||
_lastSyncedLastName = initialLastName;
|
||||
}
|
||||
|
||||
final AccountProvider _accountProvider;
|
||||
AccountProvider? _accountProvider;
|
||||
final String initialFirstName;
|
||||
final String initialLastName;
|
||||
final String errorMessage;
|
||||
@@ -26,6 +27,8 @@ class AccountNameState extends ChangeNotifier {
|
||||
EditState _editState = EditState.view;
|
||||
String _errorText = '';
|
||||
bool _disposed = false;
|
||||
String _lastSyncedFirstName = '';
|
||||
String _lastSyncedLastName = '';
|
||||
|
||||
TextEditingController get firstNameController => _firstNameController;
|
||||
TextEditingController get lastNameController => _lastNameController;
|
||||
@@ -33,9 +36,12 @@ class AccountNameState extends ChangeNotifier {
|
||||
String get errorText => _errorText;
|
||||
bool get isEditing => _editState != EditState.view;
|
||||
bool get isSaving => _editState == EditState.saving;
|
||||
bool get isBusy => _accountProvider.isLoading || isSaving;
|
||||
String get currentFirstName => _accountProvider.account?.name ?? initialFirstName;
|
||||
String get currentLastName => _accountProvider.account?.lastName ?? initialLastName;
|
||||
bool get isBusy => (_accountProvider?.isLoading ?? false) || isSaving;
|
||||
|
||||
String get currentFirstName =>
|
||||
_accountProvider?.account?.name ?? initialFirstName;
|
||||
String get currentLastName =>
|
||||
_accountProvider?.account?.lastName ?? initialLastName;
|
||||
String get currentFullName {
|
||||
final first = currentFirstName.trim();
|
||||
final last = currentLastName.trim();
|
||||
@@ -45,6 +51,14 @@ class AccountNameState extends ChangeNotifier {
|
||||
return '$first $last';
|
||||
}
|
||||
|
||||
void update(AccountProvider accountProvider) {
|
||||
_accountProvider = accountProvider;
|
||||
final changed = _syncNamesFromProvider();
|
||||
if (changed) {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void startEditing() => _setState(EditState.edit);
|
||||
|
||||
void cancelEditing() {
|
||||
@@ -54,23 +68,17 @@ class AccountNameState extends ChangeNotifier {
|
||||
_setState(EditState.view);
|
||||
}
|
||||
|
||||
void syncNames(String latestFirstName, String latestLastName) {
|
||||
if (isEditing) return;
|
||||
if (_firstNameController.text != latestFirstName) {
|
||||
_firstNameController.text = latestFirstName;
|
||||
}
|
||||
if (_lastNameController.text != latestLastName) {
|
||||
_lastNameController.text = latestLastName;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> save() async {
|
||||
final accountProvider = _accountProvider;
|
||||
if (accountProvider == null) return false;
|
||||
|
||||
final newFirstName = _firstNameController.text.trim();
|
||||
final newLastName = _lastNameController.text.trim();
|
||||
final currentFirst = currentFirstName;
|
||||
final currentLast = currentLastName;
|
||||
|
||||
if (newFirstName.isEmpty || (newFirstName == currentFirst && newLastName == currentLast)) {
|
||||
if (newFirstName.isEmpty ||
|
||||
(newFirstName == currentFirst && newLastName == currentLast)) {
|
||||
cancelEditing();
|
||||
return false;
|
||||
}
|
||||
@@ -79,7 +87,10 @@ class AccountNameState extends ChangeNotifier {
|
||||
_setState(EditState.saving);
|
||||
|
||||
try {
|
||||
await _accountProvider.resetUsername(newFirstName, lastName: newLastName);
|
||||
await accountProvider.resetUsername(
|
||||
newFirstName,
|
||||
lastName: newLastName,
|
||||
);
|
||||
_setState(EditState.view);
|
||||
return true;
|
||||
} catch (_) {
|
||||
@@ -93,6 +104,23 @@ class AccountNameState extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
bool _syncNamesFromProvider() {
|
||||
if (isEditing) return false;
|
||||
final latestFirstName = currentFirstName;
|
||||
final latestLastName = currentLastName;
|
||||
final didChange = latestFirstName != _lastSyncedFirstName ||
|
||||
latestLastName != _lastSyncedLastName;
|
||||
if (_firstNameController.text != latestFirstName) {
|
||||
_firstNameController.text = latestFirstName;
|
||||
}
|
||||
if (_lastNameController.text != latestLastName) {
|
||||
_lastNameController.text = latestLastName;
|
||||
}
|
||||
_lastSyncedFirstName = latestFirstName;
|
||||
_lastSyncedLastName = latestLastName;
|
||||
return didChange;
|
||||
}
|
||||
|
||||
void _setState(EditState value) {
|
||||
if (_disposed || _editState == value) return;
|
||||
_editState = value;
|
||||
@@ -1,14 +1,14 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pshared/provider/account.dart';
|
||||
|
||||
import 'package:pweb/models/edit_state.dart';
|
||||
import 'package:pshared/api/responses/error/server.dart';
|
||||
import 'package:pweb/models/password_field_type.dart';
|
||||
import 'package:pweb/models/visibility.dart';
|
||||
|
||||
import 'package:pweb/models/state/edit_state.dart';
|
||||
import 'package:pweb/models/auth/password_field_type.dart';
|
||||
import 'package:pweb/models/state/visibility.dart';
|
||||
|
||||
|
||||
class PasswordFormProvider extends ChangeNotifier {
|
||||
class PasswordFormController extends ChangeNotifier {
|
||||
final formKey = GlobalKey<FormState>();
|
||||
final oldPasswordController = TextEditingController();
|
||||
final newPasswordController = TextEditingController();
|
||||
85
frontend/pweb/lib/controllers/common/cooldown.dart
Normal file
85
frontend/pweb/lib/controllers/common/cooldown.dart
Normal file
@@ -0,0 +1,85 @@
|
||||
import 'dart:async';
|
||||
|
||||
|
||||
class CooldownController {
|
||||
CooldownController({void Function()? onTick}) : _onTick = onTick;
|
||||
|
||||
final void Function()? _onTick;
|
||||
Timer? _timer;
|
||||
DateTime? _until;
|
||||
int _remainingSeconds = 0;
|
||||
|
||||
int get remainingSeconds => _remainingSeconds;
|
||||
bool get isActive => _remainingSeconds > 0;
|
||||
DateTime? get until => _until;
|
||||
|
||||
void start(Duration duration) {
|
||||
startUntil(DateTime.now().add(duration));
|
||||
}
|
||||
|
||||
void startUntil(DateTime until) {
|
||||
_until = until;
|
||||
_restartTimer();
|
||||
_syncRemaining(notify: true);
|
||||
}
|
||||
|
||||
void syncUntil(DateTime? until, {bool notify = true}) {
|
||||
if (until == null) {
|
||||
stop(notify: notify);
|
||||
return;
|
||||
}
|
||||
_until = until;
|
||||
_restartTimer();
|
||||
_syncRemaining(notify: notify);
|
||||
}
|
||||
|
||||
void stop({bool notify = false}) {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
_until = null;
|
||||
final hadRemaining = _remainingSeconds != 0;
|
||||
_remainingSeconds = 0;
|
||||
if (notify && hadRemaining) {
|
||||
_onTick?.call();
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
}
|
||||
|
||||
void _restartTimer() {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
if (_remaining() <= 0) return;
|
||||
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
final nextRemaining = _remaining();
|
||||
if (nextRemaining <= 0) {
|
||||
stop(notify: true);
|
||||
return;
|
||||
}
|
||||
if (nextRemaining != _remainingSeconds) {
|
||||
_remainingSeconds = nextRemaining;
|
||||
_onTick?.call();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _syncRemaining({required bool notify}) {
|
||||
final nextRemaining = _remaining();
|
||||
if (nextRemaining == _remainingSeconds) return;
|
||||
_remainingSeconds = nextRemaining;
|
||||
if (notify) {
|
||||
_onTick?.call();
|
||||
}
|
||||
}
|
||||
|
||||
int _remaining() {
|
||||
final until = _until;
|
||||
if (until == null) return 0;
|
||||
final remaining = until.difference(DateTime.now()).inSeconds;
|
||||
return remaining < 0 ? 0 : remaining;
|
||||
}
|
||||
}
|
||||
111
frontend/pweb/lib/controllers/invitations/page.dart
Normal file
111
frontend/pweb/lib/controllers/invitations/page.dart
Normal file
@@ -0,0 +1,111 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
|
||||
import 'package:pshared/models/permissions/descriptions/role.dart';
|
||||
import 'package:pshared/provider/account.dart';
|
||||
import 'package:pshared/provider/invitations.dart';
|
||||
import 'package:pshared/provider/permissions.dart';
|
||||
|
||||
|
||||
class InvitationsPageController extends ChangeNotifier {
|
||||
PermissionsProvider? _permissions;
|
||||
InvitationsProvider? _invitations;
|
||||
AccountProvider? _account;
|
||||
|
||||
String? _selectedRoleRef;
|
||||
int _expiryDays = 7;
|
||||
|
||||
String? get selectedRoleRef => _selectedRoleRef;
|
||||
int get expiryDays => _expiryDays;
|
||||
|
||||
void update({
|
||||
required PermissionsProvider permissions,
|
||||
required InvitationsProvider invitations,
|
||||
required AccountProvider account,
|
||||
}) {
|
||||
_permissions = permissions;
|
||||
_invitations = invitations;
|
||||
_account = account;
|
||||
bootstrapRoleSelection(permissions.roleDescriptions);
|
||||
}
|
||||
|
||||
void setExpiryDays(int value) {
|
||||
if (_expiryDays == value) return;
|
||||
_expiryDays = value;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setSelectedRoleRef(String? roleRef) {
|
||||
if (_selectedRoleRef == roleRef) return;
|
||||
_selectedRoleRef = roleRef;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<RoleDescription?> createRole({
|
||||
required String name,
|
||||
String? description,
|
||||
}) async {
|
||||
final permissions = _permissions;
|
||||
if (permissions == null) {
|
||||
throw StateError('Permissions provider is not ready');
|
||||
}
|
||||
final normalizedDescription = description?.trim();
|
||||
final created = await permissions.createRoleDescription(
|
||||
name: name.trim(),
|
||||
description: (normalizedDescription == null || normalizedDescription.isEmpty)
|
||||
? null
|
||||
: normalizedDescription,
|
||||
);
|
||||
if (created != null) {
|
||||
setSelectedRoleRef(created.id);
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
Future<void> sendInvitation({
|
||||
required String email,
|
||||
required String name,
|
||||
required String lastName,
|
||||
required String comment,
|
||||
}) async {
|
||||
final invitations = _invitations;
|
||||
final permissions = _permissions;
|
||||
final account = _account?.account;
|
||||
if (invitations == null) {
|
||||
throw StateError('Invitations provider is not ready');
|
||||
}
|
||||
if (permissions == null) {
|
||||
throw StateError('Permissions provider is not ready');
|
||||
}
|
||||
if (account == null) {
|
||||
throw StateError('Account is not ready');
|
||||
}
|
||||
|
||||
final roleRef = _selectedRoleRef ??
|
||||
permissions.roleDescriptions.firstOrNull?.storable.id;
|
||||
if (roleRef == null) {
|
||||
throw StateError('Role is not selected');
|
||||
}
|
||||
|
||||
await invitations.sendInvitation(
|
||||
email: email.trim(),
|
||||
name: name.trim(),
|
||||
lastName: lastName.trim(),
|
||||
comment: comment.trim(),
|
||||
roleRef: roleRef,
|
||||
inviterRef: account.id,
|
||||
expiresAt: DateTime.now().toUtc().add(Duration(days: _expiryDays)),
|
||||
);
|
||||
}
|
||||
|
||||
void bootstrapRoleSelection(List<RoleDescription> roles) {
|
||||
if (roles.isEmpty) return;
|
||||
final firstRoleRef = roles.first.storable.id;
|
||||
final isSelectedAvailable = _selectedRoleRef != null &&
|
||||
roles.any((role) => role.storable.id == _selectedRoleRef);
|
||||
if (isSelectedAvailable) return;
|
||||
_selectedRoleRef = firstRoleRef;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -7,7 +6,7 @@ import 'package:pshared/models/payment/operation.dart';
|
||||
import 'package:pshared/models/payment/status.dart';
|
||||
import 'package:pshared/provider/payment/payments.dart';
|
||||
|
||||
import 'package:pweb/models/load_more_state.dart';
|
||||
import 'package:pweb/models/state/load_more_state.dart';
|
||||
import 'package:pweb/utils/report/operations.dart';
|
||||
import 'package:pweb/utils/report/payment_mapper.dart';
|
||||
|
||||
@@ -39,14 +38,7 @@ class ReportOperationsController extends ChangeNotifier {
|
||||
|
||||
void update(PaymentsProvider provider) {
|
||||
if (!identical(_payments, provider)) {
|
||||
_payments?.endAutoRefresh();
|
||||
_payments = provider;
|
||||
_payments?.beginAutoRefresh();
|
||||
if (provider.isReady || provider.isLoading) {
|
||||
unawaited(_payments?.refreshSilently());
|
||||
} else {
|
||||
unawaited(_payments?.refresh());
|
||||
}
|
||||
}
|
||||
_rebuildOperations();
|
||||
}
|
||||
@@ -122,11 +114,4 @@ class ReportOperationsController extends ChangeNotifier {
|
||||
return left.start.isAtSameMomentAs(right.start) &&
|
||||
left.end.isAtSameMomentAs(right.end);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_payments?.endAutoRefresh();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pshared/models/payment/status.dart';
|
||||
|
||||
import 'package:pweb/models/wallet/wallet_transaction.dart';
|
||||
import 'package:pweb/providers/wallet_transactions.dart';
|
||||
|
||||
|
||||
class WalletTransactionsController extends ChangeNotifier {
|
||||
List<WalletTransaction> _filteredTransactions = [];
|
||||
DateTimeRange? _dateRange;
|
||||
final Set<OperationStatus> _selectedStatuses = {};
|
||||
final Set<WalletTransactionType> _selectedTypes = {};
|
||||
WalletTransactionsProvider? _provider;
|
||||
|
||||
List<WalletTransaction> get transactions =>
|
||||
_provider?.transactions ?? const [];
|
||||
List<WalletTransaction> get filteredTransactions => _filteredTransactions;
|
||||
DateTimeRange? get dateRange => _dateRange;
|
||||
Set<OperationStatus> get selectedStatuses => _selectedStatuses;
|
||||
Set<WalletTransactionType> get selectedTypes => _selectedTypes;
|
||||
bool get isLoading => _provider?.isLoading ?? false;
|
||||
String? get error => _provider?.error;
|
||||
bool get hasFilters =>
|
||||
_dateRange != null ||
|
||||
_selectedStatuses.isNotEmpty ||
|
||||
_selectedTypes.isNotEmpty;
|
||||
|
||||
void update(WalletTransactionsProvider provider) {
|
||||
if (identical(_provider, provider)) return;
|
||||
_provider?.removeListener(_onProviderChanged);
|
||||
_provider = provider;
|
||||
_provider?.addListener(_onProviderChanged);
|
||||
_rebuildFiltered(notify: false);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setDateRange(DateTimeRange? range) {
|
||||
_dateRange = range;
|
||||
_rebuildFiltered();
|
||||
}
|
||||
|
||||
void toggleStatus(OperationStatus status) {
|
||||
if (_selectedStatuses.contains(status)) {
|
||||
_selectedStatuses.remove(status);
|
||||
} else {
|
||||
_selectedStatuses.add(status);
|
||||
}
|
||||
_rebuildFiltered();
|
||||
}
|
||||
|
||||
void toggleType(WalletTransactionType type) {
|
||||
if (_selectedTypes.contains(type)) {
|
||||
_selectedTypes.remove(type);
|
||||
} else {
|
||||
_selectedTypes.add(type);
|
||||
}
|
||||
_rebuildFiltered();
|
||||
}
|
||||
|
||||
void resetFilters() {
|
||||
_dateRange = null;
|
||||
_selectedStatuses.clear();
|
||||
_selectedTypes.clear();
|
||||
_rebuildFiltered();
|
||||
}
|
||||
|
||||
void _onProviderChanged() {
|
||||
_rebuildFiltered();
|
||||
}
|
||||
|
||||
void _rebuildFiltered({bool notify = true}) {
|
||||
final source = _provider?.transactions ?? const <WalletTransaction>[];
|
||||
_filteredTransactions = source.where((tx) {
|
||||
final statusMatch =
|
||||
_selectedStatuses.isEmpty || _selectedStatuses.contains(tx.status);
|
||||
final typeMatch =
|
||||
_selectedTypes.isEmpty || _selectedTypes.contains(tx.type);
|
||||
final dateMatch = _dateRange == null ||
|
||||
(tx.date.isAfter(_dateRange!.start.subtract(const Duration(seconds: 1))) &&
|
||||
tx.date.isBefore(_dateRange!.end.add(const Duration(seconds: 1))));
|
||||
|
||||
return statusMatch && typeMatch && dateMatch;
|
||||
}).toList();
|
||||
|
||||
if (notify) notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_provider?.removeListener(_onProviderChanged);
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
|
||||
import 'package:pshared/models/payment/methods/data.dart';
|
||||
import 'package:pshared/models/payment/methods/type.dart';
|
||||
import 'package:pshared/models/payment/type.dart';
|
||||
import 'package:pshared/models/recipient/payment_method_draft.dart';
|
||||
import 'package:pshared/models/recipient/recipient.dart';
|
||||
import 'package:pshared/provider/recipient/methods_cache.dart';
|
||||
|
||||
import 'package:pweb/models/recipient/method_snapshot.dart';
|
||||
import 'package:pweb/models/state/seed_state.dart';
|
||||
import 'package:pweb/providers/address_book_recipient_form.dart';
|
||||
import 'package:pweb/utils/payment/label.dart';
|
||||
import 'package:pweb/widgets/dialogs/confirmation_dialog.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
|
||||
class AddressBookRecipientFormController extends ChangeNotifier {
|
||||
static final ListEquality<RecipientMethodSnapshot> _listEquality =
|
||||
ListEquality<RecipientMethodSnapshot>();
|
||||
|
||||
final List<PaymentType> _supportedTypes;
|
||||
final Map<PaymentType, List<RecipientMethodDraft>> _methods;
|
||||
|
||||
Recipient? _recipient;
|
||||
RecipientMethodsCacheProvider? _methodsCache;
|
||||
|
||||
String _initialName = '';
|
||||
String _initialEmail = '';
|
||||
List<RecipientMethodSnapshot> _initialMethods = const [];
|
||||
SeedState _snapshotState = SeedState.idle;
|
||||
SeedState _seedState = SeedState.idle;
|
||||
|
||||
AddressBookRecipientFormController({
|
||||
required List<PaymentType> supportedTypes,
|
||||
}) : _supportedTypes = List.unmodifiable(supportedTypes),
|
||||
_methods = {
|
||||
for (final type in supportedTypes) type: <RecipientMethodDraft>[],
|
||||
};
|
||||
|
||||
List<PaymentType> get supportedTypes => _supportedTypes;
|
||||
Map<PaymentType, List<RecipientMethodDraft>> get methods => {
|
||||
for (final entry in _methods.entries)
|
||||
entry.key: List<RecipientMethodDraft>.unmodifiable(entry.value),
|
||||
};
|
||||
PaymentType? get preferredType =>
|
||||
_supportedTypes.firstWhere(
|
||||
(type) => _methods[type]?.isNotEmpty == true,
|
||||
orElse: () => _supportedTypes.first,
|
||||
);
|
||||
|
||||
bool get hasAnyMethod => _methods.values.any(
|
||||
(entries) => entries.any((entry) => entry.data != null || entry.existing != null),
|
||||
);
|
||||
|
||||
Future<void> saveForm({
|
||||
required BuildContext context,
|
||||
required GlobalKey<FormState> formKey,
|
||||
required AddressBookRecipientFormProvider formState,
|
||||
required String name,
|
||||
required String email,
|
||||
ValueChanged<Recipient?>? onSaved,
|
||||
}) async {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
if (!formKey.currentState!.validate() || !hasAnyMethod) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l10n.recipientFormRule)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final saved = await formState.save(
|
||||
name: name,
|
||||
email: email,
|
||||
methodNames: _methodNames(context),
|
||||
methodDrafts: allDrafts(),
|
||||
);
|
||||
onSaved?.call(saved);
|
||||
} catch (_) {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l10n.notificationError(l10n.noErrorInformation))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> handleBack({
|
||||
required BuildContext context,
|
||||
required GlobalKey<FormState> formKey,
|
||||
required AddressBookRecipientFormProvider formState,
|
||||
required String name,
|
||||
required String email,
|
||||
ValueChanged<Recipient?>? onSaved,
|
||||
}) async {
|
||||
if (!context.mounted) return;
|
||||
if (!hasUnsavedChanges(name: name, email: email)) {
|
||||
onSaved?.call(null);
|
||||
return;
|
||||
}
|
||||
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final shouldSave = await showConfirmationDialog(
|
||||
context: context,
|
||||
title: l10n.unsavedChangesTitle,
|
||||
message: l10n.unsavedChangesMessage,
|
||||
confirmLabel: l10n.save,
|
||||
cancelLabel: l10n.discard,
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
|
||||
if (shouldSave) {
|
||||
await saveForm(
|
||||
context: context,
|
||||
formKey: formKey,
|
||||
formState: formState,
|
||||
name: name,
|
||||
email: email,
|
||||
onSaved: onSaved,
|
||||
);
|
||||
} else {
|
||||
onSaved?.call(null);
|
||||
}
|
||||
}
|
||||
|
||||
void update({
|
||||
required Recipient? recipient,
|
||||
required RecipientMethodsCacheProvider methodsCache,
|
||||
}) {
|
||||
if (!identical(_methodsCache, methodsCache)) {
|
||||
_methodsCache?.removeListener(_handleCacheChange);
|
||||
_methodsCache = methodsCache;
|
||||
_methodsCache?.addListener(_handleCacheChange);
|
||||
}
|
||||
|
||||
if (_recipient?.id != recipient?.id) {
|
||||
_reset(recipient);
|
||||
}
|
||||
|
||||
_maybeSeedFromCache();
|
||||
}
|
||||
|
||||
bool hasUnsavedChanges({
|
||||
required String name,
|
||||
required String email,
|
||||
}) {
|
||||
if (_recipient == null) return false;
|
||||
final methodsCache = _methodsCache;
|
||||
if (methodsCache == null) return false;
|
||||
|
||||
_captureIfReady();
|
||||
final nameChanged = name.trim() != _initialName.trim();
|
||||
final emailChanged = email.trim() != _initialEmail.trim();
|
||||
if (_snapshotState != SeedState.seeded) {
|
||||
return nameChanged || emailChanged;
|
||||
}
|
||||
final current = _snapshotFrom();
|
||||
final methodsChanged = !_listEquality.equals(_initialMethods, current);
|
||||
|
||||
return nameChanged || emailChanged || methodsChanged;
|
||||
}
|
||||
|
||||
List<RecipientMethodDraft> allDrafts() =>
|
||||
_methods.values.expand((entries) => entries).toList();
|
||||
|
||||
int? addMethod(PaymentType type) {
|
||||
final entries = _methods[type];
|
||||
if (entries == null) return null;
|
||||
entries.add(RecipientMethodDraft(type: type));
|
||||
notifyListeners();
|
||||
return entries.length - 1;
|
||||
}
|
||||
|
||||
void removeMethod(PaymentType type, int index) {
|
||||
final entries = _methods[type];
|
||||
if (entries == null) return;
|
||||
if (index < 0 || index >= entries.length) return;
|
||||
entries.removeAt(index);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void updateMethod(PaymentType type, int index, PaymentMethodData data) {
|
||||
final entries = _methods[type];
|
||||
if (entries == null) return;
|
||||
if (index < 0 || index >= entries.length) return;
|
||||
entries[index].data = data;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _handleCacheChange() {
|
||||
_maybeSeedFromCache();
|
||||
}
|
||||
|
||||
void _reset(Recipient? recipient) {
|
||||
_recipient = recipient;
|
||||
_initialName = recipient?.name ?? '';
|
||||
_initialEmail = recipient?.email ?? '';
|
||||
_initialMethods = const [];
|
||||
_snapshotState = SeedState.idle;
|
||||
_seedState = SeedState.idle;
|
||||
_resetMethods();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _resetMethods() {
|
||||
for (final entries in _methods.values) {
|
||||
entries.clear();
|
||||
}
|
||||
}
|
||||
|
||||
void _maybeSeedFromCache() {
|
||||
final recipient = _recipient;
|
||||
final methodsCache = _methodsCache;
|
||||
if (recipient == null || methodsCache == null) return;
|
||||
if (_seedState == SeedState.seeded) return;
|
||||
if (!methodsCache.hasMethodsFor(recipient.id)) return;
|
||||
_seedState = SeedState.seeded;
|
||||
_seedMethodsFromExisting(methodsCache.methodsForRecipient(recipient.id));
|
||||
}
|
||||
|
||||
void _seedMethodsFromExisting(List<PaymentMethod> existing) {
|
||||
_resetMethods();
|
||||
for (final method in existing) {
|
||||
final type = method.type;
|
||||
final entries = _methods[type];
|
||||
if (entries == null) continue;
|
||||
entries.add(
|
||||
RecipientMethodDraft(
|
||||
type: type,
|
||||
existing: method,
|
||||
data: method.data,
|
||||
),
|
||||
);
|
||||
}
|
||||
_initialMethods = _snapshotFrom();
|
||||
_snapshotState = SeedState.seeded;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _captureIfReady() {
|
||||
if (_snapshotState == SeedState.seeded) return;
|
||||
final recipient = _recipient;
|
||||
final methodsCache = _methodsCache;
|
||||
if (recipient == null || methodsCache == null) return;
|
||||
if (!methodsCache.hasMethodsFor(recipient.id)) return;
|
||||
|
||||
_initialMethods = _snapshotFrom();
|
||||
_snapshotState = SeedState.seeded;
|
||||
}
|
||||
|
||||
List<RecipientMethodSnapshot> _snapshotFrom() {
|
||||
final snapshots = <RecipientMethodSnapshot>[];
|
||||
for (final type in _supportedTypes) {
|
||||
final entries = _methods[type] ?? const <RecipientMethodDraft>[];
|
||||
for (final entry in entries) {
|
||||
snapshots.add(RecipientMethodSnapshot.fromDraft(entry));
|
||||
}
|
||||
}
|
||||
return snapshots;
|
||||
}
|
||||
|
||||
Map<PaymentType, String> _methodNames(BuildContext context) => {
|
||||
for (final type in _supportedTypes) type: getPaymentTypeLabel(context, type),
|
||||
};
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_methodsCache?.removeListener(_handleCacheChange);
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:pshared/models/payment/type.dart';
|
||||
|
||||
import 'package:pweb/controllers/organization/address_book_recipient_form.dart';
|
||||
|
||||
|
||||
class AddressBookRecipientFormSelectionController extends ChangeNotifier {
|
||||
AddressBookRecipientFormController? _formController;
|
||||
PaymentType? _selectedType;
|
||||
int? _selectedIndex;
|
||||
|
||||
PaymentType? get selectedType => _selectedType;
|
||||
int? get selectedIndex => _selectedIndex;
|
||||
|
||||
void update(AddressBookRecipientFormController formController) {
|
||||
if (identical(_formController, formController)) return;
|
||||
_formController?.removeListener(_handleFormChanged);
|
||||
_formController = formController;
|
||||
_formController?.addListener(_handleFormChanged);
|
||||
_reconcileSelection();
|
||||
}
|
||||
|
||||
void select(PaymentType type, int index) {
|
||||
if (_selectedType == type && _selectedIndex == index) return;
|
||||
_selectedType = type;
|
||||
_selectedIndex = index;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void selectAfterAdd(PaymentType type, int? index) {
|
||||
if (_selectedType == type && _selectedIndex == index) return;
|
||||
_selectedType = type;
|
||||
_selectedIndex = index;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _handleFormChanged() {
|
||||
_reconcileSelection();
|
||||
}
|
||||
|
||||
void _reconcileSelection() {
|
||||
final form = _formController;
|
||||
if (form == null) return;
|
||||
final types = form.supportedTypes;
|
||||
if (types.isEmpty) return;
|
||||
|
||||
var nextType = _selectedType;
|
||||
var nextIndex = _selectedIndex;
|
||||
|
||||
if (nextType == null || !types.contains(nextType)) {
|
||||
nextType = form.preferredType ?? types.first;
|
||||
nextIndex = null;
|
||||
}
|
||||
|
||||
final entries = form.methods[nextType] ?? const [];
|
||||
if (entries.isEmpty) {
|
||||
nextIndex = null;
|
||||
} else if (nextIndex == null || nextIndex < 0 || nextIndex >= entries.length) {
|
||||
nextIndex = 0;
|
||||
}
|
||||
|
||||
if (nextType == _selectedType && nextIndex == _selectedIndex) return;
|
||||
_selectedType = nextType;
|
||||
_selectedIndex = nextIndex;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_formController?.removeListener(_handleFormChanged);
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
67
frontend/pweb/lib/controllers/payments/amount_field.dart
Normal file
67
frontend/pweb/lib/controllers/payments/amount_field.dart
Normal file
@@ -0,0 +1,67 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pshared/provider/payment/amount.dart';
|
||||
import 'package:pshared/utils/currency.dart';
|
||||
import 'package:pshared/utils/money.dart';
|
||||
|
||||
|
||||
class PaymentAmountFieldController extends ChangeNotifier {
|
||||
final TextEditingController textController;
|
||||
PaymentAmountProvider? _provider;
|
||||
bool _isSyncingText = false;
|
||||
|
||||
PaymentAmountFieldController({required double initialAmount})
|
||||
: textController = TextEditingController(
|
||||
text: amountToString(initialAmount),
|
||||
);
|
||||
|
||||
void update(PaymentAmountProvider provider) {
|
||||
if (identical(_provider, provider)) return;
|
||||
_provider?.removeListener(_handleProviderChanged);
|
||||
_provider = provider;
|
||||
_provider?.addListener(_handleProviderChanged);
|
||||
_syncTextWithAmount(provider.amount);
|
||||
}
|
||||
|
||||
void handleChanged(String value) {
|
||||
if (_isSyncingText) return;
|
||||
final parsed = _parseAmount(value);
|
||||
if (parsed != null) {
|
||||
_provider?.setAmount(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleProviderChanged() {
|
||||
final provider = _provider;
|
||||
if (provider == null) return;
|
||||
_syncTextWithAmount(provider.amount);
|
||||
}
|
||||
|
||||
double? _parseAmount(String value) {
|
||||
final parsed = parseMoneyAmount(
|
||||
value.replaceAll(',', '.'),
|
||||
fallback: double.nan,
|
||||
);
|
||||
return parsed.isNaN ? null : parsed;
|
||||
}
|
||||
|
||||
void _syncTextWithAmount(double amount) {
|
||||
final parsedText = _parseAmount(textController.text);
|
||||
if (parsedText != null && parsedText == amount) return;
|
||||
|
||||
final nextText = amountToString(amount);
|
||||
_isSyncingText = true;
|
||||
textController.value = TextEditingValue(
|
||||
text: nextText,
|
||||
selection: TextSelection.collapsed(offset: nextText.length),
|
||||
);
|
||||
_isSyncingText = false;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_provider?.removeListener(_handleProviderChanged);
|
||||
textController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:pshared/models/payment/payment.dart';
|
||||
@@ -37,14 +35,7 @@ class PaymentDetailsController extends ChangeNotifier {
|
||||
}
|
||||
|
||||
if (!identical(_payments, provider)) {
|
||||
_payments?.endAutoRefresh();
|
||||
_payments = provider;
|
||||
_payments?.beginAutoRefresh();
|
||||
if (provider.isReady || provider.isLoading) {
|
||||
unawaited(_payments?.refreshSilently());
|
||||
} else {
|
||||
unawaited(_payments?.refresh());
|
||||
}
|
||||
}
|
||||
|
||||
_rebuild();
|
||||
@@ -68,10 +59,4 @@ class PaymentDetailsController extends ChangeNotifier {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_payments?.endAutoRefresh();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:pshared/provider/payment/flow.dart';
|
||||
@@ -5,6 +7,8 @@ import 'package:pshared/provider/payment/provider.dart';
|
||||
import 'package:pshared/provider/payment/quotation/quotation.dart';
|
||||
import 'package:pshared/provider/recipient/provider.dart';
|
||||
|
||||
import 'package:pweb/services/posthog.dart';
|
||||
|
||||
|
||||
class PaymentPageController extends ChangeNotifier {
|
||||
PaymentProvider? _payment;
|
||||
@@ -58,6 +62,11 @@ class PaymentPageController extends ChangeNotifier {
|
||||
_recipients?.setCurrentObject(null);
|
||||
}
|
||||
|
||||
void handleSuccess() {
|
||||
unawaited(PosthogService.paymentInitiated(method: _flow?.selectedType));
|
||||
resetAfterSuccess();
|
||||
}
|
||||
|
||||
void _setSending(bool value) {
|
||||
if (_isSending == value) return;
|
||||
_isSending = value;
|
||||
43
frontend/pweb/lib/controllers/payments/page_ui.dart
Normal file
43
frontend/pweb/lib/controllers/payments/page_ui.dart
Normal file
@@ -0,0 +1,43 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'package:pweb/models/state/visibility.dart';
|
||||
|
||||
|
||||
class PaymentPageUiController extends ChangeNotifier {
|
||||
final TextEditingController searchController = TextEditingController();
|
||||
final FocusNode searchFocusNode = FocusNode();
|
||||
|
||||
String _query = '';
|
||||
VisibilityState _paymentDetailsVisibility = VisibilityState.hidden;
|
||||
|
||||
String get query => _query;
|
||||
VisibilityState get paymentDetailsVisibility => _paymentDetailsVisibility;
|
||||
|
||||
void setQuery(String query) {
|
||||
if (_query == query) return;
|
||||
_query = query;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void clearSearch() {
|
||||
if (searchController.text.isNotEmpty) {
|
||||
searchController.clear();
|
||||
}
|
||||
searchFocusNode.unfocus();
|
||||
setQuery('');
|
||||
}
|
||||
|
||||
void togglePaymentDetails() {
|
||||
_paymentDetailsVisibility = _paymentDetailsVisibility == VisibilityState.visible
|
||||
? VisibilityState.hidden
|
||||
: VisibilityState.visible;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
searchController.dispose();
|
||||
searchFocusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:pshared/models/payment/operation.dart';
|
||||
@@ -19,14 +17,7 @@ class RecentPaymentsController extends ChangeNotifier {
|
||||
|
||||
void update(PaymentsProvider provider) {
|
||||
if (!identical(_payments, provider)) {
|
||||
_payments?.endAutoRefresh();
|
||||
_payments = provider;
|
||||
_payments?.beginAutoRefresh();
|
||||
if (provider.isReady || provider.isLoading) {
|
||||
unawaited(_payments?.refreshSilently());
|
||||
} else {
|
||||
unawaited(_payments?.refresh());
|
||||
}
|
||||
}
|
||||
_rebuild();
|
||||
}
|
||||
@@ -39,10 +30,4 @@ class RecentPaymentsController extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_payments?.endAutoRefresh();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -2,15 +2,9 @@ import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:pshared/provider/payment/multiple/quotation.dart';
|
||||
|
||||
import 'package:pweb/providers/quotation/auto_refresh.dart';
|
||||
|
||||
|
||||
class MultiQuotationController extends ChangeNotifier {
|
||||
static const Duration _autoRefreshLead = Duration(seconds: 5);
|
||||
|
||||
MultiQuotationProvider? _quotation;
|
||||
final QuotationAutoRefreshController _autoRefreshController =
|
||||
QuotationAutoRefreshController();
|
||||
|
||||
void update(MultiQuotationProvider quotation) {
|
||||
if (identical(_quotation, quotation)) return;
|
||||
@@ -32,37 +26,12 @@ class MultiQuotationController extends ChangeNotifier {
|
||||
}
|
||||
|
||||
void _handleQuotationChanged() {
|
||||
_syncAutoRefresh();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _syncAutoRefresh() {
|
||||
final quotation = _quotation;
|
||||
if (quotation == null) {
|
||||
_autoRefreshController.reset();
|
||||
return;
|
||||
}
|
||||
|
||||
final expiresAt = quoteExpiresAt;
|
||||
final scheduledAt = expiresAt?.subtract(_autoRefreshLead);
|
||||
|
||||
_autoRefreshController.setEnabled(true);
|
||||
_autoRefreshController.sync(
|
||||
isLoading: quotation.isLoading,
|
||||
canRefresh: quotation.canRefresh,
|
||||
expiresAt: scheduledAt,
|
||||
onRefresh: _refreshQuotation,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _refreshQuotation() async {
|
||||
await _quotation?.refreshQuotation();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_quotation?.removeListener(_handleQuotationChanged);
|
||||
_autoRefreshController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,8 @@ import 'package:pshared/models/payment/payment.dart';
|
||||
import 'package:pshared/models/payment/quote/status_type.dart';
|
||||
import 'package:pshared/models/payment/wallet.dart';
|
||||
|
||||
import 'package:pweb/models/multiple_payouts/csv_row.dart';
|
||||
import 'package:pweb/models/multiple_payouts/state.dart';
|
||||
import 'package:pweb/models/payment/multiple_payouts/csv_row.dart';
|
||||
import 'package:pweb/models/payment/multiple_payouts/state.dart';
|
||||
import 'package:pweb/providers/multiple_payouts.dart';
|
||||
import 'package:pweb/services/payments/csv_input.dart';
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pshared/provider/payout_verification.dart';
|
||||
|
||||
import 'package:pweb/models/flow_status.dart';
|
||||
import 'package:pweb/controllers/common/cooldown.dart';
|
||||
import 'package:pweb/models/state/flow_status.dart';
|
||||
|
||||
|
||||
class PayoutVerificationController extends ChangeNotifier {
|
||||
PayoutVerificationController() {
|
||||
_cooldown = CooldownController(onTick: () => notifyListeners());
|
||||
}
|
||||
|
||||
PayoutVerificationProvider? _provider;
|
||||
|
||||
FlowStatus _status = FlowStatus.idle;
|
||||
Object? _error;
|
||||
Timer? _cooldownTimer;
|
||||
int _cooldownRemainingSeconds = 0;
|
||||
DateTime? _cooldownUntil;
|
||||
late final CooldownController _cooldown;
|
||||
String? _contextKey;
|
||||
String? _cooldownContextKey;
|
||||
|
||||
FlowStatus get status => _status;
|
||||
bool get isSubmitting => _status == FlowStatus.submitting;
|
||||
@@ -23,8 +26,17 @@ class PayoutVerificationController extends ChangeNotifier {
|
||||
bool get verificationSuccess => _status == FlowStatus.success;
|
||||
Object? get error => _error;
|
||||
String get target => _provider?.target ?? '';
|
||||
int get cooldownRemainingSeconds => _cooldownRemainingSeconds;
|
||||
bool get isCooldownActive => _cooldownRemainingSeconds > 0;
|
||||
int get cooldownRemainingSeconds => _cooldown.remainingSeconds;
|
||||
bool get isCooldownActive => _cooldown.isActive;
|
||||
bool isCooldownActiveFor(String? contextKey) {
|
||||
if (!_cooldown.isActive) return false;
|
||||
return _cooldownContextKey == contextKey;
|
||||
}
|
||||
|
||||
int cooldownRemainingSecondsFor(String? contextKey) {
|
||||
if (_cooldownContextKey != contextKey) return 0;
|
||||
return _cooldown.remainingSeconds;
|
||||
}
|
||||
|
||||
void update(PayoutVerificationProvider provider) {
|
||||
if (identical(_provider, provider)) return;
|
||||
@@ -34,11 +46,19 @@ class PayoutVerificationController extends ChangeNotifier {
|
||||
_syncCooldown(provider.cooldownUntil);
|
||||
}
|
||||
|
||||
void setContextKey(String? contextKey) {
|
||||
if (_contextKey == contextKey) return;
|
||||
_contextKey = contextKey;
|
||||
_cooldownContextKey = null;
|
||||
_cooldown.stop();
|
||||
}
|
||||
|
||||
Future<void> requestCode() async {
|
||||
final provider = _provider;
|
||||
if (provider == null) {
|
||||
throw StateError('Payout verification provider is not ready');
|
||||
}
|
||||
_bindCooldownContext();
|
||||
_error = null;
|
||||
_setStatus(FlowStatus.submitting);
|
||||
try {
|
||||
@@ -75,7 +95,7 @@ class PayoutVerificationController extends ChangeNotifier {
|
||||
throw StateError('Payout verification provider is not ready');
|
||||
}
|
||||
if (isResending || isCooldownActive) return;
|
||||
|
||||
_bindCooldownContext();
|
||||
_error = null;
|
||||
_setStatus(FlowStatus.resending);
|
||||
|
||||
@@ -91,7 +111,9 @@ class PayoutVerificationController extends ChangeNotifier {
|
||||
void reset() {
|
||||
_error = null;
|
||||
_setStatus(FlowStatus.idle);
|
||||
_stopCooldown();
|
||||
_cooldown.stop();
|
||||
_cooldownContextKey = null;
|
||||
_contextKey = null;
|
||||
_provider?.reset();
|
||||
}
|
||||
|
||||
@@ -106,67 +128,38 @@ class PayoutVerificationController extends ChangeNotifier {
|
||||
}
|
||||
|
||||
void _syncCooldown(DateTime? until) {
|
||||
if (_cooldownContextKey == null || _cooldownContextKey != _contextKey) {
|
||||
_cooldown.stop(notify: _cooldown.isActive);
|
||||
return;
|
||||
}
|
||||
if (until == null) {
|
||||
_stopCooldown(notify: _cooldownRemainingSeconds != 0);
|
||||
_cooldown.stop(notify: _cooldown.isActive);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_isCooldownActive(until) && _cooldownRemainingSeconds != 0) {
|
||||
_stopCooldown(notify: true);
|
||||
if (!_isCooldownActive(until)) {
|
||||
_cooldown.stop(notify: _cooldown.isActive);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_cooldownUntil == null || _cooldownUntil != until) {
|
||||
_startCooldownUntil(until);
|
||||
final currentUntil = _cooldown.until;
|
||||
if (currentUntil == null || !currentUntil.isAtSameMomentAs(until)) {
|
||||
_cooldown.syncUntil(until, notify: true);
|
||||
}
|
||||
}
|
||||
|
||||
void _startCooldownUntil(DateTime until) {
|
||||
_cooldownTimer?.cancel();
|
||||
_cooldownUntil = until;
|
||||
_cooldownRemainingSeconds = _cooldownRemaining();
|
||||
|
||||
if (_cooldownRemainingSeconds <= 0) {
|
||||
_cooldownTimer = null;
|
||||
_cooldownUntil = null;
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
_cooldownTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
final remaining = _cooldownRemaining();
|
||||
if (remaining <= 0) {
|
||||
_stopCooldown(notify: true);
|
||||
return;
|
||||
}
|
||||
if (remaining != _cooldownRemainingSeconds) {
|
||||
_cooldownRemainingSeconds = remaining;
|
||||
notifyListeners();
|
||||
}
|
||||
});
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
bool _isCooldownActive(DateTime until) => until.isAfter(DateTime.now());
|
||||
|
||||
int _cooldownRemaining() {
|
||||
final until = _cooldownUntil;
|
||||
if (until == null) return 0;
|
||||
final remaining = until.difference(DateTime.now()).inSeconds;
|
||||
return remaining < 0 ? 0 : remaining;
|
||||
}
|
||||
|
||||
void _stopCooldown({bool notify = false}) {
|
||||
_cooldownTimer?.cancel();
|
||||
_cooldownTimer = null;
|
||||
final hadCooldown = _cooldownRemainingSeconds != 0;
|
||||
_cooldownRemainingSeconds = 0;
|
||||
_cooldownUntil = null;
|
||||
|
||||
if (notify && hadCooldown) {
|
||||
notifyListeners();
|
||||
void _bindCooldownContext() {
|
||||
final key = _contextKey;
|
||||
if (key == null) {
|
||||
_cooldownContextKey = null;
|
||||
_cooldown.stop();
|
||||
return;
|
||||
}
|
||||
if (_cooldownContextKey == key) return;
|
||||
_cooldown.stop();
|
||||
_cooldownContextKey = key;
|
||||
}
|
||||
|
||||
void _setStatus(FlowStatus status) {
|
||||
@@ -178,7 +171,7 @@ class PayoutVerificationController extends ChangeNotifier {
|
||||
@override
|
||||
void dispose() {
|
||||
_provider?.removeListener(_onProviderChanged);
|
||||
_stopCooldown();
|
||||
_cooldown.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -6,14 +6,9 @@ import 'package:pshared/models/auto_refresh_mode.dart';
|
||||
import 'package:pshared/models/payment/quote/status_type.dart';
|
||||
import 'package:pshared/provider/payment/quotation/quotation.dart';
|
||||
|
||||
import 'package:pweb/providers/quotation/auto_refresh.dart';
|
||||
|
||||
|
||||
class QuotationController extends ChangeNotifier {
|
||||
QuotationProvider? _quotation;
|
||||
AutoRefreshMode _autoRefreshMode = AutoRefreshMode.on;
|
||||
final QuotationAutoRefreshController _autoRefreshController =
|
||||
QuotationAutoRefreshController();
|
||||
Timer? _ticker;
|
||||
|
||||
void update(QuotationProvider quotation) {
|
||||
@@ -28,7 +23,8 @@ class QuotationController extends ChangeNotifier {
|
||||
Exception? get error => _quotation?.error;
|
||||
bool get canRefresh => _quotation?.canRefresh ?? false;
|
||||
bool get isReady => _quotation?.isReady ?? false;
|
||||
AutoRefreshMode get autoRefreshMode => _autoRefreshMode;
|
||||
AutoRefreshMode get autoRefreshMode =>
|
||||
_quotation?.autoRefreshMode ?? AutoRefreshMode.on;
|
||||
|
||||
DateTime? get quoteExpiresAt => _quotation?.quoteExpiresAt;
|
||||
|
||||
@@ -55,10 +51,7 @@ class QuotationController extends ChangeNotifier {
|
||||
bool get hasLiveQuote => isReady && _quotation?.quotation != null && !isExpired;
|
||||
|
||||
void setAutoRefreshMode(AutoRefreshMode mode) {
|
||||
if (_autoRefreshMode == mode) return;
|
||||
_autoRefreshMode = mode;
|
||||
_syncAutoRefresh();
|
||||
notifyListeners();
|
||||
_quotation?.setAutoRefreshMode(mode);
|
||||
}
|
||||
|
||||
void refreshQuotation() {
|
||||
@@ -66,7 +59,6 @@ class QuotationController extends ChangeNotifier {
|
||||
}
|
||||
|
||||
void _handleQuotationChanged() {
|
||||
_syncAutoRefresh();
|
||||
_syncTicker();
|
||||
notifyListeners();
|
||||
}
|
||||
@@ -103,33 +95,10 @@ class QuotationController extends ChangeNotifier {
|
||||
_ticker = null;
|
||||
}
|
||||
|
||||
void _syncAutoRefresh() {
|
||||
final quotation = _quotation;
|
||||
if (quotation == null) {
|
||||
_autoRefreshController.reset();
|
||||
return;
|
||||
}
|
||||
|
||||
final isAutoRefreshEnabled = _autoRefreshMode == AutoRefreshMode.on;
|
||||
_autoRefreshController.setEnabled(isAutoRefreshEnabled);
|
||||
final canAutoRefresh = isAutoRefreshEnabled && quotation.canRefresh;
|
||||
_autoRefreshController.sync(
|
||||
isLoading: quotation.isLoading,
|
||||
canRefresh: canAutoRefresh,
|
||||
expiresAt: quoteExpiresAt,
|
||||
onRefresh: _refreshQuotation,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _refreshQuotation() async {
|
||||
await _quotation?.refreshQuotation();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_quotation?.removeListener(_handleQuotationChanged);
|
||||
_autoRefreshController.dispose();
|
||||
_stopTicker();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:pshared/provider/account.dart';
|
||||
|
||||
import 'package:pweb/models/flow_status.dart';
|
||||
import 'package:pweb/models/resend/action_result.dart';
|
||||
import 'package:pweb/models/resend/avaliability.dart';
|
||||
import 'package:pweb/controllers/common/cooldown.dart';
|
||||
import 'package:pweb/models/state/flow_status.dart';
|
||||
import 'package:pweb/models/auth/resend/action_result.dart';
|
||||
import 'package:pweb/models/auth/resend/avaliability.dart';
|
||||
|
||||
|
||||
class SignupConfirmationCardController extends ChangeNotifier {
|
||||
@@ -14,18 +13,17 @@ class SignupConfirmationCardController extends ChangeNotifier {
|
||||
required AccountProvider accountProvider,
|
||||
Duration defaultCooldown = const Duration(seconds: 60),
|
||||
}) : _accountProvider = accountProvider,
|
||||
_defaultCooldown = defaultCooldown;
|
||||
_defaultCooldown = defaultCooldown {
|
||||
_cooldown = CooldownController(onTick: () => notifyListeners());
|
||||
}
|
||||
|
||||
final AccountProvider _accountProvider;
|
||||
final Duration _defaultCooldown;
|
||||
|
||||
Timer? _cooldownTimer;
|
||||
DateTime? _cooldownUntil;
|
||||
int _cooldownRemainingSeconds = 0;
|
||||
late final CooldownController _cooldown;
|
||||
FlowStatus _resendState = FlowStatus.idle;
|
||||
String? _email;
|
||||
|
||||
int get cooldownRemainingSeconds => _cooldownRemainingSeconds;
|
||||
int get cooldownRemainingSeconds => _cooldown.remainingSeconds;
|
||||
ResendAvailability get resendAvailability {
|
||||
final email = _email;
|
||||
if (email == null || email.isEmpty) {
|
||||
@@ -34,7 +32,7 @@ class SignupConfirmationCardController extends ChangeNotifier {
|
||||
if (_resendState == FlowStatus.submitting) {
|
||||
return ResendAvailability.resending;
|
||||
}
|
||||
if (_cooldownRemainingSeconds > 0) {
|
||||
if (_cooldown.isActive) {
|
||||
return ResendAvailability.cooldown;
|
||||
}
|
||||
return ResendAvailability.available;
|
||||
@@ -85,43 +83,12 @@ class SignupConfirmationCardController extends ChangeNotifier {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_cooldownTimer?.cancel();
|
||||
_cooldown.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _startCooldown(Duration duration) {
|
||||
_cooldownTimer?.cancel();
|
||||
_cooldownUntil = DateTime.now().add(duration);
|
||||
_syncRemaining();
|
||||
|
||||
if (_cooldownRemainingSeconds <= 0) {
|
||||
_cooldownUntil = null;
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
_cooldownTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
_syncRemaining();
|
||||
if (_cooldownRemainingSeconds <= 0) {
|
||||
timer.cancel();
|
||||
_cooldownUntil = null;
|
||||
notifyListeners();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _syncRemaining() {
|
||||
final remaining = _cooldownRemaining();
|
||||
if (remaining == _cooldownRemainingSeconds) return;
|
||||
_cooldownRemainingSeconds = remaining;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
int _cooldownRemaining() {
|
||||
final until = _cooldownUntil;
|
||||
if (until == null) return 0;
|
||||
final remaining = until.difference(DateTime.now()).inSeconds;
|
||||
return remaining < 0 ? 0 : remaining;
|
||||
_cooldown.start(duration);
|
||||
}
|
||||
|
||||
void _setResendState(FlowStatus state) {
|
||||
|
||||
@@ -703,6 +703,9 @@
|
||||
"accountVerifiedDescription": "Your account has been successfully verified. You can now log in to access your account.",
|
||||
"retryVerification": "Retry Verification",
|
||||
"save": "Save",
|
||||
"discard": "Discard",
|
||||
"unsavedChangesTitle": "Save changes?",
|
||||
"unsavedChangesMessage": "You have unsaved changes.",
|
||||
"editWallet": "Edit Wallet",
|
||||
"userNamePlaceholder": "User Name",
|
||||
"noWalletSelected": "No wallet selected",
|
||||
|
||||
@@ -705,6 +705,9 @@
|
||||
"accountVerifiedDescription": "Ваш аккаунт успешно подтвержден. Теперь вы можете войти, чтобы получить доступ к своему аккаунту",
|
||||
"retryVerification": "Повторить подтверждение",
|
||||
"save": "Сохранить",
|
||||
"discard": "Не сохранять",
|
||||
"unsavedChangesTitle": "Сохранить изменения?",
|
||||
"unsavedChangesMessage": "У вас есть несохранённые изменения.",
|
||||
"editWallet": "Редактировать кошелек",
|
||||
"userNamePlaceholder": "Имя пользователя",
|
||||
"noWalletSelected": "Кошелек не выбран",
|
||||
|
||||
@@ -3,6 +3,8 @@ import 'package:flutter/material.dart';
|
||||
// ignore: depend_on_referenced_packages
|
||||
import 'package:flutter_web_plugins/flutter_web_plugins.dart';
|
||||
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:logging/logging.dart';
|
||||
@@ -29,10 +31,12 @@ import 'package:pweb/app/app.dart';
|
||||
import 'package:pweb/pages/invitations/widgets/list/view_model.dart';
|
||||
import 'package:pweb/app/timeago.dart';
|
||||
import 'package:pweb/providers/two_factor.dart';
|
||||
import 'package:pweb/controllers/operations/wallet_transactions.dart';
|
||||
import 'package:pweb/providers/wallet_transactions.dart';
|
||||
import 'package:pweb/services/posthog.dart';
|
||||
import 'package:pweb/services/wallet_transactions.dart';
|
||||
import 'package:pweb/providers/account.dart';
|
||||
import 'package:pweb/providers/locale.dart';
|
||||
|
||||
void _setupLogging() {
|
||||
Logger.root.level = Level.ALL;
|
||||
@@ -51,13 +55,16 @@ void main() async {
|
||||
|
||||
_setupLogging();
|
||||
setUrlStrategy(PathUrlStrategy());
|
||||
GoRouter.optionURLReflectsImperativeAPIs = true;
|
||||
|
||||
initializeTimeagoLocales();
|
||||
|
||||
runApp(
|
||||
MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider(create: (_) => LocaleProvider(null)),
|
||||
ChangeNotifierProvider<LocaleProvider>(
|
||||
create: (_) => PwebLocaleProvider(null),
|
||||
),
|
||||
ChangeNotifierProxyProvider<LocaleProvider, AccountProvider>(
|
||||
create: (_) => PwebAccountProvider(),
|
||||
update: (context, localeProvider, provider) =>
|
||||
@@ -68,7 +75,13 @@ void main() async {
|
||||
update: (context, accountProvider, provider) =>
|
||||
provider!..update(accountProvider),
|
||||
),
|
||||
ChangeNotifierProvider(create: (_) => OrganizationsProvider()),
|
||||
ChangeNotifierProxyProvider<AccountProvider, OrganizationsProvider>(
|
||||
//TODO controll scope of the provider
|
||||
create: (_) => OrganizationsProvider(),
|
||||
lazy: false,
|
||||
update: (_, accountProvider, organizations) =>
|
||||
organizations!..updateAccount(accountProvider),
|
||||
),
|
||||
ChangeNotifierProxyProvider<OrganizationsProvider, PermissionsProvider>(
|
||||
create: (_) => PermissionsProvider(),
|
||||
update: (context, orgnization, provider) =>
|
||||
@@ -130,9 +143,16 @@ void main() async {
|
||||
update: (_, wallets, controller) => controller!..update(wallets),
|
||||
),
|
||||
ChangeNotifierProvider(
|
||||
create: (_) =>
|
||||
WalletTransactionsProvider(MockWalletTransactionsService())
|
||||
..load(),
|
||||
create: (_) => WalletTransactionsProvider(
|
||||
MockWalletTransactionsService(),
|
||||
),
|
||||
),
|
||||
ChangeNotifierProxyProvider<
|
||||
WalletTransactionsProvider,
|
||||
WalletTransactionsController
|
||||
>(
|
||||
create: (_) => WalletTransactionsController(),
|
||||
update: (_, provider, controller) => controller!..update(provider),
|
||||
),
|
||||
],
|
||||
child: const PayApp(),
|
||||
|
||||
4
frontend/pweb/lib/models/account/account_loader.dart
Normal file
4
frontend/pweb/lib/models/account/account_loader.dart
Normal file
@@ -0,0 +1,4 @@
|
||||
enum AccountLoaderAction {
|
||||
goToLogin,
|
||||
showErrorAndGoToLogin,
|
||||
}
|
||||
@@ -1,25 +1,35 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:pshared/models/auto_refresh_mode.dart';
|
||||
import 'package:pshared/models/payment/quote/status_type.dart';
|
||||
|
||||
import 'package:pweb/pages/dashboard/payouts/quote_status/widgets/body.dart';
|
||||
import 'package:pweb/providers/quotation/quotation.dart';
|
||||
import 'package:pweb/controllers/payouts/quotation.dart';
|
||||
import 'package:pweb/utils/quote_duration_format.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
|
||||
class QuoteStatus extends StatelessWidget {
|
||||
final double spacing;
|
||||
class QuoteStatusData {
|
||||
final QuoteStatusType statusType;
|
||||
final String statusText;
|
||||
final String? helperText;
|
||||
final bool isLoading;
|
||||
final bool canRefresh;
|
||||
final bool showPrimaryRefresh;
|
||||
final AutoRefreshMode autoRefreshMode;
|
||||
|
||||
const QuoteStatus({super.key, required this.spacing});
|
||||
const QuoteStatusData({
|
||||
required this.statusType,
|
||||
required this.statusText,
|
||||
required this.helperText,
|
||||
required this.isLoading,
|
||||
required this.canRefresh,
|
||||
required this.showPrimaryRefresh,
|
||||
required this.autoRefreshMode,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final loc = AppLocalizations.of(context)!;
|
||||
final controller = context.watch<QuotationController>();
|
||||
static QuoteStatusData resolve({
|
||||
required QuotationController controller,
|
||||
required AppLocalizations loc,
|
||||
}) {
|
||||
final timeLeft = controller.timeLeft;
|
||||
final isLoading = controller.isLoading;
|
||||
final statusType = controller.quoteStatus;
|
||||
@@ -54,8 +64,7 @@ class QuoteStatus extends StatelessWidget {
|
||||
statusType == QuoteStatusType.error ||
|
||||
statusType == QuoteStatusType.missing);
|
||||
|
||||
return QuoteStatusBody(
|
||||
spacing: spacing,
|
||||
return QuoteStatusData(
|
||||
statusType: statusType,
|
||||
statusText: statusText,
|
||||
helperText: helperText,
|
||||
@@ -63,8 +72,6 @@ class QuoteStatus extends StatelessWidget {
|
||||
canRefresh: canRefresh,
|
||||
showPrimaryRefresh: showPrimaryRefresh,
|
||||
autoRefreshMode: autoRefreshMode,
|
||||
onAutoRefreshModeChanged: controller.setAutoRefreshMode,
|
||||
onRefresh: controller.refreshQuotation,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
class PaymentSummaryValues {
|
||||
final String sentAmount;
|
||||
final String fee;
|
||||
final String recipientReceives;
|
||||
final String total;
|
||||
|
||||
const PaymentSummaryValues({
|
||||
required this.sentAmount,
|
||||
required this.fee,
|
||||
required this.recipientReceives,
|
||||
required this.total,
|
||||
62
frontend/pweb/lib/models/recipient/method_snapshot.dart
Normal file
62
frontend/pweb/lib/models/recipient/method_snapshot.dart
Normal file
@@ -0,0 +1,62 @@
|
||||
import 'package:collection/collection.dart';
|
||||
|
||||
import 'package:pshared/data/mapper/payment/method.dart';
|
||||
import 'package:pshared/models/payment/methods/data.dart';
|
||||
import 'package:pshared/models/payment/type.dart';
|
||||
import 'package:pshared/models/recipient/payment_method_draft.dart';
|
||||
|
||||
|
||||
class RecipientMethodSnapshot {
|
||||
final PaymentType type;
|
||||
final String? existingId;
|
||||
final Map<String, dynamic>? data;
|
||||
final Map<String, String>? metadata;
|
||||
|
||||
static final DeepCollectionEquality _mapEquality =
|
||||
const DeepCollectionEquality();
|
||||
|
||||
const RecipientMethodSnapshot({
|
||||
required this.type,
|
||||
required this.existingId,
|
||||
required this.data,
|
||||
required this.metadata,
|
||||
});
|
||||
|
||||
factory RecipientMethodSnapshot.fromDraft(RecipientMethodDraft draft) {
|
||||
return RecipientMethodSnapshot(
|
||||
type: draft.type,
|
||||
existingId: draft.existing?.id,
|
||||
data: _dataToSnapshot(draft.data),
|
||||
metadata: _metadataToSnapshot(draft.data),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
return other is RecipientMethodSnapshot &&
|
||||
other.type == type &&
|
||||
other.existingId == existingId &&
|
||||
_mapEquality.equals(other.data, data) &&
|
||||
_mapEquality.equals(other.metadata, metadata);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
type,
|
||||
existingId,
|
||||
_mapEquality.hash(data),
|
||||
_mapEquality.hash(metadata),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic>? _dataToSnapshot(PaymentMethodData? data) {
|
||||
if (data == null) return null;
|
||||
return data.toJsonMap();
|
||||
}
|
||||
|
||||
Map<String, String>? _metadataToSnapshot(PaymentMethodData? data) {
|
||||
final metadata = data?.metadata;
|
||||
if (metadata == null) return null;
|
||||
return Map<String, String>.from(metadata);
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
class RoleDraft {
|
||||
final String name;
|
||||
final String description;
|
||||
|
||||
const RoleDraft({
|
||||
required this.name,
|
||||
required this.description,
|
||||
});
|
||||
}
|
||||
@@ -2,15 +2,14 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:pshared/models/payment/methods/data.dart';
|
||||
import 'package:pshared/models/payment/type.dart';
|
||||
|
||||
import 'package:pweb/controllers/organization/address_book_recipient_form.dart';
|
||||
import 'package:pweb/controllers/organization/address_book_recipient_form_selection.dart';
|
||||
import 'package:pweb/pages/address_book/form/view.dart';
|
||||
import 'package:pweb/providers/address_book_recipient_form.dart';
|
||||
import 'package:pweb/utils/payment/availability.dart';
|
||||
|
||||
|
||||
class AddressBookRecipientFormBody extends StatefulWidget {
|
||||
class AddressBookRecipientFormBody extends StatelessWidget {
|
||||
final GlobalKey<FormState> formKey;
|
||||
final TextEditingController nameCtrl;
|
||||
final TextEditingController emailCtrl;
|
||||
@@ -27,94 +26,43 @@ class AddressBookRecipientFormBody extends StatefulWidget {
|
||||
required this.onBack,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AddressBookRecipientFormBody> createState() => _AddressBookRecipientFormBodyState();
|
||||
}
|
||||
|
||||
class _AddressBookRecipientFormBodyState extends State<AddressBookRecipientFormBody> {
|
||||
PaymentType? _selectedType;
|
||||
int? _selectedIndex;
|
||||
|
||||
void _reconcileSelection(AddressBookRecipientFormProvider formState) {
|
||||
final types = formState.supportedTypes;
|
||||
if (types.isEmpty) return;
|
||||
|
||||
var nextType = _selectedType;
|
||||
var nextIndex = _selectedIndex;
|
||||
|
||||
if (nextType == null || !types.contains(nextType)) {
|
||||
nextType = formState.preferredType ?? types.first;
|
||||
nextIndex = null;
|
||||
}
|
||||
|
||||
final entries = formState.methods[nextType] ?? const [];
|
||||
if (entries.isEmpty) {
|
||||
nextIndex = null;
|
||||
} else if (nextIndex == null || nextIndex < 0 || nextIndex >= entries.length) {
|
||||
nextIndex = 0;
|
||||
}
|
||||
|
||||
if (nextType == _selectedType && nextIndex == _selectedIndex) return;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_selectedType = nextType;
|
||||
_selectedIndex = nextIndex;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void _onMethodSelected(PaymentType type, int index) {
|
||||
setState(() {
|
||||
_selectedType = type;
|
||||
_selectedIndex = index;
|
||||
});
|
||||
}
|
||||
|
||||
void _onMethodAdd(AddressBookRecipientFormProvider formState, PaymentType type) {
|
||||
final newIndex = formState.addMethod(type);
|
||||
setState(() {
|
||||
_selectedType = type;
|
||||
_selectedIndex = newIndex;
|
||||
});
|
||||
}
|
||||
|
||||
void _onMethodRemove(AddressBookRecipientFormProvider formState, int index) {
|
||||
final type = _selectedType ?? formState.supportedTypes.first;
|
||||
formState.removeMethod(type, index);
|
||||
}
|
||||
|
||||
void _onMethodChanged(
|
||||
AddressBookRecipientFormProvider formState,
|
||||
int index,
|
||||
PaymentMethodData data,
|
||||
) {
|
||||
final type = _selectedType ?? formState.supportedTypes.first;
|
||||
formState.updateMethod(type, index, data);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final formState = context.watch<AddressBookRecipientFormProvider>();
|
||||
_reconcileSelection(formState);
|
||||
final formState = Provider.of<AddressBookRecipientFormProvider>(context, listen: false);
|
||||
final controller = Provider.of<AddressBookRecipientFormController>(context);
|
||||
final selection =
|
||||
Provider.of<AddressBookRecipientFormSelectionController>(context);
|
||||
|
||||
final selectedType = _selectedType ?? formState.supportedTypes.first;
|
||||
if (controller.supportedTypes.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final selectedType = selection.selectedType ?? controller.supportedTypes.first;
|
||||
return FormView(
|
||||
formKey: widget.formKey,
|
||||
nameCtrl: widget.nameCtrl,
|
||||
emailCtrl: widget.emailCtrl,
|
||||
types: formState.supportedTypes,
|
||||
formKey: formKey,
|
||||
nameCtrl: nameCtrl,
|
||||
emailCtrl: emailCtrl,
|
||||
types: controller.supportedTypes,
|
||||
selectedType: selectedType,
|
||||
selectedIndex: _selectedIndex,
|
||||
methods: formState.methods,
|
||||
onMethodSelected: _onMethodSelected,
|
||||
onMethodAdd: (type) => _onMethodAdd(formState, type),
|
||||
selectedIndex: selection.selectedIndex,
|
||||
methods: controller.methods,
|
||||
onMethodSelected: selection.select,
|
||||
onMethodAdd: (type) {
|
||||
final newIndex = controller.addMethod(type);
|
||||
selection.selectAfterAdd(type, newIndex);
|
||||
},
|
||||
disabledTypes: disabledPaymentTypes,
|
||||
onMethodRemove: (index) => _onMethodRemove(formState, index),
|
||||
onMethodChanged: (index, data) => _onMethodChanged(formState, index, data),
|
||||
onSave: () => widget.onSave(formState),
|
||||
isEditing: widget.isEditing,
|
||||
onBack: widget.onBack,
|
||||
onMethodRemove: (index) {
|
||||
final type = selection.selectedType ?? controller.supportedTypes.first;
|
||||
controller.removeMethod(type, index);
|
||||
},
|
||||
onMethodChanged: (index, data) {
|
||||
final type = selection.selectedType ?? controller.supportedTypes.first;
|
||||
controller.updateMethod(type, index, data);
|
||||
},
|
||||
onSave: () => onSave(formState),
|
||||
isEditing: isEditing,
|
||||
onBack: onBack,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,12 +7,11 @@ import 'package:pshared/models/recipient/recipient.dart';
|
||||
import 'package:pshared/provider/recipient/methods_cache.dart';
|
||||
import 'package:pshared/provider/recipient/provider.dart';
|
||||
|
||||
import 'package:pweb/controllers/organization/address_book_recipient_form.dart';
|
||||
import 'package:pweb/controllers/organization/address_book_recipient_form_selection.dart';
|
||||
import 'package:pweb/pages/address_book/form/body.dart';
|
||||
import 'package:pweb/providers/address_book_recipient_form.dart';
|
||||
import 'package:pweb/utils/payment/availability.dart';
|
||||
import 'package:pweb/utils/payment/label.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
|
||||
class AddressBookRecipientForm extends StatefulWidget {
|
||||
@@ -29,6 +28,8 @@ class _AddressBookRecipientFormState extends State<AddressBookRecipientForm> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
late TextEditingController _nameCtrl;
|
||||
late TextEditingController _emailCtrl;
|
||||
late final String _initialName;
|
||||
late final String _initialEmail;
|
||||
|
||||
static const List<PaymentType> _supportedTypes = visiblePaymentTypes;
|
||||
|
||||
@@ -36,61 +37,79 @@ class _AddressBookRecipientFormState extends State<AddressBookRecipientForm> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
final r = widget.recipient;
|
||||
_nameCtrl = TextEditingController(text: r?.name ?? '');
|
||||
_emailCtrl = TextEditingController(text: r?.email ?? '');
|
||||
}
|
||||
|
||||
Map<PaymentType, String> _methodNames(BuildContext context) => {
|
||||
for (final type in _supportedTypes) type: getPaymentTypeLabel(context, type),
|
||||
};
|
||||
|
||||
Future<void> _save(AddressBookRecipientFormProvider formState) async {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
if (!_formKey.currentState!.validate() || !formState.hasAnyMethod) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l10n.recipientFormRule)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final saved = await formState.save(
|
||||
name: _nameCtrl.text,
|
||||
email: _emailCtrl.text,
|
||||
methodNames: _methodNames(context),
|
||||
);
|
||||
widget.onSaved?.call(saved);
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l10n.notificationError(l10n.noErrorInformation))),
|
||||
);
|
||||
}
|
||||
_initialName = r?.name ?? '';
|
||||
_initialEmail = r?.email ?? '';
|
||||
_nameCtrl = TextEditingController(text: _initialName);
|
||||
_emailCtrl = TextEditingController(text: _initialEmail);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ChangeNotifierProxyProvider2<
|
||||
RecipientsProvider,
|
||||
RecipientMethodsCacheProvider,
|
||||
AddressBookRecipientFormProvider
|
||||
>(
|
||||
create: (_) => AddressBookRecipientFormProvider(
|
||||
recipient: widget.recipient,
|
||||
supportedTypes: _supportedTypes,
|
||||
),
|
||||
update: (_, recipientsProvider, methodsCache, formProvider) =>
|
||||
formProvider!..updateProviders(
|
||||
recipientsProvider: recipientsProvider,
|
||||
methodsCache: methodsCache,
|
||||
return MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProxyProvider2<
|
||||
RecipientsProvider,
|
||||
RecipientMethodsCacheProvider,
|
||||
AddressBookRecipientFormProvider
|
||||
>(
|
||||
create: (_) => AddressBookRecipientFormProvider(
|
||||
recipient: widget.recipient,
|
||||
),
|
||||
child: AddressBookRecipientFormBody(
|
||||
formKey: _formKey,
|
||||
nameCtrl: _nameCtrl,
|
||||
emailCtrl: _emailCtrl,
|
||||
isEditing: widget.recipient != null,
|
||||
onSave: _save,
|
||||
onBack: () => widget.onSaved?.call(null),
|
||||
update: (_, recipientsProvider, methodsCache, formProvider) =>
|
||||
formProvider!..updateProviders(
|
||||
recipientsProvider: recipientsProvider,
|
||||
methodsCache: methodsCache,
|
||||
),
|
||||
),
|
||||
ChangeNotifierProxyProvider<
|
||||
RecipientMethodsCacheProvider,
|
||||
AddressBookRecipientFormController
|
||||
>(
|
||||
create: (_) => AddressBookRecipientFormController(
|
||||
supportedTypes: _supportedTypes,
|
||||
),
|
||||
update: (_, methodsCache, controller) => controller!
|
||||
..update(
|
||||
recipient: widget.recipient,
|
||||
methodsCache: methodsCache,
|
||||
),
|
||||
),
|
||||
ChangeNotifierProxyProvider<
|
||||
AddressBookRecipientFormController,
|
||||
AddressBookRecipientFormSelectionController
|
||||
>(
|
||||
create: (_) => AddressBookRecipientFormSelectionController(),
|
||||
update: (_, formController, selectionController) =>
|
||||
selectionController!..update(formController),
|
||||
),
|
||||
],
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final formState = context.read<AddressBookRecipientFormProvider>();
|
||||
final controller = context.read<AddressBookRecipientFormController>();
|
||||
return AddressBookRecipientFormBody(
|
||||
formKey: _formKey,
|
||||
nameCtrl: _nameCtrl,
|
||||
emailCtrl: _emailCtrl,
|
||||
isEditing: widget.recipient != null,
|
||||
onSave: (form) => controller.saveForm(
|
||||
context: context,
|
||||
formKey: _formKey,
|
||||
formState: form,
|
||||
name: _nameCtrl.text,
|
||||
email: _emailCtrl.text,
|
||||
onSaved: widget.onSaved,
|
||||
),
|
||||
onBack: () => controller.handleBack(
|
||||
context: context,
|
||||
formKey: _formKey,
|
||||
formState: formState,
|
||||
name: _nameCtrl.text,
|
||||
email: _emailCtrl.text,
|
||||
onSaved: widget.onSaved,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,8 @@ import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
class AddPaymentMethodButton extends StatelessWidget {
|
||||
final List<PaymentType> types;
|
||||
final Set<PaymentType> disabledTypes;
|
||||
final ValueChanged<PaymentType> onAdd;
|
||||
final ValueChanged<PaymentType>? onAdd;
|
||||
final VoidCallback? onPressed;
|
||||
|
||||
static const double _borderRadius = 14;
|
||||
static const double _iconSize = 18;
|
||||
@@ -20,13 +21,18 @@ class AddPaymentMethodButton extends StatelessWidget {
|
||||
static const double _menuIconSize = 18;
|
||||
static const double _menuIconTextSpacing = 8;
|
||||
static const double _buttonHeight = 70;
|
||||
static const EdgeInsets _buttonPadding = EdgeInsets.symmetric(horizontal: 14, vertical: 10);
|
||||
static const EdgeInsets _buttonPadding = EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 10,
|
||||
);
|
||||
static const FontWeight _labelWeight = FontWeight.w600;
|
||||
|
||||
const AddPaymentMethodButton({
|
||||
super.key,
|
||||
required this.types,
|
||||
required this.disabledTypes,
|
||||
required this.onAdd,
|
||||
this.onAdd,
|
||||
this.onPressed,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -41,6 +47,38 @@ class AddPaymentMethodButton extends StatelessWidget {
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.onSurface.withValues(alpha: 0.4);
|
||||
|
||||
final buttonChild = Container(
|
||||
height: _buttonHeight,
|
||||
padding: _buttonPadding,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(_borderRadius),
|
||||
border: Border.all(color: borderColor),
|
||||
color: theme.colorScheme.onSecondary,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.add, size: _iconSize, color: textColor),
|
||||
const SizedBox(width: _iconTextSpacing),
|
||||
Text(
|
||||
l10n.addPaymentMethod,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
color: textColor,
|
||||
fontWeight: _labelWeight,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
final onPressed = this.onPressed;
|
||||
if (onPressed != null) {
|
||||
return GestureDetector(
|
||||
onTap: hasEnabled ? onPressed : null,
|
||||
child: buttonChild,
|
||||
);
|
||||
}
|
||||
|
||||
return PopupMenuButton<PaymentType>(
|
||||
enabled: hasEnabled,
|
||||
onSelected: onAdd,
|
||||
@@ -67,29 +105,7 @@ class AddPaymentMethodButton extends StatelessWidget {
|
||||
);
|
||||
})
|
||||
.toList(),
|
||||
child: Container(
|
||||
height: _buttonHeight,
|
||||
padding: _buttonPadding,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(_borderRadius),
|
||||
border: Border.all(color: borderColor),
|
||||
color: theme.colorScheme.onSecondary,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.add, size: _iconSize, color: textColor),
|
||||
const SizedBox(width: _iconTextSpacing),
|
||||
Text(
|
||||
l10n.addPaymentMethod,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
color: textColor,
|
||||
fontWeight: _labelWeight,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: buttonChild,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import 'package:pshared/models/recipient/payment_method_draft.dart';
|
||||
import 'package:pweb/pages/payment_methods/form.dart';
|
||||
import 'package:pweb/pages/payment_methods/icon.dart';
|
||||
import 'package:pweb/widgets/dialogs/confirmation_dialog.dart';
|
||||
import 'package:pweb/models/state/control_state.dart';
|
||||
import 'package:pweb/models/state/visibility.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
@@ -17,6 +19,8 @@ class PaymentMethodPanel extends StatelessWidget {
|
||||
final List<RecipientMethodDraft> entries;
|
||||
final ValueChanged<int> onRemove;
|
||||
final void Function(int, PaymentMethodData) onChanged;
|
||||
final ControlState editState;
|
||||
final VisibilityState deleteVisibility;
|
||||
|
||||
final double padding;
|
||||
|
||||
@@ -27,6 +31,8 @@ class PaymentMethodPanel extends StatelessWidget {
|
||||
required this.entries,
|
||||
required this.onRemove,
|
||||
required this.onChanged,
|
||||
this.editState = ControlState.enabled,
|
||||
this.deleteVisibility = VisibilityState.visible,
|
||||
this.padding = 16,
|
||||
});
|
||||
|
||||
@@ -79,7 +85,7 @@ class PaymentMethodPanel extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (entry != null)
|
||||
if (entry != null && deleteVisibility == VisibilityState.visible)
|
||||
TextButton.icon(
|
||||
onPressed: () => _confirmDelete(context, () => onRemove(selectedIndex)),
|
||||
icon: Icon(Icons.delete, color: theme.colorScheme.error),
|
||||
@@ -96,6 +102,7 @@ class PaymentMethodPanel extends StatelessWidget {
|
||||
key: ValueKey('${selectedType.name}-${entry.existing?.id ?? selectedIndex}-form'),
|
||||
selectedType: selectedType,
|
||||
initialData: entry.data,
|
||||
isEditable: editState == ControlState.enabled,
|
||||
onChanged: (data) {
|
||||
if (data == null) return;
|
||||
onChanged(selectedIndex, data);
|
||||
|
||||
@@ -3,8 +3,8 @@ import 'package:flutter/material.dart';
|
||||
import 'package:pshared/models/payment/type.dart';
|
||||
import 'package:pshared/models/recipient/payment_method_draft.dart';
|
||||
|
||||
import 'package:pweb/models/payment_method_tile/availability.dart';
|
||||
import 'package:pweb/models/payment_method_tile/selection.dart';
|
||||
import 'package:pweb/models/payment/method_tile/availability.dart';
|
||||
import 'package:pweb/models/payment/method_tile/selection.dart';
|
||||
import 'package:pweb/pages/address_book/form/widgets/payment_methods/add_button.dart';
|
||||
import 'package:pweb/pages/address_book/form/widgets/payment_methods/tile.dart';
|
||||
|
||||
@@ -15,8 +15,10 @@ class PaymentMethodSelectorRow extends StatelessWidget {
|
||||
final int? selectedIndex;
|
||||
final Map<PaymentType, List<RecipientMethodDraft>> methods;
|
||||
final void Function(PaymentType type, int index) onSelected;
|
||||
final ValueChanged<PaymentType> onAdd;
|
||||
final ValueChanged<PaymentType>? onAdd;
|
||||
final VoidCallback? onAddPressed;
|
||||
final Set<PaymentType> disabledTypes;
|
||||
final String? Function(RecipientMethodDraft entry)? detailsBuilder;
|
||||
|
||||
final double spacing;
|
||||
final double tilePadding;
|
||||
@@ -29,8 +31,10 @@ class PaymentMethodSelectorRow extends StatelessWidget {
|
||||
required this.selectedIndex,
|
||||
required this.methods,
|
||||
required this.onSelected,
|
||||
required this.onAdd,
|
||||
this.onAdd,
|
||||
this.onAddPressed,
|
||||
this.disabledTypes = const {},
|
||||
this.detailsBuilder,
|
||||
this.spacing = 12,
|
||||
this.tilePadding = 10,
|
||||
this.runSpacing = 12,
|
||||
@@ -51,12 +55,14 @@ class PaymentMethodSelectorRow extends StatelessWidget {
|
||||
final availability = isAdded
|
||||
? PaymentMethodTileAvailability.added
|
||||
: PaymentMethodTileAvailability.available;
|
||||
final detailsText = detailsBuilder?.call(entry);
|
||||
tiles.add(
|
||||
PaymentMethodTile(
|
||||
type: type,
|
||||
selection: selection,
|
||||
availability: availability,
|
||||
padding: tilePadding,
|
||||
detailsText: detailsText,
|
||||
onTap: () => onSelected(type, index),
|
||||
),
|
||||
);
|
||||
@@ -68,6 +74,7 @@ class PaymentMethodSelectorRow extends StatelessWidget {
|
||||
types: types,
|
||||
disabledTypes: disabledTypes,
|
||||
onAdd: onAdd,
|
||||
onPressed: onAddPressed,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -78,4 +85,4 @@ class PaymentMethodSelectorRow extends StatelessWidget {
|
||||
children: tiles,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pshared/models/payment/type.dart';
|
||||
|
||||
import 'package:pweb/models/payment_method_tile/availability.dart';
|
||||
import 'package:pweb/models/payment_method_tile/selection.dart';
|
||||
import 'package:pweb/models/payment/method_tile/availability.dart';
|
||||
import 'package:pweb/models/payment/method_tile/selection.dart';
|
||||
import 'package:pweb/pages/payment_methods/icon.dart';
|
||||
import 'package:pweb/utils/payment/label.dart';
|
||||
|
||||
@@ -15,6 +15,7 @@ class PaymentMethodTile extends StatelessWidget {
|
||||
final PaymentMethodTileSelection selection;
|
||||
final PaymentMethodTileAvailability availability;
|
||||
final double padding;
|
||||
final String? detailsText;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const PaymentMethodTile({
|
||||
@@ -22,6 +23,7 @@ class PaymentMethodTile extends StatelessWidget {
|
||||
required this.selection,
|
||||
required this.availability,
|
||||
required this.padding,
|
||||
this.detailsText,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@@ -50,6 +52,13 @@ class PaymentMethodTile extends StatelessWidget {
|
||||
final backgroundColor = isSelected
|
||||
? theme.colorScheme.primary.withValues(alpha: 0.08)
|
||||
: theme.colorScheme.onSecondary;
|
||||
final showDetails =
|
||||
availability == PaymentMethodTileAvailability.added &&
|
||||
detailsText != null &&
|
||||
detailsText!.isNotEmpty;
|
||||
final detailsColor = isSelected
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.onSurfaceVariant;
|
||||
|
||||
return IntrinsicWidth(
|
||||
child: Opacity(
|
||||
@@ -68,9 +77,9 @@ class PaymentMethodTile extends StatelessWidget {
|
||||
border: Border.all(color: borderColor),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
iconForPaymentType(type),
|
||||
@@ -78,30 +87,44 @@ class PaymentMethodTile extends StatelessWidget {
|
||||
color: isSelected ? theme.colorScheme.primary : theme.colorScheme.onSurface,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
label,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isSelected ? theme.colorScheme.primary : theme.colorScheme.onSurface,
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isSelected ? theme.colorScheme.primary : theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (showDetails)
|
||||
Text(
|
||||
detailsText!,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: detailsColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
)
|
||||
else
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: badgeColor,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
badgeLabel,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: badgeTextColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: badgeColor,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
badgeLabel,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: badgeTextColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -34,6 +34,8 @@ class RecipientAddressBookInfoRow extends StatelessWidget {
|
||||
final style = textStyle ?? Theme.of(context).textTheme.bodySmall!;
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(iconForPaymentType(type), size: iconSize),
|
||||
@@ -55,4 +57,4 @@ class RecipientAddressBookInfoRow extends StatelessWidget {
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ class _RecipientAddressBookItemState extends State<RecipientAddressBookItem> {
|
||||
child: Padding(
|
||||
padding: widget.padding,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
|
||||
@@ -29,12 +29,22 @@ class RecipientPaymentRow extends StatelessWidget {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
return Row(
|
||||
spacing: spacing,
|
||||
children: cacheProvider.methodsForRecipient(recipientId).map((m) => RecipientAddressBookInfoRow(
|
||||
type: m.type,
|
||||
value: getPaymentTypeDescription(context, m),
|
||||
)).toList(),
|
||||
return Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Wrap(
|
||||
alignment: WrapAlignment.start,
|
||||
runAlignment: WrapAlignment.start,
|
||||
crossAxisAlignment: WrapCrossAlignment.start,
|
||||
spacing: spacing,
|
||||
runSpacing: spacing,
|
||||
children: cacheProvider
|
||||
.methodsForRecipient(recipientId)
|
||||
.map((m) => RecipientAddressBookInfoRow(
|
||||
type: m.type,
|
||||
value: getPaymentTypeDescription(context, m),
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import 'package:pshared/models/payment/type.dart';
|
||||
import 'package:pshared/models/recipient/recipient.dart';
|
||||
import 'package:pshared/models/payment/wallet.dart';
|
||||
|
||||
import 'package:pweb/models/dashboard_payment_mode.dart';
|
||||
import 'package:pweb/models/dashboard/dashboard_payment_mode.dart';
|
||||
import 'package:pweb/pages/dashboard/buttons/balance/balance.dart';
|
||||
import 'package:pweb/pages/dashboard/buttons/balance/controller.dart';
|
||||
import 'package:pweb/pages/dashboard/buttons/buttons.dart';
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:pshared/provider/payment/amount.dart';
|
||||
import 'package:pshared/utils/currency.dart';
|
||||
import 'package:pshared/utils/money.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
|
||||
class PaymentAmountWidget extends StatefulWidget {
|
||||
const PaymentAmountWidget({super.key});
|
||||
|
||||
@override
|
||||
State<PaymentAmountWidget> createState() => _PaymentAmountWidgetState();
|
||||
}
|
||||
|
||||
class _PaymentAmountWidgetState extends State<PaymentAmountWidget> {
|
||||
late final TextEditingController _controller;
|
||||
bool _isSyncingText = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final initialAmount = context.read<PaymentAmountProvider>().amount;
|
||||
_controller = TextEditingController(text: amountToString(initialAmount));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
double? _parseAmount(String value) {
|
||||
final parsed = parseMoneyAmount(
|
||||
value.replaceAll(',', '.'),
|
||||
fallback: double.nan,
|
||||
);
|
||||
return parsed.isNaN ? null : parsed;
|
||||
}
|
||||
|
||||
void _syncTextWithAmount(double amount) {
|
||||
final parsedText = _parseAmount(_controller.text);
|
||||
if (parsedText != null && parsedText == amount) return;
|
||||
|
||||
final nextText = amountToString(amount);
|
||||
_isSyncingText = true;
|
||||
_controller.value = TextEditingValue(
|
||||
text: nextText,
|
||||
selection: TextSelection.collapsed(offset: nextText.length),
|
||||
);
|
||||
_isSyncingText = false;
|
||||
}
|
||||
|
||||
void _onChanged(String value) {
|
||||
if (_isSyncingText) return;
|
||||
|
||||
final parsed = _parseAmount(value);
|
||||
if (parsed != null) {
|
||||
context.read<PaymentAmountProvider>().setAmount(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final amount = context.select<PaymentAmountProvider, double>((provider) => provider.amount);
|
||||
_syncTextWithAmount(amount);
|
||||
|
||||
return TextField(
|
||||
controller: _controller,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
decoration: InputDecoration(
|
||||
labelText: AppLocalizations.of(context)!.amount,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
onChanged: _onChanged,
|
||||
);
|
||||
}
|
||||
}
|
||||
37
frontend/pweb/lib/pages/dashboard/payouts/amount/feild.dart
Normal file
37
frontend/pweb/lib/pages/dashboard/payouts/amount/feild.dart
Normal file
@@ -0,0 +1,37 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:pshared/controllers/balance_mask/wallets.dart';
|
||||
import 'package:pshared/models/currency.dart';
|
||||
import 'package:pshared/utils/currency.dart';
|
||||
|
||||
import 'package:pweb/controllers/payments/amount_field.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
|
||||
class PaymentAmountField extends StatelessWidget {
|
||||
const PaymentAmountField();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final currency = context.select<WalletsController, Currency?>(
|
||||
(c) => c.selectedWallet?.currency,
|
||||
);
|
||||
final symbol = currency == null ? null : currencyCodeToSymbol(currency);
|
||||
|
||||
final ui = context.watch<PaymentAmountFieldController>();
|
||||
|
||||
return TextField(
|
||||
controller: ui.textController,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
decoration: InputDecoration(
|
||||
labelText: AppLocalizations.of(context)!.amount,
|
||||
border: const OutlineInputBorder(),
|
||||
prefixText: symbol == null ? null : '$symbol\u00A0',
|
||||
),
|
||||
onChanged: ui.handleChanged,
|
||||
);
|
||||
}
|
||||
}
|
||||
28
frontend/pweb/lib/pages/dashboard/payouts/amount/widget.dart
Normal file
28
frontend/pweb/lib/pages/dashboard/payouts/amount/widget.dart
Normal file
@@ -0,0 +1,28 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:pshared/provider/payment/amount.dart';
|
||||
|
||||
import 'package:pweb/controllers/payments/amount_field.dart';
|
||||
import 'package:pweb/pages/dashboard/payouts/amount/feild.dart';
|
||||
|
||||
|
||||
class PaymentAmountWidget extends StatelessWidget {
|
||||
const PaymentAmountWidget({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ChangeNotifierProxyProvider<PaymentAmountProvider, PaymentAmountFieldController>(
|
||||
create: (ctx) {
|
||||
final initialAmount = ctx.read<PaymentAmountProvider>().amount;
|
||||
return PaymentAmountFieldController(initialAmount: initialAmount);
|
||||
},
|
||||
update: (ctx, amountProvider, controller) {
|
||||
controller!.update(amountProvider);
|
||||
return controller;
|
||||
},
|
||||
child: const PaymentAmountField(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -15,15 +15,30 @@ class FeePayerSwitch extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Consumer<PaymentAmountProvider>(
|
||||
builder: (context, provider, _) => Row(
|
||||
spacing: spacing,
|
||||
children: [
|
||||
Text(AppLocalizations.of(context)!.recipientPaysFee, style: style),
|
||||
Switch(
|
||||
value: !provider.payerCoversFee,
|
||||
onChanged: (val) => provider.setPayerCoversFee(!val),
|
||||
builder: (context, provider, _) {
|
||||
final recipientPaysFee = !provider.payerCoversFee;
|
||||
final textStyle = style ?? Theme.of(context).textTheme.bodySmall;
|
||||
void updateRecipientPaysFee(bool value) {
|
||||
provider.setPayerCoversFee(!value);
|
||||
}
|
||||
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
onTap: () => updateRecipientPaysFee(!recipientPaysFee),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Checkbox(
|
||||
value: recipientPaysFee,
|
||||
onChanged: (val) => updateRecipientPaysFee(val ?? false),
|
||||
visualDensity: VisualDensity.compact,
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
SizedBox(width: spacing),
|
||||
Text(AppLocalizations.of(context)!.recipientPaysFee, style: textStyle),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pweb/pages/dashboard/payouts/amount.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:pweb/controllers/payouts/quotation.dart';
|
||||
import 'package:pweb/models/dashboard/quote_status_data.dart';
|
||||
import 'package:pweb/pages/dashboard/payouts/amount/widget.dart';
|
||||
import 'package:pweb/pages/dashboard/payouts/fee_payer.dart';
|
||||
import 'package:pweb/pages/dashboard/payouts/quote_status/quote_status.dart';
|
||||
import 'package:pweb/pages/dashboard/payouts/quote_status/widgets/card.dart';
|
||||
import 'package:pweb/pages/dashboard/payouts/quote_status/widgets/refresh_section.dart';
|
||||
import 'package:pweb/pages/dashboard/payouts/summary/widget.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
@@ -12,34 +17,134 @@ class PaymentFormWidget extends StatelessWidget {
|
||||
const PaymentFormWidget({super.key});
|
||||
|
||||
static const double _smallSpacing = 5;
|
||||
static const double _mediumSpacing = 10;
|
||||
static const double _largeSpacing = 16;
|
||||
static const double _mediumSpacing = 12;
|
||||
static const double _largeSpacing = 20;
|
||||
static const double _extraSpacing = 15;
|
||||
static const double _columnSpacing = 24;
|
||||
static const double _narrowWidth = 560;
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final loc = AppLocalizations.of(context)!;
|
||||
final controller = context.watch<QuotationController>();
|
||||
final quoteStatus = QuoteStatusData.resolve(
|
||||
controller: controller,
|
||||
loc: loc,
|
||||
);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(loc.details, style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: _smallSpacing),
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final isNarrow = constraints.maxWidth < _narrowWidth;
|
||||
|
||||
const PaymentAmountWidget(),
|
||||
final detailsHeader = Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(loc.details, style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: _smallSpacing),
|
||||
],
|
||||
);
|
||||
|
||||
const SizedBox(height: _mediumSpacing),
|
||||
final quoteCard = QuoteStatusCard(
|
||||
statusType: quoteStatus.statusType,
|
||||
isLoading: quoteStatus.isLoading,
|
||||
statusText: quoteStatus.statusText,
|
||||
helperText: quoteStatus.helperText,
|
||||
canRefresh: quoteStatus.canRefresh,
|
||||
showPrimaryRefresh: quoteStatus.showPrimaryRefresh,
|
||||
onRefresh: controller.refreshQuotation,
|
||||
);
|
||||
|
||||
FeePayerSwitch(spacing: _mediumSpacing, style: theme.textTheme.titleMedium),
|
||||
final autoRefreshSection = QuoteAutoRefreshSection(
|
||||
autoRefreshMode: quoteStatus.autoRefreshMode,
|
||||
canRefresh: quoteStatus.canRefresh,
|
||||
onModeChanged: controller.setAutoRefreshMode,
|
||||
);
|
||||
|
||||
const SizedBox(height: _largeSpacing),
|
||||
final leftColumn = Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const PaymentAmountWidget(),
|
||||
const SizedBox(height: _smallSpacing),
|
||||
FeePayerSwitch(
|
||||
spacing: _smallSpacing,
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: _mediumSpacing),
|
||||
const PaymentSummary(spacing: _extraSpacing),
|
||||
],
|
||||
);
|
||||
|
||||
const PaymentSummary(spacing: _extraSpacing),
|
||||
const SizedBox(height: _mediumSpacing),
|
||||
const QuoteStatus(spacing: _smallSpacing),
|
||||
],
|
||||
final rightColumn = Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
quoteCard,
|
||||
const SizedBox(height: _smallSpacing),
|
||||
autoRefreshSection,
|
||||
],
|
||||
);
|
||||
|
||||
if (isNarrow) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
detailsHeader,
|
||||
leftColumn,
|
||||
const SizedBox(height: _largeSpacing),
|
||||
rightColumn,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
detailsHeader,
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const PaymentAmountWidget(),
|
||||
const SizedBox(height: _smallSpacing),
|
||||
FeePayerSwitch(
|
||||
spacing: _smallSpacing,
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: _columnSpacing),
|
||||
Expanded(flex: 2, child: quoteCard),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: _mediumSpacing),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Expanded(
|
||||
flex: 3,
|
||||
child: PaymentSummary(spacing: _extraSpacing),
|
||||
),
|
||||
const SizedBox(width: _columnSpacing),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
autoRefreshSection,
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:pweb/controllers/multiple_payouts.dart';
|
||||
import 'package:pweb/controllers/payout_verification.dart';
|
||||
import 'package:pshared/provider/payment/multiple/quotation.dart';
|
||||
|
||||
import 'package:pweb/controllers/payouts/multiple_payouts.dart';
|
||||
import 'package:pweb/controllers/payouts/payout_verification.dart';
|
||||
import 'package:pweb/utils/payment/payout_verification_flow.dart';
|
||||
import 'package:pweb/widgets/dialogs/payment_status_dialog.dart';
|
||||
|
||||
@@ -13,9 +15,13 @@ Future<void> handleMultiplePayoutSend(
|
||||
MultiplePayoutsController controller,
|
||||
) async {
|
||||
final verificationController = context.read<PayoutVerificationController>();
|
||||
final quotationProvider = context.read<MultiQuotationProvider>();
|
||||
final verificationContextKey = quotationProvider.quotation?.quoteRef ??
|
||||
quotationProvider.quotation?.idempotencyKey;
|
||||
final verified = await runPayoutVerification(
|
||||
context: context,
|
||||
controller: verificationController,
|
||||
contextKey: verificationContextKey,
|
||||
);
|
||||
if (!verified) return;
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import 'package:pshared/models/money.dart';
|
||||
import 'package:pshared/utils/currency.dart';
|
||||
import 'package:pshared/utils/money.dart';
|
||||
|
||||
import 'package:pweb/controllers/multiple_payouts.dart';
|
||||
import 'package:pweb/controllers/payouts/multiple_payouts.dart';
|
||||
|
||||
|
||||
String moneyLabel(Money? money) {
|
||||
@@ -32,8 +32,5 @@ String sentAmountLabel(MultiplePayoutsController controller) {
|
||||
}
|
||||
|
||||
String feeLabel(MultiplePayoutsController controller) {
|
||||
final feeLabelText = moneyLabel(controller.aggregateFeeAmount);
|
||||
final percent = controller.aggregateFeePercent;
|
||||
if (percent == null) return feeLabelText;
|
||||
return '$feeLabelText (${percent.toStringAsFixed(2)}%)';
|
||||
return moneyLabel(controller.aggregateFeeAmount);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pweb/controllers/multiple_payouts.dart';
|
||||
import 'package:pweb/models/summary_values.dart';
|
||||
import 'package:pweb/controllers/payouts/multiple_payouts.dart';
|
||||
import 'package:pweb/models/dashboard/summary_values.dart';
|
||||
import 'package:pweb/pages/dashboard/payouts/multiple/panels/source_quote/helpers.dart';
|
||||
import 'package:pweb/pages/dashboard/payouts/summary/widget.dart';
|
||||
|
||||
@@ -21,7 +21,6 @@ class SourceQuoteSummary extends StatelessWidget {
|
||||
return PaymentSummary(
|
||||
spacing: spacing,
|
||||
values: PaymentSummaryValues(
|
||||
sentAmount: sentAmountLabel(controller),
|
||||
fee: feeLabel(controller),
|
||||
recipientReceives: moneyLabel(controller.aggregateSettlementAmount),
|
||||
total: moneyLabel(controller.aggregateDebitAmount),
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pshared/controllers/balance_mask/wallets.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:pweb/controllers/multiple_payouts.dart';
|
||||
import 'package:pweb/controllers/payout_verification.dart';
|
||||
import 'package:pshared/controllers/balance_mask/wallets.dart';
|
||||
import 'package:pshared/provider/payment/multiple/quotation.dart';
|
||||
|
||||
import 'package:pweb/controllers/payouts/multiple_payouts.dart';
|
||||
import 'package:pweb/controllers/payouts/payout_verification.dart';
|
||||
import 'package:pweb/pages/dashboard/payouts/multiple/actions.dart';
|
||||
import 'package:pweb/pages/dashboard/payouts/multiple/panels/source_quote/header.dart';
|
||||
import 'package:pweb/pages/dashboard/payouts/multiple/panels/source_quote/summary.dart';
|
||||
import 'package:pweb/pages/dashboard/payouts/multiple/widgets/quote_status.dart';
|
||||
import 'package:pweb/pages/payment_methods/payment_page/send_button.dart';
|
||||
import 'package:pweb/pages/payout_page/send/widgets/send_button.dart';
|
||||
import 'package:pweb/widgets/payment/source_wallet_selector.dart';
|
||||
import 'package:pweb/widgets/cooldown_hint.dart';
|
||||
import 'package:pweb/models/control_state.dart';
|
||||
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:pweb/models/state/control_state.dart';
|
||||
|
||||
|
||||
class SourceQuotePanel extends StatelessWidget {
|
||||
@@ -31,7 +32,12 @@ class SourceQuotePanel extends StatelessWidget {
|
||||
final theme = Theme.of(context);
|
||||
final verificationController =
|
||||
context.watch<PayoutVerificationController>();
|
||||
final isCooldownActive = verificationController.isCooldownActive;
|
||||
final quotationProvider = context.watch<MultiQuotationProvider>();
|
||||
final verificationContextKey = quotationProvider.quotation?.quoteRef ??
|
||||
quotationProvider.quotation?.idempotencyKey;
|
||||
final isCooldownActive = verificationController.isCooldownActiveFor(
|
||||
verificationContextKey,
|
||||
);
|
||||
final canSend = controller.canSend && !isCooldownActive;
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
@@ -72,7 +78,9 @@ class SourceQuotePanel extends StatelessWidget {
|
||||
if (isCooldownActive) ...[
|
||||
const SizedBox(height: 8),
|
||||
CooldownHint(
|
||||
seconds: verificationController.cooldownRemainingSeconds,
|
||||
seconds: verificationController.cooldownRemainingSecondsFor(
|
||||
verificationContextKey,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pweb/controllers/multiple_payouts.dart';
|
||||
import 'package:pweb/controllers/payouts/multiple_payouts.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:dotted_border/dotted_border.dart';
|
||||
|
||||
import 'package:pweb/controllers/multiple_payouts.dart';
|
||||
import 'package:pweb/controllers/payouts/multiple_payouts.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pweb/controllers/multiple_payouts.dart';
|
||||
import 'package:pweb/controllers/payouts/multiple_payouts.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pweb/controllers/multiple_payouts.dart';
|
||||
import 'package:pweb/controllers/payouts/multiple_payouts.dart';
|
||||
|
||||
import 'package:pweb/pages/dashboard/payouts/multiple/panels/upload_panel/drop_zone.dart';
|
||||
import 'package:pweb/pages/dashboard/payouts/multiple/panels/upload_panel/actions.dart';
|
||||
|
||||
@@ -6,7 +6,7 @@ import 'package:pshared/models/payment/operation.dart';
|
||||
import 'package:pshared/provider/payment/payments.dart';
|
||||
|
||||
import 'package:pweb/pages/dashboard/payouts/multiple/sections/history/header.dart';
|
||||
import 'package:pweb/controllers/recent_payments.dart';
|
||||
import 'package:pweb/controllers/payments/recent_payments.dart';
|
||||
import 'package:pweb/pages/report/cards/column.dart';
|
||||
import 'package:pweb/utils/report/payment_mapper.dart';
|
||||
import 'package:pweb/app/router/payout_routes.dart';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'package:pweb/models/multiple_payouts/csv_row.dart';
|
||||
import 'package:pweb/models/payment/multiple_payouts/csv_row.dart';
|
||||
|
||||
|
||||
const String sampleFileName = 'sample.csv';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pweb/models/multiple_payouts/csv_row.dart';
|
||||
import 'package:pweb/models/payment/multiple_payouts/csv_row.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pshared/controllers/balance_mask/wallets.dart';
|
||||
|
||||
import 'package:pweb/controllers/multiple_payouts.dart';
|
||||
import 'package:pweb/controllers/payouts/multiple_payouts.dart';
|
||||
import 'package:pweb/pages/dashboard/payouts/multiple/panels/source_quote/widget.dart';
|
||||
import 'package:pweb/pages/dashboard/payouts/multiple/panels/upload_panel/widget.dart';
|
||||
import 'package:pweb/pages/dashboard/payouts/multiple/sections/upload_csv/panel_card.dart';
|
||||
|
||||
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:pweb/controllers/multiple_payouts.dart';
|
||||
import 'package:pweb/controllers/payouts/multiple_payouts.dart';
|
||||
import 'package:pweb/pages/dashboard/payouts/multiple/sections/upload_csv/header.dart';
|
||||
import 'package:pweb/pages/dashboard/payouts/multiple/sections/upload_csv/layout.dart';
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pshared/models/payment/quote/status_type.dart';
|
||||
|
||||
import 'package:pweb/controllers/multiple_payouts.dart';
|
||||
import 'package:pweb/controllers/payouts/multiple_payouts.dart';
|
||||
import 'package:pweb/pages/dashboard/payouts/quote_status/widgets/card.dart';
|
||||
import 'package:pweb/utils/quote_duration_format.dart';
|
||||
|
||||
|
||||
@@ -48,34 +48,14 @@ class QuoteAutoRefreshSection extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: _autoRefreshSpacing),
|
||||
ToggleButtons(
|
||||
isSelected: [
|
||||
autoRefreshMode == AutoRefreshMode.off,
|
||||
autoRefreshMode == AutoRefreshMode.on,
|
||||
],
|
||||
onPressed: canRefresh
|
||||
? (index) {
|
||||
final nextMode =
|
||||
index == 1 ? AutoRefreshMode.on : AutoRefreshMode.off;
|
||||
if (nextMode == autoRefreshMode) return;
|
||||
onModeChanged(nextMode);
|
||||
}
|
||||
Switch.adaptive(
|
||||
activeTrackColor: theme.colorScheme.primary,
|
||||
value: autoRefreshMode == AutoRefreshMode.on,
|
||||
onChanged: canRefresh
|
||||
? (value) => onModeChanged(
|
||||
value ? AutoRefreshMode.on : AutoRefreshMode.off,
|
||||
)
|
||||
: null,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
constraints: const BoxConstraints(minHeight: 32, minWidth: 56),
|
||||
selectedColor: theme.colorScheme.onPrimary,
|
||||
fillColor: theme.colorScheme.primary,
|
||||
color: theme.colorScheme.onSurface,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Text(loc.toggleOff),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Text(loc.toggleOn),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -26,6 +26,7 @@ class RecipientAvatar extends StatelessWidget {
|
||||
final textColor = Theme.of(context).colorScheme.onPrimary;
|
||||
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: avatarRadius,
|
||||
|
||||
@@ -7,11 +7,13 @@ import 'package:pweb/pages/dashboard/payouts/single/address_book/avatar.dart';
|
||||
class ShortListAddressBookPayout extends StatelessWidget {
|
||||
final List<Recipient> recipients;
|
||||
final ValueChanged<Recipient> onSelected;
|
||||
final Widget? trailing;
|
||||
|
||||
const ShortListAddressBookPayout({
|
||||
super.key,
|
||||
required this.recipients,
|
||||
required this.onSelected,
|
||||
this.trailing,
|
||||
});
|
||||
|
||||
static const double _avatarRadius = 20;
|
||||
@@ -21,10 +23,13 @@ class ShortListAddressBookPayout extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final trailingWidget = trailing;
|
||||
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: recipients.map((recipient) {
|
||||
children:
|
||||
recipients.map((recipient) {
|
||||
return Padding(
|
||||
padding: _padding,
|
||||
child: InkWell(
|
||||
@@ -44,8 +49,13 @@ class ShortListAddressBookPayout extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
}).toList()
|
||||
..addAll(
|
||||
trailingWidget == null
|
||||
? const []
|
||||
: [Padding(padding: _padding, child: trailingWidget)],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:pshared/models/asset.dart';
|
||||
import 'package:pshared/models/currency.dart';
|
||||
import 'package:pshared/provider/payment/amount.dart';
|
||||
|
||||
import 'package:pweb/pages/dashboard/payouts/summary/row.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
|
||||
class PaymentSentAmountRow extends StatelessWidget {
|
||||
final Currency currency;
|
||||
const PaymentSentAmountRow({super.key, required this.currency});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Consumer<PaymentAmountProvider>(
|
||||
builder: (context, provider, _) => PaymentSummaryRow(
|
||||
labelFactory: AppLocalizations.of(context)!.sentAmount,
|
||||
asset: Asset(currency: currency, amount: provider.amount),
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:pshared/controllers/balance_mask/wallets.dart';
|
||||
import 'package:pshared/utils/currency.dart';
|
||||
|
||||
import 'package:pweb/models/summary_values.dart';
|
||||
import 'package:pweb/models/dashboard/summary_values.dart';
|
||||
import 'package:pweb/pages/dashboard/payouts/summary/fee.dart';
|
||||
import 'package:pweb/pages/dashboard/payouts/summary/recipient_receives.dart';
|
||||
import 'package:pweb/pages/dashboard/payouts/summary/row.dart';
|
||||
import 'package:pweb/pages/dashboard/payouts/summary/sent_amount.dart';
|
||||
import 'package:pweb/pages/dashboard/payouts/summary/total.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
@@ -36,12 +30,6 @@ class PaymentSummary extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
PaymentSummaryRow(
|
||||
labelFactory: loc.sentAmount,
|
||||
asset: null,
|
||||
value: resolvedValues.sentAmount,
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
PaymentSummaryRow(
|
||||
labelFactory: loc.fee,
|
||||
asset: null,
|
||||
@@ -73,12 +61,6 @@ class PaymentSummary extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
PaymentSentAmountRow(
|
||||
currency: currencyStringToCode(
|
||||
context.read<WalletsController>().selectedWallet?.tokenSymbol ??
|
||||
'USDT',
|
||||
),
|
||||
),
|
||||
const PaymentFeeRow(),
|
||||
const PaymentRecipientReceivesRow(),
|
||||
SizedBox(height: spacing),
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:pshared/models/resources.dart';
|
||||
import 'package:pshared/provider/account.dart';
|
||||
import 'package:pshared/provider/invitations.dart';
|
||||
import 'package:pshared/provider/permissions.dart';
|
||||
|
||||
import 'package:pweb/pages/invitations/widgets/header.dart';
|
||||
import 'package:pweb/pages/invitations/widgets/form/form.dart';
|
||||
import 'package:pweb/pages/invitations/widgets/list/list.dart';
|
||||
import 'package:pweb/pages/loader.dart';
|
||||
import 'package:pweb/utils/error/snackbar.dart';
|
||||
import 'package:pweb/widgets/roles/create_role_dialog.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
|
||||
class InvitationsPage extends StatefulWidget {
|
||||
const InvitationsPage({super.key});
|
||||
|
||||
@override
|
||||
State<InvitationsPage> createState() => _InvitationsPageState();
|
||||
}
|
||||
|
||||
class _InvitationsPageState extends State<InvitationsPage> {
|
||||
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
|
||||
final TextEditingController _emailController = TextEditingController();
|
||||
final TextEditingController _firstNameController = TextEditingController();
|
||||
final TextEditingController _lastNameController = TextEditingController();
|
||||
final TextEditingController _messageController = TextEditingController();
|
||||
|
||||
String? _selectedRoleRef;
|
||||
int _expiryDays = 7;
|
||||
|
||||
Future<void> _createRole() async {
|
||||
final loc = AppLocalizations.of(context)!;
|
||||
final draft = await showCreateRoleDialog(context);
|
||||
if (draft == null) return;
|
||||
|
||||
final permissions = context.read<PermissionsProvider>();
|
||||
final createdRole = await executeActionWithNotification(
|
||||
context: context,
|
||||
action: () => permissions.createRoleDescription(
|
||||
name: draft.name,
|
||||
description: draft.description.isEmpty ? null : draft.description,
|
||||
),
|
||||
successMessage: loc.invitationRoleCreated,
|
||||
errorMessage: loc.invitationRoleCreateFailed,
|
||||
);
|
||||
|
||||
if (createdRole != null && mounted) {
|
||||
setState(() => _selectedRoleRef = createdRole.id);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_bootstrapRoleSelection();
|
||||
}
|
||||
|
||||
void _bootstrapRoleSelection() {
|
||||
final roles = context.read<PermissionsProvider>().roleDescriptions;
|
||||
if (roles.isEmpty) return;
|
||||
final firstRoleRef = roles.first.storable.id;
|
||||
final isSelectedAvailable = _selectedRoleRef != null
|
||||
&& roles.any((role) => role.storable.id == _selectedRoleRef);
|
||||
if (isSelectedAvailable) return;
|
||||
if (!mounted) return;
|
||||
setState(() => _selectedRoleRef = firstRoleRef);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailController.dispose();
|
||||
_firstNameController.dispose();
|
||||
_lastNameController.dispose();
|
||||
_messageController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _sendInvitation() async {
|
||||
final form = _formKey.currentState;
|
||||
if (form == null || !form.validate()) return;
|
||||
|
||||
final account = context.read<AccountProvider>().account;
|
||||
if (account == null) return;
|
||||
final permissions = context.read<PermissionsProvider>();
|
||||
final roleRef = _selectedRoleRef ?? permissions.roleDescriptions.firstOrNull?.storable.id;
|
||||
if (roleRef == null) return;
|
||||
|
||||
final invitations = context.read<InvitationsProvider>();
|
||||
final loc = AppLocalizations.of(context)!;
|
||||
|
||||
await executeActionWithNotification(
|
||||
context: context,
|
||||
action: () => invitations.sendInvitation(
|
||||
email: _emailController.text.trim(),
|
||||
name: _firstNameController.text.trim(),
|
||||
lastName: _lastNameController.text.trim(),
|
||||
comment: _messageController.text.trim(),
|
||||
roleRef: roleRef,
|
||||
inviterRef: account.id,
|
||||
expiresAt: DateTime.now().toUtc().add(Duration(days: _expiryDays)),
|
||||
),
|
||||
successMessage: loc.invitationCreatedSuccess,
|
||||
errorMessage: loc.errorCreatingInvitation,
|
||||
);
|
||||
|
||||
_emailController.clear();
|
||||
_firstNameController.clear();
|
||||
_lastNameController.clear();
|
||||
_messageController.clear();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final loc = AppLocalizations.of(context)!;
|
||||
final permissions = context.watch<PermissionsProvider>();
|
||||
final canCreateRoles = permissions.canCreate(ResourceType.roles);
|
||||
|
||||
if (!permissions.canRead(ResourceType.invitations)) {
|
||||
return PageViewLoader(
|
||||
child: Center(child: Text(loc.errorAccessDenied)),
|
||||
);
|
||||
}
|
||||
|
||||
return PageViewLoader(
|
||||
child: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
InvitationsHeader(loc: loc),
|
||||
const SizedBox(height: 16),
|
||||
InvitationsForm(
|
||||
formKey: _formKey,
|
||||
emailController: _emailController,
|
||||
firstNameController: _firstNameController,
|
||||
lastNameController: _lastNameController,
|
||||
messageController: _messageController,
|
||||
canCreateRoles: canCreateRoles,
|
||||
onCreateRole: _createRole,
|
||||
expiryDays: _expiryDays,
|
||||
onExpiryChanged: (value) => setState(() => _expiryDays = value),
|
||||
selectedRoleRef: _selectedRoleRef,
|
||||
onRoleChanged: (role) => setState(() => _selectedRoleRef = role),
|
||||
canCreate: permissions.canCreate(ResourceType.invitations),
|
||||
onSubmit: _sendInvitation,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const InvitationsList(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
75
frontend/pweb/lib/pages/invitations/page/page.dart
Normal file
75
frontend/pweb/lib/pages/invitations/page/page.dart
Normal file
@@ -0,0 +1,75 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:pweb/controllers/invitations/page.dart';
|
||||
import 'package:pweb/pages/invitations/page/providers.dart';
|
||||
import 'package:pweb/pages/invitations/page/view.dart';
|
||||
import 'package:pweb/utils/error/snackbar.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
|
||||
class InvitationsPage extends StatefulWidget {
|
||||
const InvitationsPage({super.key});
|
||||
|
||||
@override
|
||||
State<InvitationsPage> createState() => _InvitationsPageState();
|
||||
}
|
||||
|
||||
class _InvitationsPageState extends State<InvitationsPage> {
|
||||
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
|
||||
final TextEditingController _emailController = TextEditingController();
|
||||
final TextEditingController _firstNameController = TextEditingController();
|
||||
final TextEditingController _lastNameController = TextEditingController();
|
||||
final TextEditingController _messageController = TextEditingController();
|
||||
|
||||
Future<void> _sendInvitation(BuildContext context) async {
|
||||
final form = _formKey.currentState;
|
||||
if (form == null || !form.validate()) return;
|
||||
|
||||
final loc = AppLocalizations.of(context)!;
|
||||
|
||||
await executeActionWithNotification(
|
||||
context: context,
|
||||
action: () => context.read<InvitationsPageController>().sendInvitation(
|
||||
email: _emailController.text,
|
||||
name: _firstNameController.text,
|
||||
lastName: _lastNameController.text,
|
||||
comment: _messageController.text,
|
||||
),
|
||||
successMessage: loc.invitationCreatedSuccess,
|
||||
errorMessage: loc.errorCreatingInvitation,
|
||||
);
|
||||
|
||||
_emailController.clear();
|
||||
_firstNameController.clear();
|
||||
_lastNameController.clear();
|
||||
_messageController.clear();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailController.dispose();
|
||||
_firstNameController.dispose();
|
||||
_lastNameController.dispose();
|
||||
_messageController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InvitationsPageProviders(
|
||||
child: Builder(
|
||||
builder: (context) => InvitationsPageView(
|
||||
formKey: _formKey,
|
||||
emailController: _emailController,
|
||||
firstNameController: _firstNameController,
|
||||
lastNameController: _lastNameController,
|
||||
messageController: _messageController,
|
||||
onSubmit: () => _sendInvitation(context),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
38
frontend/pweb/lib/pages/invitations/page/providers.dart
Normal file
38
frontend/pweb/lib/pages/invitations/page/providers.dart
Normal file
@@ -0,0 +1,38 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:pshared/provider/account.dart';
|
||||
import 'package:pshared/provider/invitations.dart';
|
||||
import 'package:pshared/provider/permissions.dart';
|
||||
|
||||
import 'package:pweb/controllers/invitations/page.dart';
|
||||
|
||||
|
||||
class InvitationsPageProviders extends StatelessWidget {
|
||||
final Widget child;
|
||||
|
||||
const InvitationsPageProviders({
|
||||
super.key,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ChangeNotifierProxyProvider3<
|
||||
PermissionsProvider,
|
||||
InvitationsProvider,
|
||||
AccountProvider,
|
||||
InvitationsPageController
|
||||
>(
|
||||
create: (_) => InvitationsPageController(),
|
||||
update: (_, permissions, invitations, account, controller) => controller!
|
||||
..update(
|
||||
permissions: permissions,
|
||||
invitations: invitations,
|
||||
account: account,
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
79
frontend/pweb/lib/pages/invitations/page/view.dart
Normal file
79
frontend/pweb/lib/pages/invitations/page/view.dart
Normal file
@@ -0,0 +1,79 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:pshared/models/resources.dart';
|
||||
import 'package:pshared/provider/permissions.dart';
|
||||
|
||||
import 'package:pweb/controllers/invitations/page.dart';
|
||||
import 'package:pweb/pages/invitations/widgets/header.dart';
|
||||
import 'package:pweb/pages/invitations/widgets/form/form.dart';
|
||||
import 'package:pweb/pages/invitations/widgets/list/list.dart';
|
||||
import 'package:pweb/pages/loader.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
|
||||
class InvitationsPageView extends StatelessWidget {
|
||||
final GlobalKey<FormState> formKey;
|
||||
final TextEditingController emailController;
|
||||
final TextEditingController firstNameController;
|
||||
final TextEditingController lastNameController;
|
||||
final TextEditingController messageController;
|
||||
final VoidCallback onSubmit;
|
||||
|
||||
const InvitationsPageView({
|
||||
super.key,
|
||||
required this.formKey,
|
||||
required this.emailController,
|
||||
required this.firstNameController,
|
||||
required this.lastNameController,
|
||||
required this.messageController,
|
||||
required this.onSubmit,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final loc = AppLocalizations.of(context)!;
|
||||
final permissions = context.watch<PermissionsProvider>();
|
||||
final canCreateRoles = permissions.canCreate(ResourceType.roles);
|
||||
final ui = context.watch<InvitationsPageController>();
|
||||
|
||||
if (!permissions.canRead(ResourceType.invitations)) {
|
||||
return PageViewLoader(
|
||||
child: Center(child: Text(loc.errorAccessDenied)),
|
||||
);
|
||||
}
|
||||
|
||||
return PageViewLoader(
|
||||
child: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
InvitationsHeader(loc: loc),
|
||||
const SizedBox(height: 16),
|
||||
InvitationsForm(
|
||||
formKey: formKey,
|
||||
emailController: emailController,
|
||||
firstNameController: firstNameController,
|
||||
lastNameController: lastNameController,
|
||||
messageController: messageController,
|
||||
canCreateRoles: canCreateRoles,
|
||||
expiryDays: ui.expiryDays,
|
||||
onExpiryChanged: ui.setExpiryDays,
|
||||
selectedRoleRef: ui.selectedRoleRef,
|
||||
onRoleChanged: ui.setSelectedRoleRef,
|
||||
canCreate: permissions.canCreate(ResourceType.invitations),
|
||||
onSubmit: onSubmit,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const InvitationsList(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pweb/models/invitation_filter.dart';
|
||||
import 'package:pweb/models/invitation/invitation_filter.dart';
|
||||
import 'package:pweb/pages/invitations/widgets/filter/invitation_filter.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:pshared/models/invitation/invitation.dart';
|
||||
import 'package:pshared/models/invitation/status.dart';
|
||||
|
||||
import 'package:pweb/models/invitation_filter.dart';
|
||||
import 'package:pweb/models/invitation/invitation_filter.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ class InvitationsForm extends StatelessWidget {
|
||||
final TextEditingController lastNameController;
|
||||
final TextEditingController messageController;
|
||||
final bool canCreateRoles;
|
||||
final VoidCallback onCreateRole;
|
||||
final int expiryDays;
|
||||
final ValueChanged<int> onExpiryChanged;
|
||||
final String? selectedRoleRef;
|
||||
@@ -26,7 +25,6 @@ class InvitationsForm extends StatelessWidget {
|
||||
required this.lastNameController,
|
||||
required this.messageController,
|
||||
required this.canCreateRoles,
|
||||
required this.onCreateRole,
|
||||
required this.expiryDays,
|
||||
required this.onExpiryChanged,
|
||||
required this.selectedRoleRef,
|
||||
@@ -43,7 +41,6 @@ class InvitationsForm extends StatelessWidget {
|
||||
lastNameController: lastNameController,
|
||||
messageController: messageController,
|
||||
canCreateRoles: canCreateRoles,
|
||||
onCreateRole: onCreateRole,
|
||||
expiryDays: expiryDays,
|
||||
onExpiryChanged: onExpiryChanged,
|
||||
selectedRoleRef: selectedRoleRef,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user