diff --git a/frontend/pshared/lib/controllers/balance_mask/wallets.dart b/frontend/pshared/lib/controllers/balance_mask/wallets.dart index 80994dac..6cdefbb0 100644 --- a/frontend/pshared/lib/controllers/balance_mask/wallets.dart +++ b/frontend/pshared/lib/controllers/balance_mask/wallets.dart @@ -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 _maskedBalanceWalletRefs = {}; Set _knownWalletRefs = {}; + List _walletsList = []; + Map _walletsById = {}; + 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 get wallets => _wallets.wallets; + List 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 _uniqueWalletsById(List wallets) { + final result = {}; + for (final wallet in wallets) { + result.putIfAbsent(wallet.id, () => wallet); + } + return result; + } } diff --git a/frontend/pshared/lib/data/mapper/payment/method.dart b/frontend/pshared/lib/data/mapper/payment/method.dart index 640eaf06..740b114b 100644 --- a/frontend/pshared/lib/data/mapper/payment/method.dart +++ b/frontend/pshared/lib/data/mapper/payment/method.dart @@ -33,6 +33,20 @@ import 'package:pshared/models/permissions/bound.dart'; import 'package:pshared/models/storable.dart'; +extension PaymentMethodDataJsonMapper on PaymentMethodData { + Map 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 _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 _dataToJson(PaymentMethodData data) => data.toJsonMap(); } extension PaymentMethodDTOMapper on PaymentMethodDTO { diff --git a/frontend/pshared/lib/provider/organizations.dart b/frontend/pshared/lib/provider/organizations.dart index 1c8bd816..4792ed00 100644 --- a/frontend/pshared/lib/provider/organizations.dart +++ b/frontend/pshared/lib/provider/organizations.dart @@ -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 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> 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 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 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 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; + } + } } diff --git a/frontend/pweb/lib/providers/quotation/auto_refresh.dart b/frontend/pshared/lib/provider/payment/auto_refresh.dart similarity index 68% rename from frontend/pweb/lib/providers/quotation/auto_refresh.dart rename to frontend/pshared/lib/provider/payment/auto_refresh.dart index 407f26c4..e6d4809e 100644 --- a/frontend/pweb/lib/providers/quotation/auto_refresh.dart +++ b/frontend/pshared/lib/provider/payment/auto_refresh.dart @@ -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 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(); } diff --git a/frontend/pshared/lib/provider/payment/multiple/quotation.dart b/frontend/pshared/lib/provider/payment/multiple/quotation.dart index e5ecd046..45e3fb7a 100644 --- a/frontend/pshared/lib/provider/payment/multiple/quotation.dart +++ b/frontend/pshared/lib/provider/payment/multiple/quotation.dart @@ -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 _quotation = Resource(data: null); + final AutoRefreshScheduler _autoRefresh = AutoRefreshScheduler(); List? _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 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(); + } } diff --git a/frontend/pshared/lib/provider/payment/payments.dart b/frontend/pshared/lib/provider/payment/payments.dart index 5ba2a3be..a5a3ba60 100644 --- a/frontend/pshared/lib/provider/payment/payments.dart +++ b/frontend/pshared/lib/provider/payment/payments.dart @@ -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? _states; int _opSeq = 0; + Timer? _pendingRefreshTimer; + bool _isPendingRefreshInFlight = false; Resource> get resource => _resource; List 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 incoming) { + if (incoming.isEmpty) return; + final existing = List.from(_resource.data ?? const []); + final combined = [ + ...incoming, + ...existing, + ]; + final seen = {}; + final merged = []; + + 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 _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> 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? _normalizeStates(List? 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 _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(); + } + } diff --git a/frontend/pshared/lib/provider/payment/quotation/quotation.dart b/frontend/pshared/lib/provider/payment/quotation/quotation.dart index f5f45c7d..e205ad6b 100644 --- a/frontend/pshared/lib/provider/payment/quotation/quotation.dart +++ b/frontend/pshared/lib/provider/payment/quotation/quotation.dart @@ -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 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(); + } } diff --git a/frontend/pshared/lib/provider/payment/updates.dart b/frontend/pshared/lib/provider/payment/updates.dart new file mode 100644 index 00000000..d98cf46b --- /dev/null +++ b/frontend/pshared/lib/provider/payment/updates.dart @@ -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 _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 []) + ..._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 []; + if (payments.isEmpty) return; + final incoming = []; + 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 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 _keyFor(Payment payment) sync* { + final key = _key(payment); + if (key != null) yield key; + } + + @override + void dispose() { + _paymentProvider?.removeListener(_onSinglePaymentChanged); + _multiPaymentProvider?.removeListener(_onMultiPaymentChanged); + super.dispose(); + } +} diff --git a/frontend/pshared/lib/service/organization.dart b/frontend/pshared/lib/service/organization.dart index d34b42fc..1ab8cabd 100644 --- a/frontend/pshared/lib/service/organization.dart +++ b/frontend/pshared/lib/service/organization.dart @@ -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 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 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 uploadLogo(String organizationRef, XFile logoFile) async { _logger.fine('Uploading logo'); return FilesService.uploadImage(_objectType, organizationRef, logoFile); diff --git a/frontend/pweb/lib/app/router/payout_routes.dart b/frontend/pweb/lib/app/router/payout_routes.dart index bb27809f..f1e69a33 100644 --- a/frontend/pweb/lib/app/router/payout_routes.dart +++ b/frontend/pweb/lib/app/router/payout_routes.dart @@ -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 buildQueryParameters({ PaymentType? paymentType, - PayoutDestination? returnTo, }) { final params = { 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); } diff --git a/frontend/pweb/lib/app/router/payout_shell.dart b/frontend/pweb/lib/app/router/payout_shell.dart index e52f6828..97f882fe 100644 --- a/frontend/pweb/lib/app/router/payout_shell.dart +++ b/frontend/pweb/lib/app/router/payout_shell.dart @@ -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().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(); 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().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().setCurrentObject(recipient.id); + context.pushNamed(PayoutRoutes.editRecipient); +} + void _openWalletEdit( BuildContext context, - Wallet wallet, { - required PayoutDestination returnTo, -}) { + Wallet wallet, +) { context.read().selectWallet(wallet); - context.pushToEditWallet(returnTo: returnTo); + context.pushToEditWallet(); } void _openWalletTopUp( BuildContext context, - Wallet wallet, { - required PayoutDestination returnTo, -}) { + Wallet wallet, +) { context.read().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); } } diff --git a/frontend/pweb/lib/controllers/auth/account_loader.dart b/frontend/pweb/lib/controllers/auth/account_loader.dart new file mode 100644 index 00000000..a7dfcfea --- /dev/null +++ b/frontend/pweb/lib/controllers/auth/account_loader.dart @@ -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(); + } +} diff --git a/frontend/pweb/lib/providers/account_name.dart b/frontend/pweb/lib/controllers/auth/account_name.dart similarity index 65% rename from frontend/pweb/lib/providers/account_name.dart rename to frontend/pweb/lib/controllers/auth/account_name.dart index 22b7d10f..959952bc 100644 --- a/frontend/pweb/lib/providers/account_name.dart +++ b/frontend/pweb/lib/controllers/auth/account_name.dart @@ -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 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; diff --git a/frontend/pweb/lib/controllers/email.dart b/frontend/pweb/lib/controllers/auth/email.dart similarity index 100% rename from frontend/pweb/lib/controllers/email.dart rename to frontend/pweb/lib/controllers/auth/email.dart diff --git a/frontend/pweb/lib/providers/password_form.dart b/frontend/pweb/lib/controllers/auth/password_form.dart similarity index 92% rename from frontend/pweb/lib/providers/password_form.dart rename to frontend/pweb/lib/controllers/auth/password_form.dart index 23770c7a..d456c741 100644 --- a/frontend/pweb/lib/providers/password_form.dart +++ b/frontend/pweb/lib/controllers/auth/password_form.dart @@ -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(); final oldPasswordController = TextEditingController(); final newPasswordController = TextEditingController(); diff --git a/frontend/pweb/lib/controllers/common/cooldown.dart b/frontend/pweb/lib/controllers/common/cooldown.dart new file mode 100644 index 00000000..61ea094c --- /dev/null +++ b/frontend/pweb/lib/controllers/common/cooldown.dart @@ -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; + } +} diff --git a/frontend/pweb/lib/controllers/invitations/page.dart b/frontend/pweb/lib/controllers/invitations/page.dart new file mode 100644 index 00000000..b810f286 --- /dev/null +++ b/frontend/pweb/lib/controllers/invitations/page.dart @@ -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 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 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 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(); + } +} diff --git a/frontend/pweb/lib/controllers/report_operations.dart b/frontend/pweb/lib/controllers/operations/report_operations.dart similarity index 89% rename from frontend/pweb/lib/controllers/report_operations.dart rename to frontend/pweb/lib/controllers/operations/report_operations.dart index 7b723549..1e3f5841 100644 --- a/frontend/pweb/lib/controllers/report_operations.dart +++ b/frontend/pweb/lib/controllers/operations/report_operations.dart @@ -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(); - } - } diff --git a/frontend/pweb/lib/controllers/operations/wallet_transactions.dart b/frontend/pweb/lib/controllers/operations/wallet_transactions.dart new file mode 100644 index 00000000..2e7c5542 --- /dev/null +++ b/frontend/pweb/lib/controllers/operations/wallet_transactions.dart @@ -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 _filteredTransactions = []; + DateTimeRange? _dateRange; + final Set _selectedStatuses = {}; + final Set _selectedTypes = {}; + WalletTransactionsProvider? _provider; + + List get transactions => + _provider?.transactions ?? const []; + List get filteredTransactions => _filteredTransactions; + DateTimeRange? get dateRange => _dateRange; + Set get selectedStatuses => _selectedStatuses; + Set 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 []; + _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(); + } +} diff --git a/frontend/pweb/lib/controllers/organization/address_book_recipient_form.dart b/frontend/pweb/lib/controllers/organization/address_book_recipient_form.dart new file mode 100644 index 00000000..3f0374aa --- /dev/null +++ b/frontend/pweb/lib/controllers/organization/address_book_recipient_form.dart @@ -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 _listEquality = + ListEquality(); + + final List _supportedTypes; + final Map> _methods; + + Recipient? _recipient; + RecipientMethodsCacheProvider? _methodsCache; + + String _initialName = ''; + String _initialEmail = ''; + List _initialMethods = const []; + SeedState _snapshotState = SeedState.idle; + SeedState _seedState = SeedState.idle; + + AddressBookRecipientFormController({ + required List supportedTypes, + }) : _supportedTypes = List.unmodifiable(supportedTypes), + _methods = { + for (final type in supportedTypes) type: [], + }; + + List get supportedTypes => _supportedTypes; + Map> get methods => { + for (final entry in _methods.entries) + entry.key: List.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 saveForm({ + required BuildContext context, + required GlobalKey formKey, + required AddressBookRecipientFormProvider formState, + required String name, + required String email, + ValueChanged? 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 handleBack({ + required BuildContext context, + required GlobalKey formKey, + required AddressBookRecipientFormProvider formState, + required String name, + required String email, + ValueChanged? 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 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 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 _snapshotFrom() { + final snapshots = []; + for (final type in _supportedTypes) { + final entries = _methods[type] ?? const []; + for (final entry in entries) { + snapshots.add(RecipientMethodSnapshot.fromDraft(entry)); + } + } + return snapshots; + } + + Map _methodNames(BuildContext context) => { + for (final type in _supportedTypes) type: getPaymentTypeLabel(context, type), + }; + + @override + void dispose() { + _methodsCache?.removeListener(_handleCacheChange); + super.dispose(); + } +} diff --git a/frontend/pweb/lib/controllers/organization/address_book_recipient_form_selection.dart b/frontend/pweb/lib/controllers/organization/address_book_recipient_form_selection.dart new file mode 100644 index 00000000..bbda80e8 --- /dev/null +++ b/frontend/pweb/lib/controllers/organization/address_book_recipient_form_selection.dart @@ -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(); + } +} diff --git a/frontend/pweb/lib/controllers/payments/amount_field.dart b/frontend/pweb/lib/controllers/payments/amount_field.dart new file mode 100644 index 00000000..da73f007 --- /dev/null +++ b/frontend/pweb/lib/controllers/payments/amount_field.dart @@ -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(); + } +} diff --git a/frontend/pweb/lib/controllers/payment_details.dart b/frontend/pweb/lib/controllers/payments/details.dart similarity index 82% rename from frontend/pweb/lib/controllers/payment_details.dart rename to frontend/pweb/lib/controllers/payments/details.dart index 018d08bd..33f8b2d8 100644 --- a/frontend/pweb/lib/controllers/payment_details.dart +++ b/frontend/pweb/lib/controllers/payments/details.dart @@ -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(); - } } diff --git a/frontend/pweb/lib/controllers/payment_page.dart b/frontend/pweb/lib/controllers/payments/page.dart similarity index 89% rename from frontend/pweb/lib/controllers/payment_page.dart rename to frontend/pweb/lib/controllers/payments/page.dart index 5ea7eaf3..1d39d249 100644 --- a/frontend/pweb/lib/controllers/payment_page.dart +++ b/frontend/pweb/lib/controllers/payments/page.dart @@ -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; diff --git a/frontend/pweb/lib/controllers/payments/page_ui.dart b/frontend/pweb/lib/controllers/payments/page_ui.dart new file mode 100644 index 00000000..bdf2b108 --- /dev/null +++ b/frontend/pweb/lib/controllers/payments/page_ui.dart @@ -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(); + } +} diff --git a/frontend/pweb/lib/pages/payout_page/methods/controller.dart b/frontend/pweb/lib/controllers/payments/payment_config.dart similarity index 100% rename from frontend/pweb/lib/pages/payout_page/methods/controller.dart rename to frontend/pweb/lib/controllers/payments/payment_config.dart diff --git a/frontend/pweb/lib/controllers/recent_payments.dart b/frontend/pweb/lib/controllers/payments/recent_payments.dart similarity index 72% rename from frontend/pweb/lib/controllers/recent_payments.dart rename to frontend/pweb/lib/controllers/payments/recent_payments.dart index 4435e04c..72497a65 100644 --- a/frontend/pweb/lib/controllers/recent_payments.dart +++ b/frontend/pweb/lib/controllers/payments/recent_payments.dart @@ -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(); - } - } diff --git a/frontend/pweb/lib/controllers/multi_quotation.dart b/frontend/pweb/lib/controllers/payouts/multi_quotation.dart similarity index 53% rename from frontend/pweb/lib/controllers/multi_quotation.dart rename to frontend/pweb/lib/controllers/payouts/multi_quotation.dart index de1911d8..59cd6944 100644 --- a/frontend/pweb/lib/controllers/multi_quotation.dart +++ b/frontend/pweb/lib/controllers/payouts/multi_quotation.dart @@ -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 _refreshQuotation() async { - await _quotation?.refreshQuotation(); - } - @override void dispose() { _quotation?.removeListener(_handleQuotationChanged); - _autoRefreshController.dispose(); super.dispose(); } } diff --git a/frontend/pweb/lib/controllers/multiple_payouts.dart b/frontend/pweb/lib/controllers/payouts/multiple_payouts.dart similarity index 97% rename from frontend/pweb/lib/controllers/multiple_payouts.dart rename to frontend/pweb/lib/controllers/payouts/multiple_payouts.dart index b1b97b6d..c857af08 100644 --- a/frontend/pweb/lib/controllers/multiple_payouts.dart +++ b/frontend/pweb/lib/controllers/payouts/multiple_payouts.dart @@ -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'; diff --git a/frontend/pweb/lib/controllers/payout_verification.dart b/frontend/pweb/lib/controllers/payouts/payout_verification.dart similarity index 62% rename from frontend/pweb/lib/controllers/payout_verification.dart rename to frontend/pweb/lib/controllers/payouts/payout_verification.dart index 5f92449f..5266d83f 100644 --- a/frontend/pweb/lib/controllers/payout_verification.dart +++ b/frontend/pweb/lib/controllers/payouts/payout_verification.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 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(); } } diff --git a/frontend/pweb/lib/controllers/payout_volumes.dart b/frontend/pweb/lib/controllers/payouts/payout_volumes.dart similarity index 100% rename from frontend/pweb/lib/controllers/payout_volumes.dart rename to frontend/pweb/lib/controllers/payouts/payout_volumes.dart diff --git a/frontend/pweb/lib/providers/quotation/quotation.dart b/frontend/pweb/lib/controllers/payouts/quotation.dart similarity index 70% rename from frontend/pweb/lib/providers/quotation/quotation.dart rename to frontend/pweb/lib/controllers/payouts/quotation.dart index 658d9ebf..b8ebe9d6 100644 --- a/frontend/pweb/lib/providers/quotation/quotation.dart +++ b/frontend/pweb/lib/controllers/payouts/quotation.dart @@ -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 _refreshQuotation() async { - await _quotation?.refreshQuotation(); - } - @override void dispose() { _quotation?.removeListener(_handleQuotationChanged); - _autoRefreshController.dispose(); _stopTicker(); super.dispose(); } -} \ No newline at end of file +} diff --git a/frontend/pweb/lib/controllers/signup/confirmation_card.dart b/frontend/pweb/lib/controllers/signup/confirmation_card.dart index 1b74648b..7167ad87 100644 --- a/frontend/pweb/lib/controllers/signup/confirmation_card.dart +++ b/frontend/pweb/lib/controllers/signup/confirmation_card.dart @@ -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) { diff --git a/frontend/pweb/lib/l10n/en.arb b/frontend/pweb/lib/l10n/en.arb index c4bbd9e1..5fe8e274 100644 --- a/frontend/pweb/lib/l10n/en.arb +++ b/frontend/pweb/lib/l10n/en.arb @@ -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", diff --git a/frontend/pweb/lib/l10n/ru.arb b/frontend/pweb/lib/l10n/ru.arb index abb6063f..6e8ce47f 100644 --- a/frontend/pweb/lib/l10n/ru.arb +++ b/frontend/pweb/lib/l10n/ru.arb @@ -705,6 +705,9 @@ "accountVerifiedDescription": "Ваш аккаунт успешно подтвержден. Теперь вы можете войти, чтобы получить доступ к своему аккаунту", "retryVerification": "Повторить подтверждение", "save": "Сохранить", + "discard": "Не сохранять", + "unsavedChangesTitle": "Сохранить изменения?", + "unsavedChangesMessage": "У вас есть несохранённые изменения.", "editWallet": "Редактировать кошелек", "userNamePlaceholder": "Имя пользователя", "noWalletSelected": "Кошелек не выбран", diff --git a/frontend/pweb/lib/main.dart b/frontend/pweb/lib/main.dart index b1ee2638..527e2c60 100644 --- a/frontend/pweb/lib/main.dart +++ b/frontend/pweb/lib/main.dart @@ -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( + create: (_) => PwebLocaleProvider(null), + ), ChangeNotifierProxyProvider( create: (_) => PwebAccountProvider(), update: (context, localeProvider, provider) => @@ -68,7 +75,13 @@ void main() async { update: (context, accountProvider, provider) => provider!..update(accountProvider), ), - ChangeNotifierProvider(create: (_) => OrganizationsProvider()), + ChangeNotifierProxyProvider( + //TODO controll scope of the provider + create: (_) => OrganizationsProvider(), + lazy: false, + update: (_, accountProvider, organizations) => + organizations!..updateAccount(accountProvider), + ), ChangeNotifierProxyProvider( 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(), diff --git a/frontend/pweb/lib/models/account/account_loader.dart b/frontend/pweb/lib/models/account/account_loader.dart new file mode 100644 index 00000000..ca29abb3 --- /dev/null +++ b/frontend/pweb/lib/models/account/account_loader.dart @@ -0,0 +1,4 @@ +enum AccountLoaderAction { + goToLogin, + showErrorAndGoToLogin, +} \ No newline at end of file diff --git a/frontend/pweb/lib/models/password_field_type.dart b/frontend/pweb/lib/models/auth/password_field_type.dart similarity index 100% rename from frontend/pweb/lib/models/password_field_type.dart rename to frontend/pweb/lib/models/auth/password_field_type.dart diff --git a/frontend/pweb/lib/models/resend/action_result.dart b/frontend/pweb/lib/models/auth/resend/action_result.dart similarity index 100% rename from frontend/pweb/lib/models/resend/action_result.dart rename to frontend/pweb/lib/models/auth/resend/action_result.dart diff --git a/frontend/pweb/lib/models/resend/avaliability.dart b/frontend/pweb/lib/models/auth/resend/avaliability.dart similarity index 100% rename from frontend/pweb/lib/models/resend/avaliability.dart rename to frontend/pweb/lib/models/auth/resend/avaliability.dart diff --git a/frontend/pweb/lib/models/dashboard_payment_mode.dart b/frontend/pweb/lib/models/dashboard/dashboard_payment_mode.dart similarity index 100% rename from frontend/pweb/lib/models/dashboard_payment_mode.dart rename to frontend/pweb/lib/models/dashboard/dashboard_payment_mode.dart diff --git a/frontend/pweb/lib/pages/dashboard/payouts/quote_status/quote_status.dart b/frontend/pweb/lib/models/dashboard/quote_status_data.dart similarity index 67% rename from frontend/pweb/lib/pages/dashboard/payouts/quote_status/quote_status.dart rename to frontend/pweb/lib/models/dashboard/quote_status_data.dart index 328952bf..3d71e203 100644 --- a/frontend/pweb/lib/pages/dashboard/payouts/quote_status/quote_status.dart +++ b/frontend/pweb/lib/models/dashboard/quote_status_data.dart @@ -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(); + 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, ); } -} +} \ No newline at end of file diff --git a/frontend/pweb/lib/models/summary_values.dart b/frontend/pweb/lib/models/dashboard/summary_values.dart similarity index 80% rename from frontend/pweb/lib/models/summary_values.dart rename to frontend/pweb/lib/models/dashboard/summary_values.dart index b82723b9..4d508da7 100644 --- a/frontend/pweb/lib/models/summary_values.dart +++ b/frontend/pweb/lib/models/dashboard/summary_values.dart @@ -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, diff --git a/frontend/pweb/lib/models/invitation_filter.dart b/frontend/pweb/lib/models/invitation/invitation_filter.dart similarity index 100% rename from frontend/pweb/lib/models/invitation_filter.dart rename to frontend/pweb/lib/models/invitation/invitation_filter.dart diff --git a/frontend/pweb/lib/models/payment_method_tile/availability.dart b/frontend/pweb/lib/models/payment/method_tile/availability.dart similarity index 100% rename from frontend/pweb/lib/models/payment_method_tile/availability.dart rename to frontend/pweb/lib/models/payment/method_tile/availability.dart diff --git a/frontend/pweb/lib/models/payment_method_tile/selection.dart b/frontend/pweb/lib/models/payment/method_tile/selection.dart similarity index 100% rename from frontend/pweb/lib/models/payment_method_tile/selection.dart rename to frontend/pweb/lib/models/payment/method_tile/selection.dart diff --git a/frontend/pweb/lib/models/multiple_payouts/csv_row.dart b/frontend/pweb/lib/models/payment/multiple_payouts/csv_row.dart similarity index 100% rename from frontend/pweb/lib/models/multiple_payouts/csv_row.dart rename to frontend/pweb/lib/models/payment/multiple_payouts/csv_row.dart diff --git a/frontend/pweb/lib/models/multiple_payouts/state.dart b/frontend/pweb/lib/models/payment/multiple_payouts/state.dart similarity index 100% rename from frontend/pweb/lib/models/multiple_payouts/state.dart rename to frontend/pweb/lib/models/payment/multiple_payouts/state.dart diff --git a/frontend/pweb/lib/models/payment_state.dart b/frontend/pweb/lib/models/payment/payment_state.dart similarity index 100% rename from frontend/pweb/lib/models/payment_state.dart rename to frontend/pweb/lib/models/payment/payment_state.dart diff --git a/frontend/pweb/lib/models/recipient/method_snapshot.dart b/frontend/pweb/lib/models/recipient/method_snapshot.dart new file mode 100644 index 00000000..dc59d8d6 --- /dev/null +++ b/frontend/pweb/lib/models/recipient/method_snapshot.dart @@ -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? data; + final Map? 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? _dataToSnapshot(PaymentMethodData? data) { + if (data == null) return null; + return data.toJsonMap(); +} + +Map? _metadataToSnapshot(PaymentMethodData? data) { + final metadata = data?.metadata; + if (metadata == null) return null; + return Map.from(metadata); +} diff --git a/frontend/pweb/lib/models/chart_point.dart b/frontend/pweb/lib/models/report/chart_point.dart similarity index 100% rename from frontend/pweb/lib/models/chart_point.dart rename to frontend/pweb/lib/models/report/chart_point.dart diff --git a/frontend/pweb/lib/models/role_draft.dart b/frontend/pweb/lib/models/role_draft.dart deleted file mode 100644 index 8b3c8434..00000000 --- a/frontend/pweb/lib/models/role_draft.dart +++ /dev/null @@ -1,9 +0,0 @@ -class RoleDraft { - final String name; - final String description; - - const RoleDraft({ - required this.name, - required this.description, - }); -} diff --git a/frontend/pweb/lib/models/control_state.dart b/frontend/pweb/lib/models/state/control_state.dart similarity index 100% rename from frontend/pweb/lib/models/control_state.dart rename to frontend/pweb/lib/models/state/control_state.dart diff --git a/frontend/pweb/lib/models/edit_state.dart b/frontend/pweb/lib/models/state/edit_state.dart similarity index 100% rename from frontend/pweb/lib/models/edit_state.dart rename to frontend/pweb/lib/models/state/edit_state.dart diff --git a/frontend/pweb/lib/models/flow_status.dart b/frontend/pweb/lib/models/state/flow_status.dart similarity index 100% rename from frontend/pweb/lib/models/flow_status.dart rename to frontend/pweb/lib/models/state/flow_status.dart diff --git a/frontend/pweb/lib/models/load_more_state.dart b/frontend/pweb/lib/models/state/load_more_state.dart similarity index 100% rename from frontend/pweb/lib/models/load_more_state.dart rename to frontend/pweb/lib/models/state/load_more_state.dart diff --git a/frontend/pweb/lib/models/seed_state.dart b/frontend/pweb/lib/models/state/seed_state.dart similarity index 100% rename from frontend/pweb/lib/models/seed_state.dart rename to frontend/pweb/lib/models/state/seed_state.dart diff --git a/frontend/pweb/lib/models/visibility.dart b/frontend/pweb/lib/models/state/visibility.dart similarity index 100% rename from frontend/pweb/lib/models/visibility.dart rename to frontend/pweb/lib/models/state/visibility.dart diff --git a/frontend/pweb/lib/models/wallet_transaction.dart b/frontend/pweb/lib/models/wallet/wallet_transaction.dart similarity index 100% rename from frontend/pweb/lib/models/wallet_transaction.dart rename to frontend/pweb/lib/models/wallet/wallet_transaction.dart diff --git a/frontend/pweb/lib/pages/address_book/form/body.dart b/frontend/pweb/lib/pages/address_book/form/body.dart index 8bb1da1f..d88d305e 100644 --- a/frontend/pweb/lib/pages/address_book/form/body.dart +++ b/frontend/pweb/lib/pages/address_book/form/body.dart @@ -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 formKey; final TextEditingController nameCtrl; final TextEditingController emailCtrl; @@ -27,94 +26,43 @@ class AddressBookRecipientFormBody extends StatefulWidget { required this.onBack, }); - @override - State createState() => _AddressBookRecipientFormBodyState(); -} - -class _AddressBookRecipientFormBodyState extends State { - 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(); - _reconcileSelection(formState); + final formState = Provider.of(context, listen: false); + final controller = Provider.of(context); + final selection = + Provider.of(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, ); } } diff --git a/frontend/pweb/lib/pages/address_book/form/page.dart b/frontend/pweb/lib/pages/address_book/form/page.dart index ac939266..2c11a24a 100644 --- a/frontend/pweb/lib/pages/address_book/form/page.dart +++ b/frontend/pweb/lib/pages/address_book/form/page.dart @@ -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 { final _formKey = GlobalKey(); late TextEditingController _nameCtrl; late TextEditingController _emailCtrl; + late final String _initialName; + late final String _initialEmail; static const List _supportedTypes = visiblePaymentTypes; @@ -36,61 +37,79 @@ class _AddressBookRecipientFormState extends State { void initState() { super.initState(); final r = widget.recipient; - _nameCtrl = TextEditingController(text: r?.name ?? ''); - _emailCtrl = TextEditingController(text: r?.email ?? ''); - } - - Map _methodNames(BuildContext context) => { - for (final type in _supportedTypes) type: getPaymentTypeLabel(context, type), - }; - - Future _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(); + final controller = context.read(); + 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, + ), + ); + }, ), ); } diff --git a/frontend/pweb/lib/pages/address_book/form/widgets/payment_methods/add_button.dart b/frontend/pweb/lib/pages/address_book/form/widgets/payment_methods/add_button.dart index 39d405c0..5fc5d951 100644 --- a/frontend/pweb/lib/pages/address_book/form/widgets/payment_methods/add_button.dart +++ b/frontend/pweb/lib/pages/address_book/form/widgets/payment_methods/add_button.dart @@ -12,7 +12,8 @@ import 'package:pweb/generated/i18n/app_localizations.dart'; class AddPaymentMethodButton extends StatelessWidget { final List types; final Set disabledTypes; - final ValueChanged onAdd; + final ValueChanged? 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( 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, ); } } diff --git a/frontend/pweb/lib/pages/address_book/form/widgets/payment_methods/panel.dart b/frontend/pweb/lib/pages/address_book/form/widgets/payment_methods/panel.dart index 217ad9f5..e0fe261b 100644 --- a/frontend/pweb/lib/pages/address_book/form/widgets/payment_methods/panel.dart +++ b/frontend/pweb/lib/pages/address_book/form/widgets/payment_methods/panel.dart @@ -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 entries; final ValueChanged 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); diff --git a/frontend/pweb/lib/pages/address_book/form/widgets/payment_methods/selector_row.dart b/frontend/pweb/lib/pages/address_book/form/widgets/payment_methods/selector_row.dart index 3b8c6fd2..aee0fbcc 100644 --- a/frontend/pweb/lib/pages/address_book/form/widgets/payment_methods/selector_row.dart +++ b/frontend/pweb/lib/pages/address_book/form/widgets/payment_methods/selector_row.dart @@ -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> methods; final void Function(PaymentType type, int index) onSelected; - final ValueChanged onAdd; + final ValueChanged? onAdd; + final VoidCallback? onAddPressed; final Set 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, ); } -} \ No newline at end of file +} diff --git a/frontend/pweb/lib/pages/address_book/form/widgets/payment_methods/tile.dart b/frontend/pweb/lib/pages/address_book/form/widgets/payment_methods/tile.dart index 46f336ec..aca7b746 100644 --- a/frontend/pweb/lib/pages/address_book/form/widgets/payment_methods/tile.dart +++ b/frontend/pweb/lib/pages/address_book/form/widgets/payment_methods/tile.dart @@ -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, - ), - ), - ), ], ), ), diff --git a/frontend/pweb/lib/pages/address_book/page/recipient/info_row.dart b/frontend/pweb/lib/pages/address_book/page/recipient/info_row.dart index 869088e6..483bbf75 100644 --- a/frontend/pweb/lib/pages/address_book/page/recipient/info_row.dart +++ b/frontend/pweb/lib/pages/address_book/page/recipient/info_row.dart @@ -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 { ], ); } -} \ No newline at end of file +} diff --git a/frontend/pweb/lib/pages/address_book/page/recipient/item.dart b/frontend/pweb/lib/pages/address_book/page/recipient/item.dart index 9df753c1..da1d23b0 100644 --- a/frontend/pweb/lib/pages/address_book/page/recipient/item.dart +++ b/frontend/pweb/lib/pages/address_book/page/recipient/item.dart @@ -59,6 +59,7 @@ class _RecipientAddressBookItemState extends State { child: Padding( padding: widget.padding, child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ diff --git a/frontend/pweb/lib/pages/address_book/page/recipient/payment_row.dart b/frontend/pweb/lib/pages/address_book/page/recipient/payment_row.dart index 1e3c4f0b..8b71f253 100644 --- a/frontend/pweb/lib/pages/address_book/page/recipient/payment_row.dart +++ b/frontend/pweb/lib/pages/address_book/page/recipient/payment_row.dart @@ -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(), + ), ); } } diff --git a/frontend/pweb/lib/pages/dashboard/dashboard.dart b/frontend/pweb/lib/pages/dashboard/dashboard.dart index 6da9a047..b5ed840c 100644 --- a/frontend/pweb/lib/pages/dashboard/dashboard.dart +++ b/frontend/pweb/lib/pages/dashboard/dashboard.dart @@ -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'; diff --git a/frontend/pweb/lib/pages/dashboard/payouts/amount.dart b/frontend/pweb/lib/pages/dashboard/payouts/amount.dart deleted file mode 100644 index c50053de..00000000 --- a/frontend/pweb/lib/pages/dashboard/payouts/amount.dart +++ /dev/null @@ -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 createState() => _PaymentAmountWidgetState(); -} - -class _PaymentAmountWidgetState extends State { - late final TextEditingController _controller; - bool _isSyncingText = false; - - @override - void initState() { - super.initState(); - final initialAmount = context.read().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().setAmount(parsed); - } - } - - @override - Widget build(BuildContext context) { - final amount = context.select((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, - ); - } -} diff --git a/frontend/pweb/lib/pages/dashboard/payouts/amount/feild.dart b/frontend/pweb/lib/pages/dashboard/payouts/amount/feild.dart new file mode 100644 index 00000000..3d5374ac --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/payouts/amount/feild.dart @@ -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( + (c) => c.selectedWallet?.currency, + ); + final symbol = currency == null ? null : currencyCodeToSymbol(currency); + + final ui = context.watch(); + + 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, + ); + } +} \ No newline at end of file diff --git a/frontend/pweb/lib/pages/dashboard/payouts/amount/widget.dart b/frontend/pweb/lib/pages/dashboard/payouts/amount/widget.dart new file mode 100644 index 00000000..b0eb5c3d --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/payouts/amount/widget.dart @@ -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( + create: (ctx) { + final initialAmount = ctx.read().amount; + return PaymentAmountFieldController(initialAmount: initialAmount); + }, + update: (ctx, amountProvider, controller) { + controller!.update(amountProvider); + return controller; + }, + child: const PaymentAmountField(), + ); + } +} \ No newline at end of file diff --git a/frontend/pweb/lib/pages/dashboard/payouts/fee_payer.dart b/frontend/pweb/lib/pages/dashboard/payouts/fee_payer.dart index 636bf3b2..bbc09eb9 100644 --- a/frontend/pweb/lib/pages/dashboard/payouts/fee_payer.dart +++ b/frontend/pweb/lib/pages/dashboard/payouts/fee_payer.dart @@ -15,15 +15,30 @@ class FeePayerSwitch extends StatelessWidget { @override Widget build(BuildContext context) => Consumer( - 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), + ], ), - ], - ), + ); + }, ); } diff --git a/frontend/pweb/lib/pages/dashboard/payouts/form.dart b/frontend/pweb/lib/pages/dashboard/payouts/form.dart index 36e1c379..16ebc690 100644 --- a/frontend/pweb/lib/pages/dashboard/payouts/form.dart +++ b/frontend/pweb/lib/pages/dashboard/payouts/form.dart @@ -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(); + 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, + ], + ), + ), + ], + ), + ], + ); + }, ); } } diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/actions.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/actions.dart index f3ae39cc..9030afb9 100644 --- a/frontend/pweb/lib/pages/dashboard/payouts/multiple/actions.dart +++ b/frontend/pweb/lib/pages/dashboard/payouts/multiple/actions.dart @@ -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 handleMultiplePayoutSend( MultiplePayoutsController controller, ) async { final verificationController = context.read(); + final quotationProvider = context.read(); + final verificationContextKey = quotationProvider.quotation?.quoteRef ?? + quotationProvider.quotation?.idempotencyKey; final verified = await runPayoutVerification( context: context, controller: verificationController, + contextKey: verificationContextKey, ); if (!verified) return; diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/source_quote/helpers.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/source_quote/helpers.dart index 6aafc898..1463a1ec 100644 --- a/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/source_quote/helpers.dart +++ b/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/source_quote/helpers.dart @@ -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); } diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/source_quote/summary.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/source_quote/summary.dart index 478f7c5a..5a678ad4 100644 --- a/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/source_quote/summary.dart +++ b/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/source_quote/summary.dart @@ -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), diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/source_quote/widget.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/source_quote/widget.dart index b6e492c1..fd58b055 100644 --- a/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/source_quote/widget.dart +++ b/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/source_quote/widget.dart @@ -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(); - final isCooldownActive = verificationController.isCooldownActive; + final quotationProvider = context.watch(); + 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, + ), ), ], ], diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/upload_panel/actions.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/upload_panel/actions.dart index 95742f7a..95a94fe3 100644 --- a/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/upload_panel/actions.dart +++ b/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/upload_panel/actions.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'; diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/upload_panel/drop_zone.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/upload_panel/drop_zone.dart index 24b4f7bf..572b7eba 100644 --- a/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/upload_panel/drop_zone.dart +++ b/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/upload_panel/drop_zone.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'; diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/upload_panel/status.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/upload_panel/status.dart index 59cdd05a..f40a952a 100644 --- a/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/upload_panel/status.dart +++ b/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/upload_panel/status.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'; diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/upload_panel/widget.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/upload_panel/widget.dart index 613a611e..c8dd2751 100644 --- a/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/upload_panel/widget.dart +++ b/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/upload_panel/widget.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'; diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/history/widget.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/history/widget.dart index f3662fcf..1c421798 100644 --- a/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/history/widget.dart +++ b/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/history/widget.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'; diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/sample/data.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/sample/data.dart index b8612f41..0a871e58 100644 --- a/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/sample/data.dart +++ b/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/sample/data.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'; diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/sample/table.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/sample/table.dart index 41dda609..c902448b 100644 --- a/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/sample/table.dart +++ b/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/sample/table.dart @@ -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'; diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/upload_csv/layout.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/upload_csv/layout.dart index 61598de7..be4b1fab 100644 --- a/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/upload_csv/layout.dart +++ b/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/upload_csv/layout.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'; diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/upload_csv/widget.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/upload_csv/widget.dart index fd1f51cf..3be2a39f 100644 --- a/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/upload_csv/widget.dart +++ b/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/upload_csv/widget.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'; diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/widgets/quote_status.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/widgets/quote_status.dart index 6aeb3315..64fd15b4 100644 --- a/frontend/pweb/lib/pages/dashboard/payouts/multiple/widgets/quote_status.dart +++ b/frontend/pweb/lib/pages/dashboard/payouts/multiple/widgets/quote_status.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'; diff --git a/frontend/pweb/lib/pages/dashboard/payouts/quote_status/widgets/refresh_section.dart b/frontend/pweb/lib/pages/dashboard/payouts/quote_status/widgets/refresh_section.dart index 2c25a715..7c294a62 100644 --- a/frontend/pweb/lib/pages/dashboard/payouts/quote_status/widgets/refresh_section.dart +++ b/frontend/pweb/lib/pages/dashboard/payouts/quote_status/widgets/refresh_section.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), - ), - ], ), ], ); diff --git a/frontend/pweb/lib/pages/dashboard/payouts/single/address_book/avatar.dart b/frontend/pweb/lib/pages/dashboard/payouts/single/address_book/avatar.dart index cf4f74bf..db03530e 100644 --- a/frontend/pweb/lib/pages/dashboard/payouts/single/address_book/avatar.dart +++ b/frontend/pweb/lib/pages/dashboard/payouts/single/address_book/avatar.dart @@ -26,6 +26,7 @@ class RecipientAvatar extends StatelessWidget { final textColor = Theme.of(context).colorScheme.onPrimary; return Column( + mainAxisAlignment: MainAxisAlignment.center, children: [ CircleAvatar( radius: avatarRadius, diff --git a/frontend/pweb/lib/pages/dashboard/payouts/single/address_book/short_list.dart b/frontend/pweb/lib/pages/dashboard/payouts/single/address_book/short_list.dart index 69b378b1..512bec73 100644 --- a/frontend/pweb/lib/pages/dashboard/payouts/single/address_book/short_list.dart +++ b/frontend/pweb/lib/pages/dashboard/payouts/single/address_book/short_list.dart @@ -7,11 +7,13 @@ import 'package:pweb/pages/dashboard/payouts/single/address_book/avatar.dart'; class ShortListAddressBookPayout extends StatelessWidget { final List recipients; final ValueChanged 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)], + ), ), ); } -} \ No newline at end of file +} diff --git a/frontend/pweb/lib/pages/dashboard/payouts/summary/sent_amount.dart b/frontend/pweb/lib/pages/dashboard/payouts/summary/sent_amount.dart deleted file mode 100644 index 540845e7..00000000 --- a/frontend/pweb/lib/pages/dashboard/payouts/summary/sent_amount.dart +++ /dev/null @@ -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( - builder: (context, provider, _) => PaymentSummaryRow( - labelFactory: AppLocalizations.of(context)!.sentAmount, - asset: Asset(currency: currency, amount: provider.amount), - style: Theme.of(context).textTheme.titleMedium, - ), - ); -} diff --git a/frontend/pweb/lib/pages/dashboard/payouts/summary/widget.dart b/frontend/pweb/lib/pages/dashboard/payouts/summary/widget.dart index 90811718..3a9c48be 100644 --- a/frontend/pweb/lib/pages/dashboard/payouts/summary/widget.dart +++ b/frontend/pweb/lib/pages/dashboard/payouts/summary/widget.dart @@ -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().selectedWallet?.tokenSymbol ?? - 'USDT', - ), - ), const PaymentFeeRow(), const PaymentRecipientReceivesRow(), SizedBox(height: spacing), diff --git a/frontend/pweb/lib/pages/invitations/page.dart b/frontend/pweb/lib/pages/invitations/page.dart deleted file mode 100644 index 74b65561..00000000 --- a/frontend/pweb/lib/pages/invitations/page.dart +++ /dev/null @@ -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 createState() => _InvitationsPageState(); -} - -class _InvitationsPageState extends State { - final GlobalKey _formKey = GlobalKey(); - final TextEditingController _emailController = TextEditingController(); - final TextEditingController _firstNameController = TextEditingController(); - final TextEditingController _lastNameController = TextEditingController(); - final TextEditingController _messageController = TextEditingController(); - - String? _selectedRoleRef; - int _expiryDays = 7; - - Future _createRole() async { - final loc = AppLocalizations.of(context)!; - final draft = await showCreateRoleDialog(context); - if (draft == null) return; - - final permissions = context.read(); - 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().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 _sendInvitation() async { - final form = _formKey.currentState; - if (form == null || !form.validate()) return; - - final account = context.read().account; - if (account == null) return; - final permissions = context.read(); - final roleRef = _selectedRoleRef ?? permissions.roleDescriptions.firstOrNull?.storable.id; - if (roleRef == null) return; - - final invitations = context.read(); - 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(); - 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(), - ], - ), - ), - ), - ); - } -} diff --git a/frontend/pweb/lib/pages/invitations/page/page.dart b/frontend/pweb/lib/pages/invitations/page/page.dart new file mode 100644 index 00000000..e2f1bfb7 --- /dev/null +++ b/frontend/pweb/lib/pages/invitations/page/page.dart @@ -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 createState() => _InvitationsPageState(); +} + +class _InvitationsPageState extends State { + final GlobalKey _formKey = GlobalKey(); + final TextEditingController _emailController = TextEditingController(); + final TextEditingController _firstNameController = TextEditingController(); + final TextEditingController _lastNameController = TextEditingController(); + final TextEditingController _messageController = TextEditingController(); + + Future _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().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), + ), + ), + ); + } +} diff --git a/frontend/pweb/lib/pages/invitations/page/providers.dart b/frontend/pweb/lib/pages/invitations/page/providers.dart new file mode 100644 index 00000000..3a66d6e1 --- /dev/null +++ b/frontend/pweb/lib/pages/invitations/page/providers.dart @@ -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, + ); + } +} diff --git a/frontend/pweb/lib/pages/invitations/page/view.dart b/frontend/pweb/lib/pages/invitations/page/view.dart new file mode 100644 index 00000000..75833f55 --- /dev/null +++ b/frontend/pweb/lib/pages/invitations/page/view.dart @@ -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 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(); + final canCreateRoles = permissions.canCreate(ResourceType.roles); + final ui = context.watch(); + + 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(), + ], + ), + ), + ), + ); + } +} diff --git a/frontend/pweb/lib/pages/invitations/widgets/filter/chips.dart b/frontend/pweb/lib/pages/invitations/widgets/filter/chips.dart index 4a5283ca..afa21c53 100644 --- a/frontend/pweb/lib/pages/invitations/widgets/filter/chips.dart +++ b/frontend/pweb/lib/pages/invitations/widgets/filter/chips.dart @@ -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'; diff --git a/frontend/pweb/lib/pages/invitations/widgets/filter/invitation_filter.dart b/frontend/pweb/lib/pages/invitations/widgets/filter/invitation_filter.dart index a79aaea1..11acec9e 100644 --- a/frontend/pweb/lib/pages/invitations/widgets/filter/invitation_filter.dart +++ b/frontend/pweb/lib/pages/invitations/widgets/filter/invitation_filter.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'; diff --git a/frontend/pweb/lib/pages/invitations/widgets/form/form.dart b/frontend/pweb/lib/pages/invitations/widgets/form/form.dart index 49b9eb84..8139a81c 100644 --- a/frontend/pweb/lib/pages/invitations/widgets/form/form.dart +++ b/frontend/pweb/lib/pages/invitations/widgets/form/form.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 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, diff --git a/frontend/pweb/lib/pages/invitations/widgets/form/view.dart b/frontend/pweb/lib/pages/invitations/widgets/form/view.dart index 748e8acd..c8f81dde 100644 --- a/frontend/pweb/lib/pages/invitations/widgets/form/view.dart +++ b/frontend/pweb/lib/pages/invitations/widgets/form/view.dart @@ -17,7 +17,6 @@ class InvitationFormView extends StatelessWidget { final TextEditingController lastNameController; final TextEditingController messageController; final bool canCreateRoles; - final VoidCallback onCreateRole; final int expiryDays; final ValueChanged onExpiryChanged; final String? selectedRoleRef; @@ -33,7 +32,6 @@ class InvitationFormView extends StatelessWidget { required this.lastNameController, required this.messageController, required this.canCreateRoles, - required this.onCreateRole, required this.expiryDays, required this.onExpiryChanged, required this.selectedRoleRef, diff --git a/frontend/pweb/lib/pages/invitations/widgets/list/view.dart b/frontend/pweb/lib/pages/invitations/widgets/list/view.dart index df82d1ca..b6dfd891 100644 --- a/frontend/pweb/lib/pages/invitations/widgets/list/view.dart +++ b/frontend/pweb/lib/pages/invitations/widgets/list/view.dart @@ -4,7 +4,7 @@ import 'package:provider/provider.dart'; import 'package:pshared/provider/invitations.dart'; -import 'package:pweb/models/invitation_filter.dart'; +import 'package:pweb/models/invitation/invitation_filter.dart'; import 'package:pweb/pages/invitations/widgets/filter/chips.dart'; import 'package:pweb/pages/invitations/widgets/list/body.dart'; import 'package:pweb/pages/invitations/widgets/list/view_model.dart'; diff --git a/frontend/pweb/lib/pages/invitations/widgets/list/view_model.dart b/frontend/pweb/lib/pages/invitations/widgets/list/view_model.dart index 3e46049c..dfc7ba69 100644 --- a/frontend/pweb/lib/pages/invitations/widgets/list/view_model.dart +++ b/frontend/pweb/lib/pages/invitations/widgets/list/view_model.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:pshared/models/invitation/invitation.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'; diff --git a/frontend/pweb/lib/pages/loaders/account.dart b/frontend/pweb/lib/pages/loaders/account.dart index 1b1c2908..44454a7c 100644 --- a/frontend/pweb/lib/pages/loaders/account.dart +++ b/frontend/pweb/lib/pages/loaders/account.dart @@ -6,7 +6,9 @@ import 'package:pshared/models/auth/state.dart'; import 'package:pshared/provider/account.dart'; import 'package:pweb/app/router/pages.dart'; +import 'package:pweb/controllers/auth/account_loader.dart'; import 'package:pweb/utils/error/snackbar.dart'; +import 'package:pweb/models/account/account_loader.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; @@ -20,29 +22,29 @@ class AccountLoader extends StatefulWidget { } class _AccountLoaderState extends State { - AuthState? _handledState; + late final AccountLoaderController _controller; @override void initState() { super.initState(); + _controller = AccountLoaderController()..addListener(_handleControllerAction); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; Provider.of(context, listen: false).restoreIfPossible(); }); } - void _handleSideEffects(AccountProvider provider) { - if (_handledState == provider.authState) return; - _handledState = provider.authState; - + void _handleControllerAction() { + final action = _controller.consumeAction(); + if (action == null) return; void goToLogin() { if (!mounted) return; navigateAndReplace(context, Pages.login); } - switch (provider.authState) { - case AuthState.error: - final error = provider.error ?? Exception('Authorization failed'); + switch (action) { + case AccountLoaderAction.showErrorAndGoToLogin: + final error = _controller.error ?? Exception('Authorization failed'); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; postNotifyUserOfErrorX( @@ -53,18 +55,23 @@ class _AccountLoaderState extends State { goToLogin(); }); break; - case AuthState.empty: + case AccountLoaderAction.goToLogin: WidgetsBinding.instance.addPostFrameCallback((_) => goToLogin()); break; - default: - break; } } + @override + void dispose() { + _controller.removeListener(_handleControllerAction); + _controller.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { return Consumer(builder: (context, provider, _) { - _handleSideEffects(provider); + _controller.update(provider); if (provider.authState == AuthState.ready && provider.account != null) { return widget.child; } diff --git a/frontend/pweb/lib/pages/loaders/organization.dart b/frontend/pweb/lib/pages/loaders/organization.dart index c9533774..6964827b 100644 --- a/frontend/pweb/lib/pages/loaders/organization.dart +++ b/frontend/pweb/lib/pages/loaders/organization.dart @@ -32,9 +32,6 @@ class OrganizationLoader extends StatelessWidget { ); } if ((provider.error == null) && (!provider.isOrganizationSet)) { - WidgetsBinding.instance.addPostFrameCallback((_) { - provider.load(); - }); return const Center(child: CircularProgressIndicator()); } return child; diff --git a/frontend/pweb/lib/pages/loaders/permissions.dart b/frontend/pweb/lib/pages/loaders/permissions.dart index 5a830565..8fef6891 100644 --- a/frontend/pweb/lib/pages/loaders/permissions.dart +++ b/frontend/pweb/lib/pages/loaders/permissions.dart @@ -12,16 +12,6 @@ class PermissionsLoader extends StatelessWidget { final Widget child; const PermissionsLoader({super.key, required this.child}); - void _triggerLoadIfNeeded(PermissionsProvider provider) { - if (!provider.isLoading && !provider.isReady) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!provider.isLoading && !provider.isReady) { - provider.load(); - } - }); - } - } - @override Widget build(BuildContext context) { return Consumer2( @@ -42,7 +32,6 @@ class PermissionsLoader extends StatelessWidget { ), ); } - _triggerLoadIfNeeded(provider); if (provider.isLoading || !provider.isReady) { return const Center(child: CircularProgressIndicator()); } diff --git a/frontend/pweb/lib/pages/login/form.dart b/frontend/pweb/lib/pages/login/form.dart index 1f72ce8c..a5ec8390 100644 --- a/frontend/pweb/lib/pages/login/form.dart +++ b/frontend/pweb/lib/pages/login/form.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -15,9 +13,8 @@ import 'package:pweb/widgets/password/hint/short.dart'; import 'package:pweb/widgets/password/password.dart'; import 'package:pweb/widgets/username.dart'; import 'package:pweb/widgets/vspacer.dart'; -import 'package:pweb/controllers/email.dart'; +import 'package:pweb/controllers/auth/email.dart'; import 'package:pweb/utils/error/snackbar.dart'; -import 'package:pweb/services/posthog.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; @@ -57,7 +54,6 @@ class _LoginFormState extends State { password: _passwordController.text, locale: context.read().locale.languageCode, ); - unawaited(PosthogService.login(pending: outcome.isPending)); if (outcome.isPending) { // TODO: fix context usage navigateAndReplace(context, Pages.sfactor); diff --git a/frontend/pweb/lib/pages/payout_page/methods/advanced.dart b/frontend/pweb/lib/pages/payment_methods/manage/advanced.dart similarity index 100% rename from frontend/pweb/lib/pages/payout_page/methods/advanced.dart rename to frontend/pweb/lib/pages/payment_methods/manage/advanced.dart diff --git a/frontend/pweb/lib/pages/payout_page/methods/header.dart b/frontend/pweb/lib/pages/payment_methods/manage/header.dart similarity index 100% rename from frontend/pweb/lib/pages/payout_page/methods/header.dart rename to frontend/pweb/lib/pages/payment_methods/manage/header.dart diff --git a/frontend/pweb/lib/pages/payout_page/methods/list.dart b/frontend/pweb/lib/pages/payment_methods/manage/list.dart similarity index 89% rename from frontend/pweb/lib/pages/payout_page/methods/list.dart rename to frontend/pweb/lib/pages/payment_methods/manage/list.dart index 8196d579..6c41dbc9 100644 --- a/frontend/pweb/lib/pages/payout_page/methods/list.dart +++ b/frontend/pweb/lib/pages/payment_methods/manage/list.dart @@ -4,8 +4,8 @@ import 'package:provider/provider.dart'; import 'package:pshared/provider/recipient/pmethods.dart'; -import 'package:pweb/pages/payment_methods/title.dart'; -import 'package:pweb/pages/payout_page/methods/controller.dart'; +import 'package:pweb/pages/payment_methods/manage/method_tile.dart'; +import 'package:pweb/controllers/payments/payment_config.dart'; class PaymentConfigList extends StatelessWidget { diff --git a/frontend/pweb/lib/pages/payment_methods/title.dart b/frontend/pweb/lib/pages/payment_methods/manage/method_tile.dart similarity index 100% rename from frontend/pweb/lib/pages/payment_methods/title.dart rename to frontend/pweb/lib/pages/payment_methods/manage/method_tile.dart diff --git a/frontend/pweb/lib/pages/payout_page/methods/widget.dart b/frontend/pweb/lib/pages/payment_methods/manage/widget.dart similarity index 82% rename from frontend/pweb/lib/pages/payout_page/methods/widget.dart rename to frontend/pweb/lib/pages/payment_methods/manage/widget.dart index e0250c87..bcbbcda2 100644 --- a/frontend/pweb/lib/pages/payout_page/methods/widget.dart +++ b/frontend/pweb/lib/pages/payment_methods/manage/widget.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; -import 'package:pweb/pages/payout_page/methods/advanced.dart'; -import 'package:pweb/pages/payout_page/methods/controller.dart'; -import 'package:pweb/pages/payout_page/methods/header.dart'; -import 'package:pweb/pages/payout_page/methods/list.dart'; +import 'package:pweb/pages/payment_methods/manage/advanced.dart'; +import 'package:pweb/controllers/payments/payment_config.dart'; +import 'package:pweb/pages/payment_methods/manage/header.dart'; +import 'package:pweb/pages/payment_methods/manage/list.dart'; class MethodsWidget extends StatefulWidget { diff --git a/frontend/pweb/lib/pages/payment_methods/page.dart b/frontend/pweb/lib/pages/payment_methods/page.dart deleted file mode 100644 index 5fb2868d..00000000 --- a/frontend/pweb/lib/pages/payment_methods/page.dart +++ /dev/null @@ -1,162 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:provider/provider.dart'; - -import 'package:pshared/controllers/balance_mask/wallets.dart'; -import 'package:pshared/models/payment/type.dart'; -import 'package:pshared/models/recipient/recipient.dart'; -import 'package:pshared/provider/payment/flow.dart'; -import 'package:pshared/provider/payment/provider.dart'; -import 'package:pshared/provider/recipient/pmethods.dart'; -import 'package:pshared/provider/recipient/provider.dart'; - -import 'package:pweb/pages/payment_methods/payment_page/body.dart'; -import 'package:pweb/app/router/payout_routes.dart'; -import 'package:pweb/utils/recipient/filtering.dart'; -import 'package:pweb/widgets/sidebar/destinations.dart'; -import 'package:pweb/services/posthog.dart'; -import 'package:pweb/widgets/dialogs/payment_status_dialog.dart'; -import 'package:pweb/controllers/payment_page.dart'; -import 'package:pweb/controllers/payout_verification.dart'; -import 'package:pweb/utils/payment/payout_verification_flow.dart'; -import 'package:pweb/models/control_state.dart'; - - -class PaymentPage extends StatefulWidget { - final ValueChanged? onBack; - final PaymentType? initialPaymentType; - final PayoutDestination fallbackDestination; - - const PaymentPage({ - super.key, - this.onBack, - this.initialPaymentType, - this.fallbackDestination = PayoutDestination.dashboard, - }); - - @override - State createState() => _PaymentPageState(); -} - -class _PaymentPageState extends State { - late final TextEditingController _searchController; - late final FocusNode _searchFocusNode; - Recipient? _previousRecipient; - String _query = ''; - - @override - void initState() { - super.initState(); - _searchController = TextEditingController(); - _searchFocusNode = FocusNode(); - - WidgetsBinding.instance.addPostFrameCallback((_) => _initializePaymentPage()); - } - - @override - void dispose() { - _searchController.dispose(); - _searchFocusNode.dispose(); - super.dispose(); - } - - void _initializePaymentPage() { - final flowProvider = context.read(); - flowProvider.setPreferredType(widget.initialPaymentType); - } - - void _handleSearchChanged(String query) { - setState(() { - _query = query; - }); - } - - void _handleRecipientSelected(Recipient recipient) { - final recipientProvider = context.read(); - setState(() { - _previousRecipient = recipientProvider.currentObject; - }); - recipientProvider.setCurrentObject(recipient.id); - _clearSearchField(); - } - - void _handleRecipientCleared() { - final recipientProvider = context.read(); - setState(() { - _previousRecipient = recipientProvider.currentObject; - }); - recipientProvider.setCurrentObject(null); - _clearSearchField(); - } - - void _clearSearchField() { - _searchController.clear(); - _searchFocusNode.unfocus(); - setState(() { - _query = ''; - }); - } - - Future _handleSendPayment() async { - final flowProvider = context.read(); - final paymentProvider = context.read(); - final controller = context.read(); - final verificationController = context.read(); - if (paymentProvider.isLoading) return; - - final verified = await runPayoutVerification( - context: context, - controller: verificationController, - ); - if (!verified || !mounted) return; - - final isSuccess = await controller.sendPayment(); - if (!mounted) return; - - await showPaymentStatusDialog(context, isSuccess: isSuccess); - if (!mounted) return; - - if (isSuccess) { - PosthogService.paymentInitiated(method: flowProvider.selectedType); - controller.resetAfterSuccess(); - context.goToPayout(widget.fallbackDestination); - } - } - - @override - Widget build(BuildContext context) { - final methodsProvider = context.watch(); - final recipientProvider = context.watch(); - final verificationController = - context.watch(); - final recipient = recipientProvider.currentObject; - final filteredRecipients = filterRecipients( - recipients: recipientProvider.recipients, - query: _query, - ); - final sendState = verificationController.isCooldownActive - ? ControlState.disabled - : ControlState.enabled; - - return PaymentPageBody( - onBack: widget.onBack, - fallbackDestination: widget.fallbackDestination, - recipient: recipient, - previousRecipient: _previousRecipient, - recipientProvider: recipientProvider, - searchQuery: _query, - filteredRecipients: filteredRecipients, - methodsProvider: methodsProvider, - sendState: sendState, - cooldownRemainingSeconds: - verificationController.cooldownRemainingSeconds, - onWalletSelected: context.read().selectWallet, - searchController: _searchController, - searchFocusNode: _searchFocusNode, - onSearchChanged: _handleSearchChanged, - onRecipientSelected: _handleRecipientSelected, - onRecipientCleared: _handleRecipientCleared, - onSend: _handleSendPayment, - ); - } -} diff --git a/frontend/pweb/lib/pages/payment_methods/payment_page/content.dart b/frontend/pweb/lib/pages/payment_methods/payment_page/content.dart deleted file mode 100644 index c2f52577..00000000 --- a/frontend/pweb/lib/pages/payment_methods/payment_page/content.dart +++ /dev/null @@ -1,134 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:provider/provider.dart'; - -import 'package:pshared/controllers/balance_mask/wallets.dart'; -import 'package:pshared/models/payment/wallet.dart'; -import 'package:pshared/models/recipient/recipient.dart'; -import 'package:pshared/provider/recipient/provider.dart'; - -import 'package:pweb/pages/payment_methods/payment_page/back_button.dart'; -import 'package:pweb/pages/payment_methods/payment_page/header.dart'; -import 'package:pweb/pages/payment_methods/payment_page/method_selector.dart'; -import 'package:pweb/pages/payment_methods/payment_page/send_button.dart'; -import 'package:pweb/pages/dashboard/payouts/form.dart'; -import 'package:pweb/pages/payment_methods/widgets/payment_info_section.dart'; -import 'package:pweb/pages/payment_methods/widgets/recipient_section.dart'; -import 'package:pweb/pages/payment_methods/widgets/section_title.dart'; -import 'package:pweb/utils/dimensions.dart'; -import 'package:pweb/widgets/sidebar/destinations.dart'; -import 'package:pweb/widgets/refresh_balance/wallet.dart'; - -import 'package:pweb/generated/i18n/app_localizations.dart'; - - -class PaymentPageContent extends StatelessWidget { - final ValueChanged? onBack; - final Recipient? recipient; - final Recipient? previousRecipient; - final RecipientsProvider recipientProvider; - final String searchQuery; - final List filteredRecipients; - final ValueChanged onWalletSelected; - final PayoutDestination fallbackDestination; - final TextEditingController searchController; - final FocusNode searchFocusNode; - final ValueChanged onSearchChanged; - final ValueChanged onRecipientSelected; - final VoidCallback onRecipientCleared; - final VoidCallback onSend; - - const PaymentPageContent({ - super.key, - required this.onBack, - required this.recipient, - required this.previousRecipient, - required this.recipientProvider, - required this.searchQuery, - required this.filteredRecipients, - required this.onWalletSelected, - required this.fallbackDestination, - required this.searchController, - required this.searchFocusNode, - required this.onSearchChanged, - required this.onRecipientSelected, - required this.onRecipientCleared, - required this.onSend, - }); - - @override - Widget build(BuildContext context) { - final dimensions = AppDimensions(); - final loc = AppLocalizations.of(context)!; - - return Align( - alignment: Alignment.topCenter, - child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: dimensions.maxContentWidth), - child: Material( - elevation: dimensions.elevationSmall, - borderRadius: BorderRadius.circular(dimensions.borderRadiusMedium), - color: Theme.of(context).colorScheme.onSecondary, - child: Padding( - padding: EdgeInsets.all(dimensions.paddingLarge), - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - PaymentBackButton( - onBack: onBack, - recipient: recipient, - fallbackDestination: fallbackDestination, - ), - SizedBox(height: dimensions.paddingSmall), - PaymentHeader(), - SizedBox(height: dimensions.paddingXXLarge), - Row( - children: [ - Expanded(child: SectionTitle(loc.sourceOfFunds)), - Consumer( - builder: (context, provider, _) { - final selectedWalletId = provider.selectedWallet?.id; - if (selectedWalletId == null) { - return const SizedBox.shrink(); - } - return WalletBalanceRefreshButton(walletRef: selectedWalletId); - }, - ), - ], - ), - SizedBox(height: dimensions.paddingSmall), - PaymentMethodSelector( - onMethodChanged: onWalletSelected, - ), - SizedBox(height: dimensions.paddingXLarge), - RecipientSection( - recipient: recipient, - previousRecipient: previousRecipient, - dimensions: dimensions, - recipientProvider: recipientProvider, - searchQuery: searchQuery, - filteredRecipients: filteredRecipients, - searchController: searchController, - searchFocusNode: searchFocusNode, - onSearchChanged: onSearchChanged, - onRecipientSelected: onRecipientSelected, - onRecipientCleared: onRecipientCleared, - ), - SizedBox(height: dimensions.paddingXLarge), - PaymentInfoSection(dimensions: dimensions), - SizedBox(height: dimensions.paddingLarge), - const PaymentFormWidget(), - SizedBox(height: dimensions.paddingXXXLarge), - SendButton(onPressed: onSend), - SizedBox(height: dimensions.paddingLarge), - ], - ), - ), - ), - ), - ), - ); - } -} diff --git a/frontend/pweb/lib/pages/payment_methods/payment_page/header.dart b/frontend/pweb/lib/pages/payment_methods/payment_page/header.dart deleted file mode 100644 index 7b259cb1..00000000 --- a/frontend/pweb/lib/pages/payment_methods/payment_page/header.dart +++ /dev/null @@ -1,34 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:pweb/utils/dimensions.dart'; - -import 'package:pweb/generated/i18n/app_localizations.dart'; - - -class PaymentHeader extends StatelessWidget { - - const PaymentHeader({super.key}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final dimensions = AppDimensions(); - - return Row( - children: [ - Icon( - Icons.send_rounded, - color: theme.colorScheme.primary, - size: dimensions.iconSizeLarge - ), - SizedBox(width: dimensions.spacingSmall), - Text( - AppLocalizations.of(context)!.sendTo, - style: theme.textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.bold - ), - ), - ], - ); - } -} \ No newline at end of file diff --git a/frontend/pweb/lib/pages/payment_methods/payment_page/page.dart b/frontend/pweb/lib/pages/payment_methods/payment_page/page.dart deleted file mode 100644 index 673411af..00000000 --- a/frontend/pweb/lib/pages/payment_methods/payment_page/page.dart +++ /dev/null @@ -1,136 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:pshared/models/payment/wallet.dart'; -import 'package:pshared/models/recipient/recipient.dart'; -import 'package:pshared/provider/recipient/provider.dart'; - -import 'package:pweb/pages/dashboard/payouts/form.dart'; -import 'package:pweb/pages/payment_methods/payment_page/back_button.dart'; -import 'package:pweb/pages/payment_methods/payment_page/header.dart'; -import 'package:pweb/pages/payment_methods/payment_page/method_selector.dart'; -import 'package:pweb/pages/payment_methods/payment_page/send_button.dart'; -import 'package:pweb/pages/payment_methods/widgets/payment_info_section.dart'; -import 'package:pweb/pages/payment_methods/widgets/recipient_section.dart'; -import 'package:pweb/pages/payment_methods/widgets/section_title.dart'; -import 'package:pweb/utils/dimensions.dart'; -import 'package:pweb/widgets/cooldown_hint.dart'; -import 'package:pweb/widgets/sidebar/destinations.dart'; -import 'package:pweb/models/control_state.dart'; - -import 'package:pweb/generated/i18n/app_localizations.dart'; - - -class PaymentPageContent extends StatelessWidget { - final ValueChanged? onBack; - final Recipient? recipient; - final Recipient? previousRecipient; - final RecipientsProvider recipientProvider; - final String searchQuery; - final List filteredRecipients; - final ValueChanged onWalletSelected; - final PayoutDestination fallbackDestination; - final ControlState sendState; - final int cooldownRemainingSeconds; - final TextEditingController searchController; - final FocusNode searchFocusNode; - final ValueChanged onSearchChanged; - final ValueChanged onRecipientSelected; - final VoidCallback onRecipientCleared; - final VoidCallback onSend; - - const PaymentPageContent({ - super.key, - required this.onBack, - required this.recipient, - required this.previousRecipient, - required this.recipientProvider, - required this.searchQuery, - required this.filteredRecipients, - required this.onWalletSelected, - required this.fallbackDestination, - required this.sendState, - required this.cooldownRemainingSeconds, - required this.searchController, - required this.searchFocusNode, - required this.onSearchChanged, - required this.onRecipientSelected, - required this.onRecipientCleared, - required this.onSend, - }); - - @override - Widget build(BuildContext context) { - final dimensions = AppDimensions(); - final loc = AppLocalizations.of(context)!; - - return Align( - alignment: Alignment.topCenter, - child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: dimensions.maxContentWidth), - child: Material( - elevation: dimensions.elevationSmall, - borderRadius: BorderRadius.circular(dimensions.borderRadiusMedium), - color: Theme.of(context).colorScheme.onSecondary, - child: Padding( - padding: EdgeInsets.all(dimensions.paddingLarge), - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - PaymentBackButton( - onBack: onBack, - recipient: recipient, - fallbackDestination: fallbackDestination, - ), - SizedBox(height: dimensions.paddingSmall), - PaymentHeader(), - SizedBox(height: dimensions.paddingXXLarge), - SectionTitle(loc.sourceOfFunds), - SizedBox(height: dimensions.paddingSmall), - PaymentMethodSelector( - onMethodChanged: onWalletSelected, - ), - SizedBox(height: dimensions.paddingXLarge), - RecipientSection( - recipient: recipient, - previousRecipient: previousRecipient, - dimensions: dimensions, - recipientProvider: recipientProvider, - searchQuery: searchQuery, - filteredRecipients: filteredRecipients, - searchController: searchController, - searchFocusNode: searchFocusNode, - onSearchChanged: onSearchChanged, - onRecipientSelected: onRecipientSelected, - onRecipientCleared: onRecipientCleared, - ), - SizedBox(height: dimensions.paddingXLarge), - PaymentInfoSection(dimensions: dimensions), - SizedBox(height: dimensions.paddingLarge), - const PaymentFormWidget(), - SizedBox(height: dimensions.paddingXXXLarge), - Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SendButton( - onPressed: onSend, - state: sendState, - ), - if (sendState == ControlState.disabled && - cooldownRemainingSeconds > 0) ...[ - SizedBox(height: dimensions.paddingSmall), - CooldownHint(seconds: cooldownRemainingSeconds), - ], - ], - ), - SizedBox(height: dimensions.paddingLarge), - ], - ), - ), - ), - ), - ), - ); - } -} diff --git a/frontend/pweb/lib/pages/payment_methods/widgets/payment_info_section.dart b/frontend/pweb/lib/pages/payment_methods/widgets/payment_info_section.dart deleted file mode 100644 index 5dc42782..00000000 --- a/frontend/pweb/lib/pages/payment_methods/widgets/payment_info_section.dart +++ /dev/null @@ -1,98 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:provider/provider.dart'; - -import 'package:pshared/models/payment/methods/data.dart'; -import 'package:pshared/models/payment/methods/type.dart'; -import 'package:pshared/provider/payment/flow.dart'; - -import 'package:pweb/pages/payment_methods/form.dart'; -import 'package:pweb/pages/payment_methods/widgets/section_title.dart'; -import 'package:pweb/utils/dimensions.dart'; -import 'package:pweb/utils/payment/availability.dart'; -import 'package:pweb/utils/payment/selector_type.dart'; -import 'package:pweb/utils/payment/label.dart'; - -import 'package:pweb/generated/i18n/app_localizations.dart'; - -//TODO Whole page sucks. Will redesign. -class PaymentInfoSection extends StatelessWidget { - final AppDimensions dimensions; - - const PaymentInfoSection({ - super.key, - required this.dimensions, - }); - - @override - Widget build(BuildContext context) { - final loc = AppLocalizations.of(context)!; - final flowProvider = context.watch(); - final hasRecipient = flowProvider.hasRecipient; - final MethodMap resolvedAvailableTypes = filterVisiblePaymentTypes(flowProvider.availableTypes); - final disabledTypesForSelection = hasRecipient - ? disabledPaymentTypes.difference(resolvedAvailableTypes.keys.toSet()) - : disabledPaymentTypes; - final methodsForSelectedType = flowProvider.methodsForSelectedType; - final selectedMethod = flowProvider.selectedMethod ?? - (methodsForSelectedType.isNotEmpty ? methodsForSelectedType.first : null); - - if (hasRecipient && resolvedAvailableTypes.isEmpty) { - return Text(loc.recipientNoPaymentDetails); - } - - final selectedType = flowProvider.selectedType; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SectionTitle(loc.paymentInfo), - SizedBox(height: dimensions.paddingSmall), - PaymentTypeSelector( - availableTypes: resolvedAvailableTypes, - selectedType: selectedType, - disabledTypes: disabledTypesForSelection, - onSelected: (type) => flowProvider.selectType( - type, - resetManualData: !hasRecipient, - ), - ), - SizedBox(height: dimensions.paddingMedium), - if (hasRecipient && methodsForSelectedType.length > 1) - DropdownButtonFormField( - initialValue: selectedMethod, - dropdownColor: Theme.of(context).colorScheme.onSecondary, - decoration: InputDecoration( - labelText: loc.paymentMethodDetails, - border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), - ), - items: methodsForSelectedType.map((method) { - final description = getPaymentTypeDescription(context, method); - final label = method.name.isNotEmpty ? '${method.name} - $description' : description; - return DropdownMenuItem( - value: method, - child: Text(label), - ); - }).toList(), - onChanged: (value) { - if (value != null) { - flowProvider.selectMethod(value); - } - }, - ), - if (hasRecipient && methodsForSelectedType.length > 1) - SizedBox(height: dimensions.paddingMedium), - PaymentMethodForm( - selectedType: selectedType, - onChanged: (data) { - if (!hasRecipient) { - flowProvider.setManualPaymentData(data); - } - }, - initialData: flowProvider.selectedPaymentData, - isEditable: !hasRecipient, - ), - ], - ); - } -} diff --git a/frontend/pweb/lib/pages/payout_page/page.dart b/frontend/pweb/lib/pages/payout_page/page.dart index 559a65d1..4d5e1856 100644 --- a/frontend/pweb/lib/pages/payout_page/page.dart +++ b/frontend/pweb/lib/pages/payout_page/page.dart @@ -5,8 +5,8 @@ import 'package:provider/provider.dart'; import 'package:pshared/provider/recipient/pmethods.dart'; import 'package:pshared/models/payment/wallet.dart'; -import 'package:pweb/pages/payout_page/methods/widget.dart'; -import 'package:pweb/pages/payout_page/wallet/wigets.dart'; +import 'package:pweb/pages/payment_methods/manage/widget.dart'; +import 'package:pweb/pages/payout_page/wallet/widget.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; diff --git a/frontend/pweb/lib/pages/payment_methods/payment_page/body.dart b/frontend/pweb/lib/pages/payout_page/send/body.dart similarity index 77% rename from frontend/pweb/lib/pages/payment_methods/payment_page/body.dart rename to frontend/pweb/lib/pages/payout_page/send/body.dart index 4b799f82..25e9a100 100644 --- a/frontend/pweb/lib/pages/payment_methods/payment_page/body.dart +++ b/frontend/pweb/lib/pages/payout_page/send/body.dart @@ -6,9 +6,10 @@ import 'package:pshared/provider/recipient/pmethods.dart'; import 'package:pshared/provider/recipient/provider.dart'; import 'package:pweb/widgets/sidebar/destinations.dart'; -import 'package:pweb/pages/payment_methods/widgets/state_view.dart'; -import 'package:pweb/pages/payment_methods/payment_page/page.dart'; -import 'package:pweb/models/control_state.dart'; +import 'package:pweb/pages/payout_page/send/widgets/state_view.dart'; +import 'package:pweb/pages/payout_page/send/content.dart'; +import 'package:pweb/models/state/control_state.dart'; +import 'package:pweb/models/state/visibility.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; @@ -16,7 +17,6 @@ import 'package:pweb/generated/i18n/app_localizations.dart'; class PaymentPageBody extends StatelessWidget { final ValueChanged? onBack; final Recipient? recipient; - final Recipient? previousRecipient; final RecipientsProvider recipientProvider; final String searchQuery; final List filteredRecipients; @@ -31,12 +31,15 @@ class PaymentPageBody extends StatelessWidget { final ValueChanged onRecipientSelected; final VoidCallback onRecipientCleared; final VoidCallback onSend; + final VoidCallback onAddRecipient; + final VoidCallback onAddPaymentMethod; + final VisibilityState paymentDetailsVisibility; + final VoidCallback onTogglePaymentDetails; const PaymentPageBody({ super.key, required this.onBack, required this.recipient, - required this.previousRecipient, required this.recipientProvider, required this.searchQuery, required this.filteredRecipients, @@ -51,6 +54,10 @@ class PaymentPageBody extends StatelessWidget { required this.onRecipientSelected, required this.onRecipientCleared, required this.onSend, + required this.onAddRecipient, + required this.onAddPaymentMethod, + required this.paymentDetailsVisibility, + required this.onTogglePaymentDetails, }); @override @@ -70,7 +77,6 @@ class PaymentPageBody extends StatelessWidget { return PaymentPageContent( onBack: onBack, recipient: recipient, - previousRecipient: previousRecipient, recipientProvider: recipientProvider, searchQuery: searchQuery, filteredRecipients: filteredRecipients, @@ -84,6 +90,10 @@ class PaymentPageBody extends StatelessWidget { onRecipientSelected: onRecipientSelected, onRecipientCleared: onRecipientCleared, onSend: onSend, + onAddRecipient: onAddRecipient, + onAddPaymentMethod: onAddPaymentMethod, + paymentDetailsVisibility: paymentDetailsVisibility, + onTogglePaymentDetails: onTogglePaymentDetails, ); } } diff --git a/frontend/pweb/lib/pages/payout_page/send/content.dart b/frontend/pweb/lib/pages/payout_page/send/content.dart new file mode 100644 index 00000000..0a45f067 --- /dev/null +++ b/frontend/pweb/lib/pages/payout_page/send/content.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/models/payment/wallet.dart'; +import 'package:pshared/models/recipient/recipient.dart'; +import 'package:pshared/provider/recipient/provider.dart'; + +import 'package:pweb/pages/payout_page/send/content/layout.dart'; +import 'package:pweb/pages/payout_page/send/content/sections.dart'; +import 'package:pweb/utils/dimensions.dart'; +import 'package:pweb/widgets/sidebar/destinations.dart'; +import 'package:pweb/models/state/control_state.dart'; +import 'package:pweb/models/state/visibility.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class PaymentPageContent extends StatelessWidget { + final ValueChanged? onBack; + final Recipient? recipient; + final RecipientsProvider recipientProvider; + final String searchQuery; + final List filteredRecipients; + final ValueChanged onWalletSelected; + final PayoutDestination fallbackDestination; + final ControlState sendState; + final int cooldownRemainingSeconds; + final TextEditingController searchController; + final FocusNode searchFocusNode; + final ValueChanged onSearchChanged; + final ValueChanged onRecipientSelected; + final VoidCallback onRecipientCleared; + final VoidCallback onSend; + final VoidCallback onAddRecipient; + final VoidCallback onAddPaymentMethod; + final VisibilityState paymentDetailsVisibility; + final VoidCallback onTogglePaymentDetails; + + const PaymentPageContent({ + super.key, + required this.onBack, + required this.recipient, + required this.recipientProvider, + required this.searchQuery, + required this.filteredRecipients, + required this.onWalletSelected, + required this.fallbackDestination, + required this.sendState, + required this.cooldownRemainingSeconds, + required this.searchController, + required this.searchFocusNode, + required this.onSearchChanged, + required this.onRecipientSelected, + required this.onRecipientCleared, + required this.onSend, + required this.onAddRecipient, + required this.onAddPaymentMethod, + required this.paymentDetailsVisibility, + required this.onTogglePaymentDetails, + }); + + @override + Widget build(BuildContext context) { + final dimensions = AppDimensions(); + final loc = AppLocalizations.of(context)!; + final maxWidth = dimensions.maxContentWidth + 180; + + return PaymentPageContentLayout( + maxWidth: maxWidth, + padding: EdgeInsets.only( + left: dimensions.paddingSmall, + right: dimensions.paddingSmall, + bottom: dimensions.paddingLarge, + ), + child: PaymentPageContentSections( + dimensions: dimensions, + sourceOfFundsTitle: loc.sourceOfFunds, + onBack: onBack, + recipient: recipient, + recipientProvider: recipientProvider, + searchQuery: searchQuery, + filteredRecipients: filteredRecipients, + onWalletSelected: onWalletSelected, + fallbackDestination: fallbackDestination, + sendState: sendState, + cooldownRemainingSeconds: cooldownRemainingSeconds, + searchController: searchController, + searchFocusNode: searchFocusNode, + onSearchChanged: onSearchChanged, + onRecipientSelected: onRecipientSelected, + onRecipientCleared: onRecipientCleared, + onSend: onSend, + onAddRecipient: onAddRecipient, + onAddPaymentMethod: onAddPaymentMethod, + paymentDetailsVisibility: paymentDetailsVisibility, + onTogglePaymentDetails: onTogglePaymentDetails, + ), + ); + } +} diff --git a/frontend/pweb/lib/pages/payout_page/send/content/back_section.dart b/frontend/pweb/lib/pages/payout_page/send/content/back_section.dart new file mode 100644 index 00000000..a45b992c --- /dev/null +++ b/frontend/pweb/lib/pages/payout_page/send/content/back_section.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/models/recipient/recipient.dart'; + +import 'package:pweb/pages/payout_page/send/widgets/back_button.dart'; +import 'package:pweb/widgets/sidebar/destinations.dart'; + + +class PaymentPageBackSection extends StatelessWidget { + final ValueChanged? onBack; + final Recipient? recipient; + final PayoutDestination fallbackDestination; + + const PaymentPageBackSection({ + super.key, + required this.onBack, + required this.recipient, + required this.fallbackDestination, + }); + + @override + Widget build(BuildContext context) { + return PaymentBackButton( + onBack: onBack, + recipient: recipient, + fallbackDestination: fallbackDestination, + ); + } +} diff --git a/frontend/pweb/lib/pages/payout_page/send/content/layout.dart b/frontend/pweb/lib/pages/payout_page/send/content/layout.dart new file mode 100644 index 00000000..423fadea --- /dev/null +++ b/frontend/pweb/lib/pages/payout_page/send/content/layout.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; + + +class PaymentPageContentLayout extends StatelessWidget { + final double maxWidth; + final EdgeInsets padding; + final Widget child; + + const PaymentPageContentLayout({ + super.key, + required this.maxWidth, + required this.padding, + required this.child, + }); + + @override + Widget build(BuildContext context) { + return Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxWidth), + child: SingleChildScrollView( + padding: padding, + child: child, + ), + ), + ); + } +} diff --git a/frontend/pweb/lib/pages/payout_page/send/content/recipient_section.dart b/frontend/pweb/lib/pages/payout_page/send/content/recipient_section.dart new file mode 100644 index 00000000..33ab5900 --- /dev/null +++ b/frontend/pweb/lib/pages/payout_page/send/content/recipient_section.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/models/recipient/recipient.dart'; +import 'package:pshared/provider/recipient/provider.dart'; + +import 'package:pweb/pages/payout_page/send/widgets/recipient_details_card.dart'; +import 'package:pweb/utils/dimensions.dart'; +import 'package:pweb/models/state/visibility.dart'; + + +class PaymentPageRecipientSection extends StatelessWidget { + final AppDimensions dimensions; + final Recipient? recipient; + final RecipientsProvider recipientProvider; + final String searchQuery; + final List filteredRecipients; + final TextEditingController searchController; + final FocusNode searchFocusNode; + final ValueChanged onSearchChanged; + final ValueChanged onRecipientSelected; + final VoidCallback onRecipientCleared; + final VoidCallback onAddRecipient; + final VoidCallback onAddPaymentMethod; + final VisibilityState paymentDetailsVisibility; + final VoidCallback onTogglePaymentDetails; + + const PaymentPageRecipientSection({ + super.key, + required this.dimensions, + required this.recipient, + required this.recipientProvider, + required this.searchQuery, + required this.filteredRecipients, + required this.searchController, + required this.searchFocusNode, + required this.onSearchChanged, + required this.onRecipientSelected, + required this.onRecipientCleared, + required this.onAddRecipient, + required this.onAddPaymentMethod, + required this.paymentDetailsVisibility, + required this.onTogglePaymentDetails, + }); + + @override + Widget build(BuildContext context) { + return PaymentRecipientDetailsCard( + dimensions: dimensions, + recipient: recipient, + recipientProvider: recipientProvider, + searchQuery: searchQuery, + filteredRecipients: filteredRecipients, + searchController: searchController, + searchFocusNode: searchFocusNode, + onSearchChanged: onSearchChanged, + onRecipientSelected: onRecipientSelected, + onRecipientCleared: onRecipientCleared, + onAddRecipient: onAddRecipient, + onAddPaymentMethod: onAddPaymentMethod, + paymentDetailsVisibility: paymentDetailsVisibility, + onTogglePaymentDetails: onTogglePaymentDetails, + ); + } +} diff --git a/frontend/pweb/lib/pages/payout_page/send/content/sections.dart b/frontend/pweb/lib/pages/payout_page/send/content/sections.dart new file mode 100644 index 00000000..ff3166cd --- /dev/null +++ b/frontend/pweb/lib/pages/payout_page/send/content/sections.dart @@ -0,0 +1,109 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/models/payment/wallet.dart'; +import 'package:pshared/models/recipient/recipient.dart'; +import 'package:pshared/provider/recipient/provider.dart'; + +import 'package:pweb/pages/payout_page/send/content/back_section.dart'; +import 'package:pweb/pages/payout_page/send/content/recipient_section.dart'; +import 'package:pweb/pages/payout_page/send/content/send_section.dart'; +import 'package:pweb/pages/payout_page/send/content/source_section.dart'; +import 'package:pweb/utils/dimensions.dart'; +import 'package:pweb/widgets/sidebar/destinations.dart'; +import 'package:pweb/models/state/control_state.dart'; +import 'package:pweb/models/state/visibility.dart'; + + +class PaymentPageContentSections extends StatelessWidget { + final AppDimensions dimensions; + final String sourceOfFundsTitle; + final ValueChanged? onBack; + final Recipient? recipient; + final RecipientsProvider recipientProvider; + final String searchQuery; + final List filteredRecipients; + final ValueChanged onWalletSelected; + final PayoutDestination fallbackDestination; + final ControlState sendState; + final int cooldownRemainingSeconds; + final TextEditingController searchController; + final FocusNode searchFocusNode; + final ValueChanged onSearchChanged; + final ValueChanged onRecipientSelected; + final VoidCallback onRecipientCleared; + final VoidCallback onSend; + final VoidCallback onAddRecipient; + final VoidCallback onAddPaymentMethod; + final VisibilityState paymentDetailsVisibility; + final VoidCallback onTogglePaymentDetails; + + const PaymentPageContentSections({ + super.key, + required this.dimensions, + required this.sourceOfFundsTitle, + required this.onBack, + required this.recipient, + required this.recipientProvider, + required this.searchQuery, + required this.filteredRecipients, + required this.onWalletSelected, + required this.fallbackDestination, + required this.sendState, + required this.cooldownRemainingSeconds, + required this.searchController, + required this.searchFocusNode, + required this.onSearchChanged, + required this.onRecipientSelected, + required this.onRecipientCleared, + required this.onSend, + required this.onAddRecipient, + required this.onAddPaymentMethod, + required this.paymentDetailsVisibility, + required this.onTogglePaymentDetails, + }); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + PaymentPageBackSection( + onBack: onBack, + recipient: recipient, + fallbackDestination: fallbackDestination, + ), + SizedBox(height: dimensions.paddingSmall), + PaymentPageSourceSection( + dimensions: dimensions, + title: sourceOfFundsTitle, + onWalletSelected: onWalletSelected, + ), + SizedBox(height: dimensions.paddingXLarge), + PaymentPageRecipientSection( + dimensions: dimensions, + recipient: recipient, + recipientProvider: recipientProvider, + searchQuery: searchQuery, + filteredRecipients: filteredRecipients, + searchController: searchController, + searchFocusNode: searchFocusNode, + onSearchChanged: onSearchChanged, + onRecipientSelected: onRecipientSelected, + onRecipientCleared: onRecipientCleared, + onAddRecipient: onAddRecipient, + onAddPaymentMethod: onAddPaymentMethod, + paymentDetailsVisibility: paymentDetailsVisibility, + onTogglePaymentDetails: onTogglePaymentDetails, + ), + SizedBox(height: dimensions.paddingXLarge), + PaymentPageSendSection( + dimensions: dimensions, + sendState: sendState, + cooldownRemainingSeconds: cooldownRemainingSeconds, + onSend: onSend, + ), + ], + ); + } +} diff --git a/frontend/pweb/lib/pages/payout_page/send/content/send_section.dart b/frontend/pweb/lib/pages/payout_page/send/content/send_section.dart new file mode 100644 index 00000000..46c148a8 --- /dev/null +++ b/frontend/pweb/lib/pages/payout_page/send/content/send_section.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/pages/payout_page/send/widgets/send_card.dart'; +import 'package:pweb/utils/dimensions.dart'; +import 'package:pweb/models/state/control_state.dart'; + + +class PaymentPageSendSection extends StatelessWidget { + final AppDimensions dimensions; + final ControlState sendState; + final int cooldownRemainingSeconds; + final VoidCallback onSend; + + const PaymentPageSendSection({ + super.key, + required this.dimensions, + required this.sendState, + required this.cooldownRemainingSeconds, + required this.onSend, + }); + + @override + Widget build(BuildContext context) { + return PaymentSendCard( + dimensions: dimensions, + sendState: sendState, + cooldownRemainingSeconds: cooldownRemainingSeconds, + onSend: onSend, + ); + } +} diff --git a/frontend/pweb/lib/pages/payout_page/send/content/source_section.dart b/frontend/pweb/lib/pages/payout_page/send/content/source_section.dart new file mode 100644 index 00000000..b86b63a1 --- /dev/null +++ b/frontend/pweb/lib/pages/payout_page/send/content/source_section.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/models/payment/wallet.dart'; + +import 'package:pweb/pages/payout_page/send/widgets/source_of_funds_card.dart'; +import 'package:pweb/utils/dimensions.dart'; + + +class PaymentPageSourceSection extends StatelessWidget { + final AppDimensions dimensions; + final String title; + final ValueChanged onWalletSelected; + + const PaymentPageSourceSection({ + super.key, + required this.dimensions, + required this.title, + required this.onWalletSelected, + }); + + @override + Widget build(BuildContext context) { + return PaymentSourceOfFundsCard( + dimensions: dimensions, + title: title, + onWalletSelected: onWalletSelected, + ); + } +} diff --git a/frontend/pweb/lib/pages/payout_page/send/page.dart b/frontend/pweb/lib/pages/payout_page/send/page.dart new file mode 100644 index 00000000..7118ccf8 --- /dev/null +++ b/frontend/pweb/lib/pages/payout_page/send/page.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/models/payment/type.dart'; +import 'package:pshared/models/recipient/recipient.dart'; + +import 'package:pweb/widgets/sidebar/destinations.dart'; +import 'package:pweb/controllers/payments/page_ui.dart'; +import 'package:pweb/pages/payout_page/send/page_handlers.dart'; +import 'package:pweb/pages/payout_page/send/page_view.dart'; + + +class PaymentPage extends StatefulWidget { + final ValueChanged? onBack; + final PaymentType? initialPaymentType; + final PayoutDestination fallbackDestination; + + const PaymentPage({ + super.key, + this.onBack, + this.initialPaymentType, + this.fallbackDestination = PayoutDestination.dashboard, + }); + + @override + State createState() => _PaymentPageState(); +} + +class _PaymentPageState extends State { + late final PaymentPageUiController _uiController; + + @override + void initState() { + super.initState(); + _uiController = PaymentPageUiController(); + + WidgetsBinding.instance.addPostFrameCallback( + (_) => initializePaymentPage(context, widget.initialPaymentType), + ); + } + + @override + void dispose() { + _uiController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return PaymentPageView( + uiController: _uiController, + onBack: widget.onBack, + fallbackDestination: widget.fallbackDestination, + onSearchChanged: (query) => handleSearchChanged(_uiController, query), + onRecipientSelected: (recipient) => + handleRecipientSelected(context, _uiController, recipient), + onRecipientCleared: () => handleRecipientCleared(context, _uiController), + onSend: () => handleSendPayment( + state: this, + fallbackDestination: widget.fallbackDestination, + ), + onAddRecipient: () => handleAddRecipient(context), + onAddPaymentMethod: () => handleAddPaymentMethod(context), + ); + } +} diff --git a/frontend/pweb/lib/pages/payout_page/send/page_handlers.dart b/frontend/pweb/lib/pages/payout_page/send/page_handlers.dart new file mode 100644 index 00000000..6716b4da --- /dev/null +++ b/frontend/pweb/lib/pages/payout_page/send/page_handlers.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; + +import 'package:go_router/go_router.dart'; + +import 'package:provider/provider.dart'; + +import 'package:pshared/models/payment/type.dart'; +import 'package:pshared/models/recipient/recipient.dart'; +import 'package:pshared/provider/payment/flow.dart'; +import 'package:pshared/provider/payment/provider.dart'; +import 'package:pshared/provider/payment/quotation/quotation.dart'; +import 'package:pshared/provider/recipient/provider.dart'; + +import 'package:pweb/app/router/payout_routes.dart'; +import 'package:pweb/widgets/sidebar/destinations.dart'; +import 'package:pweb/widgets/dialogs/payment_status_dialog.dart'; +import 'package:pweb/controllers/payments/page.dart'; +import 'package:pweb/controllers/payments/page_ui.dart'; +import 'package:pweb/controllers/payouts/payout_verification.dart'; +import 'package:pweb/utils/payment/payout_verification_flow.dart'; + + +void initializePaymentPage( + BuildContext context, + PaymentType? initialPaymentType, +) { + final flowProvider = context.read(); + flowProvider.setPreferredType(initialPaymentType); +} + +void handleSearchChanged(PaymentPageUiController uiController, String query) { + uiController.setQuery(query); +} + +void handleRecipientSelected( + BuildContext context, + PaymentPageUiController uiController, + Recipient recipient, +) { + final recipientProvider = context.read(); + recipientProvider.setCurrentObject(recipient.id); + uiController.clearSearch(); +} + +void handleRecipientCleared( + BuildContext context, + PaymentPageUiController uiController, +) { + final recipientProvider = context.read(); + recipientProvider.setCurrentObject(null); + uiController.clearSearch(); +} + +Future handleSendPayment({ + required State state, + required PayoutDestination fallbackDestination, +}) async { + final context = state.context; + final paymentProvider = context.read(); + final quotationProvider = context.read(); + final controller = context.read(); + final verificationController = context.read(); + if (paymentProvider.isLoading) return; + final verificationContextKey = + quotationProvider.quotation?.quoteRef ?? + quotationProvider.quotation?.idempotencyKey; + + final verified = await runPayoutVerification( + context: context, + controller: verificationController, + contextKey: verificationContextKey, + ); + if (!verified || !state.mounted) return; + + final isSuccess = await controller.sendPayment(); + if (!state.mounted) return; + + await showPaymentStatusDialog(context, isSuccess: isSuccess); + if (!state.mounted) return; + + if (isSuccess) { + controller.handleSuccess(); + context.goToPayout(fallbackDestination); + } +} + +void handleAddRecipient(BuildContext context) { + final recipients = context.read(); + recipients.setCurrentObject(null); + context.pushToPayout(PayoutDestination.addrecipient); +} + +void handleAddPaymentMethod(BuildContext context) { + final recipients = context.read(); + final recipient = recipients.currentObject; + if (recipient == null) return; + recipients.setCurrentObject(recipient.id); + context.pushNamed(PayoutRoutes.editRecipient); +} diff --git a/frontend/pweb/lib/pages/payout_page/send/page_view.dart b/frontend/pweb/lib/pages/payout_page/send/page_view.dart new file mode 100644 index 00000000..7191c794 --- /dev/null +++ b/frontend/pweb/lib/pages/payout_page/send/page_view.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:pshared/controllers/balance_mask/wallets.dart'; +import 'package:pshared/models/recipient/recipient.dart'; +import 'package:pshared/provider/payment/quotation/quotation.dart'; +import 'package:pshared/provider/recipient/pmethods.dart'; +import 'package:pshared/provider/recipient/provider.dart'; + +import 'package:pweb/pages/payout_page/send/body.dart'; +import 'package:pweb/utils/recipient/filtering.dart'; +import 'package:pweb/widgets/sidebar/destinations.dart'; +import 'package:pweb/controllers/payments/page_ui.dart'; +import 'package:pweb/controllers/payouts/payout_verification.dart'; +import 'package:pweb/models/state/control_state.dart'; + + +class PaymentPageView extends StatelessWidget { + final PaymentPageUiController uiController; + final ValueChanged? onBack; + final PayoutDestination fallbackDestination; + final ValueChanged onSearchChanged; + final ValueChanged onRecipientSelected; + final VoidCallback onRecipientCleared; + final VoidCallback onSend; + final VoidCallback onAddRecipient; + final VoidCallback onAddPaymentMethod; + + const PaymentPageView({ + super.key, + required this.uiController, + required this.onBack, + required this.fallbackDestination, + required this.onSearchChanged, + required this.onRecipientSelected, + required this.onRecipientCleared, + required this.onSend, + required this.onAddRecipient, + required this.onAddPaymentMethod, + }); + + @override + Widget build(BuildContext context) { + return ChangeNotifierProvider.value( + value: uiController, + child: Builder( + builder: (context) { + final uiController = context.watch(); + final methodsProvider = context.watch(); + final recipientProvider = context.watch(); + final quotationProvider = context.watch(); + final verificationController = + context.watch(); + final verificationContextKey = + quotationProvider.quotation?.quoteRef ?? + quotationProvider.quotation?.idempotencyKey; + final recipient = recipientProvider.currentObject; + final filteredRecipients = filterRecipients( + recipients: recipientProvider.recipients, + query: uiController.query, + ); + final sendState = + verificationController.isCooldownActiveFor(verificationContextKey) + ? ControlState.disabled + : (recipient == null + ? ControlState.disabled + : ControlState.enabled); + + return PaymentPageBody( + onBack: onBack, + fallbackDestination: fallbackDestination, + recipient: recipient, + recipientProvider: recipientProvider, + searchQuery: uiController.query, + filteredRecipients: filteredRecipients, + methodsProvider: methodsProvider, + sendState: sendState, + cooldownRemainingSeconds: + verificationController + .cooldownRemainingSecondsFor(verificationContextKey), + onWalletSelected: context.read().selectWallet, + searchController: uiController.searchController, + searchFocusNode: uiController.searchFocusNode, + onSearchChanged: onSearchChanged, + onRecipientSelected: onRecipientSelected, + onRecipientCleared: onRecipientCleared, + onSend: onSend, + onAddRecipient: onAddRecipient, + onAddPaymentMethod: onAddPaymentMethod, + paymentDetailsVisibility: uiController.paymentDetailsVisibility, + onTogglePaymentDetails: uiController.togglePaymentDetails, + ); + }, + ), + ); + } +} diff --git a/frontend/pweb/lib/pages/payout_page/send/widgets/add_payment_method_tile.dart b/frontend/pweb/lib/pages/payout_page/send/widgets/add_payment_method_tile.dart new file mode 100644 index 00000000..2c4f43f2 --- /dev/null +++ b/frontend/pweb/lib/pages/payout_page/send/widgets/add_payment_method_tile.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; + + +class AddPaymentMethodTile extends StatelessWidget { + final String label; + final VoidCallback onTap; + + const AddPaymentMethodTile({ + super.key, + required this.label, + required this.onTap, + }); + + static const double _borderRadius = 12; + static const double _iconSize = 18; + static const double _minWidth = 150; + static const EdgeInsets _padding = EdgeInsets.all(12); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final borderColor = theme.colorScheme.primary.withValues(alpha: 0.45); + + return ConstrainedBox( + constraints: const BoxConstraints(minWidth: _minWidth), + child: Material( + color: theme.colorScheme.onSecondary, + borderRadius: BorderRadius.circular(_borderRadius), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(_borderRadius), + child: Container( + padding: _padding, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(_borderRadius), + border: Border.all(color: borderColor), + ), + child: Row( + children: [ + Icon(Icons.add, size: _iconSize, color: theme.colorScheme.primary), + const SizedBox(width: 8), + Expanded( + child: Text( + label, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + color: theme.colorScheme.primary, + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/frontend/pweb/lib/pages/payout_page/send/widgets/add_recipient_tile.dart b/frontend/pweb/lib/pages/payout_page/send/widgets/add_recipient_tile.dart new file mode 100644 index 00000000..ca91f36d --- /dev/null +++ b/frontend/pweb/lib/pages/payout_page/send/widgets/add_recipient_tile.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; + + +class AddRecipientTile extends StatelessWidget { + final String label; + final VoidCallback onTap; + + const AddRecipientTile({ + super.key, + required this.label, + required this.onTap, + }); + + static const double _avatarRadius = 20; + static const double _tileSize = 80; + static const double _verticalSpacing = 6; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + hoverColor: theme.colorScheme.primaryContainer, + child: SizedBox( + width: _tileSize, + height: _tileSize, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircleAvatar( + radius: _avatarRadius, + backgroundColor: theme.colorScheme.primaryContainer, + child: Icon( + Icons.add, + color: theme.colorScheme.primary, + size: 20, + ), + ), + const SizedBox(height: _verticalSpacing), + Text( + label, + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: theme.textTheme.bodySmall?.copyWith(fontSize: 12), + ), + ], + ), + ), + ); + } +} diff --git a/frontend/pweb/lib/pages/payment_methods/payment_page/back_button.dart b/frontend/pweb/lib/pages/payout_page/send/widgets/back_button.dart similarity index 100% rename from frontend/pweb/lib/pages/payment_methods/payment_page/back_button.dart rename to frontend/pweb/lib/pages/payout_page/send/widgets/back_button.dart diff --git a/frontend/pweb/lib/pages/payment_methods/widgets/card.dart b/frontend/pweb/lib/pages/payout_page/send/widgets/card.dart similarity index 96% rename from frontend/pweb/lib/pages/payment_methods/widgets/card.dart rename to frontend/pweb/lib/pages/payout_page/send/widgets/card.dart index bc6edf2a..50a3c1c5 100644 --- a/frontend/pweb/lib/pages/payment_methods/widgets/card.dart +++ b/frontend/pweb/lib/pages/payout_page/send/widgets/card.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:pshared/models/recipient/recipient.dart'; -import 'package:pweb/pages/payment_methods/widgets/section_title.dart'; +import 'package:pweb/pages/payout_page/send/widgets/section/title.dart'; import 'package:pweb/utils/dimensions.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; diff --git a/frontend/pweb/lib/pages/payment_methods/payment_page/method_selector.dart b/frontend/pweb/lib/pages/payout_page/send/widgets/method_selector.dart similarity index 100% rename from frontend/pweb/lib/pages/payment_methods/payment_page/method_selector.dart rename to frontend/pweb/lib/pages/payout_page/send/widgets/method_selector.dart diff --git a/frontend/pweb/lib/pages/payout_page/send/widgets/payment_info/details_builder.dart b/frontend/pweb/lib/pages/payout_page/send/widgets/payment_info/details_builder.dart new file mode 100644 index 00000000..727475b5 --- /dev/null +++ b/frontend/pweb/lib/pages/payout_page/send/widgets/payment_info/details_builder.dart @@ -0,0 +1,10 @@ +import 'package:pshared/models/recipient/payment_method_draft.dart'; + +import 'package:pweb/utils/payment/label.dart'; + + +String? buildPaymentInfoDetailsText(RecipientMethodDraft entry) { + final method = entry.existing; + if (method == null) return null; + return getPaymentMethodMaskedValue(method); +} diff --git a/frontend/pweb/lib/pages/payout_page/send/widgets/payment_info/header.dart b/frontend/pweb/lib/pages/payout_page/send/widgets/payment_info/header.dart new file mode 100644 index 00000000..208389ad --- /dev/null +++ b/frontend/pweb/lib/pages/payout_page/send/widgets/payment_info/header.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/pages/payout_page/send/widgets/section/title.dart'; +import 'package:pweb/models/state/visibility.dart'; +import 'package:pweb/utils/dimensions.dart'; + + +class PaymentInfoHeader extends StatelessWidget { + final AppDimensions dimensions; + final String title; + final VisibilityState visibility; + + const PaymentInfoHeader({ + super.key, + required this.dimensions, + required this.title, + required this.visibility, + }); + + @override + Widget build(BuildContext context) { + if (visibility != VisibilityState.visible) { + return const SizedBox.shrink(); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SectionTitle(title), + SizedBox(height: dimensions.paddingSmall), + ], + ); + } +} diff --git a/frontend/pweb/lib/pages/payout_page/send/widgets/payment_info/methods_section.dart b/frontend/pweb/lib/pages/payout_page/send/widgets/payment_info/methods_section.dart new file mode 100644 index 00000000..266afcf0 --- /dev/null +++ b/frontend/pweb/lib/pages/payout_page/send/widgets/payment_info/methods_section.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/models/payment/type.dart'; +import 'package:pshared/models/recipient/payment_method_draft.dart'; + +import 'package:pweb/pages/address_book/form/widgets/payment_methods/panel.dart'; +import 'package:pweb/pages/address_book/form/widgets/payment_methods/selector_row.dart'; +import 'package:pweb/pages/payout_page/send/widgets/payment_info/details_builder.dart'; +import 'package:pweb/pages/payout_page/send/widgets/payment_info/header.dart'; +import 'package:pweb/pages/payout_page/send/widgets/payment_info/methods_state.dart'; +import 'package:pweb/models/state/control_state.dart'; +import 'package:pweb/models/state/visibility.dart'; +import 'package:pweb/utils/dimensions.dart'; +import 'package:pweb/utils/payment/availability.dart'; + + +class PaymentInfoMethodsSection extends StatelessWidget { + final AppDimensions dimensions; + final String title; + final VisibilityState titleVisibility; + final String detailsLabel; + final PaymentInfoMethodsState state; + final VoidCallback? onAddMethod; + final VisibilityState paymentDetailsVisibility; + final VoidCallback onTogglePaymentDetails; + final ValueChanged onEntrySelected; + + const PaymentInfoMethodsSection({ + super.key, + required this.dimensions, + required this.title, + required this.titleVisibility, + required this.detailsLabel, + required this.state, + required this.onAddMethod, + required this.paymentDetailsVisibility, + required this.onTogglePaymentDetails, + required this.onEntrySelected, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + PaymentInfoHeader( + dimensions: dimensions, + title: title, + visibility: titleVisibility, + ), + PaymentMethodSelectorRow( + types: state.types, + selectedType: state.selectedType, + selectedIndex: state.hasSelection ? state.selectedIndex : null, + methods: state.methodsMap, + detailsBuilder: buildPaymentInfoDetailsText, + onSelected: _handleSelected, + onAddPressed: onAddMethod, + disabledTypes: disabledPaymentTypes, + ), + if (state.hasSelection) ...[ + SizedBox(height: dimensions.paddingSmall), + Align( + alignment: Alignment.centerRight, + child: TextButton.icon( + onPressed: onTogglePaymentDetails, + icon: Icon( + paymentDetailsVisibility == VisibilityState.visible + ? Icons.expand_less + : Icons.expand_more, + ), + label: Text(detailsLabel), + ), + ), + if (paymentDetailsVisibility == VisibilityState.visible) ...[ + SizedBox(height: dimensions.paddingSmall), + PaymentMethodPanel( + selectedType: state.selectedType, + selectedIndex: state.selectedIndex!, + entries: state.selectedEntries, + onRemove: (_) {}, + onChanged: (_, _) {}, + editState: ControlState.disabled, + deleteVisibility: VisibilityState.hidden, + ), + ], + ], + ], + ); + } + + void _handleSelected(PaymentType type, int index) { + final entries = state.methodsMap[type] ?? const []; + if (index < 0 || index >= entries.length) return; + onEntrySelected(entries[index]); + } +} diff --git a/frontend/pweb/lib/pages/payout_page/send/widgets/payment_info/methods_state.dart b/frontend/pweb/lib/pages/payout_page/send/widgets/payment_info/methods_state.dart new file mode 100644 index 00000000..ddeeb3c8 --- /dev/null +++ b/frontend/pweb/lib/pages/payout_page/send/widgets/payment_info/methods_state.dart @@ -0,0 +1,59 @@ +import 'package:pshared/models/payment/type.dart'; +import 'package:pshared/models/recipient/payment_method_draft.dart'; +import 'package:pshared/provider/payment/flow.dart'; + + +class PaymentInfoMethodsState { + final List types; + final Map> methodsMap; + final PaymentType selectedType; + final List selectedEntries; + final int? selectedIndex; + + const PaymentInfoMethodsState({ + required this.types, + required this.methodsMap, + required this.selectedType, + required this.selectedEntries, + required this.selectedIndex, + }); + + bool get hasSelection => selectedIndex != null && selectedIndex! >= 0; +} + +PaymentInfoMethodsState buildPaymentInfoMethodsState({ + required PaymentFlowProvider flowProvider, + required List types, +}) { + final methods = flowProvider.methodsForRecipient; + final selectedMethod = flowProvider.selectedMethod; + final methodsMap = >{}; + for (final method in methods) { + methodsMap.putIfAbsent(method.type, () => []).add( + RecipientMethodDraft( + type: method.type, + existing: method, + ), + ); + } + + final fallbackType = methods.isNotEmpty + ? methods.first.type + : (types.isNotEmpty ? types.first : PaymentType.bankAccount); + final selectedType = selectedMethod?.type ?? fallbackType; + final selectedEntries = + methodsMap[selectedType] ?? const []; + final selectedIndex = selectedMethod == null + ? null + : selectedEntries.indexWhere( + (entry) => entry.existing?.id == selectedMethod.id, + ); + + return PaymentInfoMethodsState( + types: types, + methodsMap: methodsMap, + selectedType: selectedType, + selectedEntries: selectedEntries, + selectedIndex: selectedIndex, + ); +} diff --git a/frontend/pweb/lib/pages/payout_page/send/widgets/payment_info/no_methods.dart b/frontend/pweb/lib/pages/payout_page/send/widgets/payment_info/no_methods.dart new file mode 100644 index 00000000..1bb751d4 --- /dev/null +++ b/frontend/pweb/lib/pages/payout_page/send/widgets/payment_info/no_methods.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/models/payment/type.dart'; + +import 'package:pweb/pages/address_book/form/widgets/payment_methods/selector_row.dart'; +import 'package:pweb/pages/payout_page/send/widgets/payment_info/header.dart'; +import 'package:pweb/models/state/visibility.dart'; +import 'package:pweb/utils/dimensions.dart'; +import 'package:pweb/utils/payment/availability.dart'; + + +class PaymentInfoNoMethodsSection extends StatelessWidget { + final AppDimensions dimensions; + final String title; + final VisibilityState titleVisibility; + final String emptyMessage; + final List types; + final VoidCallback? onAddMethod; + + const PaymentInfoNoMethodsSection({ + super.key, + required this.dimensions, + required this.title, + required this.titleVisibility, + required this.emptyMessage, + required this.types, + required this.onAddMethod, + }); + + @override + Widget build(BuildContext context) { + final fallbackType = types.isNotEmpty ? types.first : PaymentType.bankAccount; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + PaymentInfoHeader( + dimensions: dimensions, + title: title, + visibility: titleVisibility, + ), + Text(emptyMessage), + if (onAddMethod != null) ...[ + SizedBox(height: dimensions.paddingMedium), + PaymentMethodSelectorRow( + types: types, + selectedType: fallbackType, + selectedIndex: null, + methods: const {}, + onSelected: (_, _) {}, + onAddPressed: onAddMethod, + disabledTypes: disabledPaymentTypes, + ), + ], + ], + ); + } +} diff --git a/frontend/pweb/lib/pages/payout_page/send/widgets/payment_info/no_recipient.dart b/frontend/pweb/lib/pages/payout_page/send/widgets/payment_info/no_recipient.dart new file mode 100644 index 00000000..0ce71c78 --- /dev/null +++ b/frontend/pweb/lib/pages/payout_page/send/widgets/payment_info/no_recipient.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/pages/payout_page/send/widgets/payment_info/header.dart'; +import 'package:pweb/models/state/visibility.dart'; +import 'package:pweb/utils/dimensions.dart'; + + +class PaymentInfoNoRecipientSection extends StatelessWidget { + final AppDimensions dimensions; + final String title; + final VisibilityState titleVisibility; + + const PaymentInfoNoRecipientSection({ + super.key, + required this.dimensions, + required this.title, + required this.titleVisibility, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + PaymentInfoHeader( + dimensions: dimensions, + title: title, + visibility: titleVisibility, + ), + ], + ); + } +} diff --git a/frontend/pweb/lib/pages/payout_page/send/widgets/payment_info/section.dart b/frontend/pweb/lib/pages/payout_page/send/widgets/payment_info/section.dart new file mode 100644 index 00000000..fd4f6399 --- /dev/null +++ b/frontend/pweb/lib/pages/payout_page/send/widgets/payment_info/section.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:pshared/provider/payment/flow.dart'; + +import 'package:pweb/pages/payout_page/send/widgets/payment_info/methods_section.dart'; +import 'package:pweb/pages/payout_page/send/widgets/payment_info/methods_state.dart'; +import 'package:pweb/pages/payout_page/send/widgets/payment_info/no_methods.dart'; +import 'package:pweb/pages/payout_page/send/widgets/payment_info/no_recipient.dart'; +import 'package:pweb/models/state/visibility.dart'; +import 'package:pweb/utils/dimensions.dart'; +import 'package:pweb/utils/payment/availability.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class PaymentInfoSection extends StatelessWidget { + final AppDimensions dimensions; + final VisibilityState titleVisibility; + final VoidCallback? onAddMethod; + final VisibilityState paymentDetailsVisibility; + final VoidCallback onTogglePaymentDetails; + + const PaymentInfoSection({ + super.key, + required this.dimensions, + this.titleVisibility = VisibilityState.visible, + this.onAddMethod, + required this.paymentDetailsVisibility, + required this.onTogglePaymentDetails, + }); + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; + final flowProvider = context.watch(); + + if (!flowProvider.hasRecipient) { + return PaymentInfoNoRecipientSection( + dimensions: dimensions, + title: loc.paymentInfo, + titleVisibility: titleVisibility, + ); + } + + final methods = flowProvider.methodsForRecipient; + final types = visiblePaymentTypes; + + if (methods.isEmpty) { + return PaymentInfoNoMethodsSection( + dimensions: dimensions, + title: loc.paymentInfo, + titleVisibility: titleVisibility, + emptyMessage: loc.recipientNoPaymentDetails, + types: types, + onAddMethod: onAddMethod, + ); + } + + final state = buildPaymentInfoMethodsState( + flowProvider: flowProvider, + types: types, + ); + + return PaymentInfoMethodsSection( + dimensions: dimensions, + title: loc.paymentInfo, + titleVisibility: titleVisibility, + detailsLabel: loc.paymentMethodDetails, + state: state, + onAddMethod: onAddMethod, + paymentDetailsVisibility: paymentDetailsVisibility, + onTogglePaymentDetails: onTogglePaymentDetails, + onEntrySelected: (entry) { + final existing = entry.existing; + if (existing != null) { + flowProvider.selectMethod(existing); + } + }, + ); + } +} diff --git a/frontend/pweb/lib/pages/payout_page/send/widgets/payment_info_section.dart b/frontend/pweb/lib/pages/payout_page/send/widgets/payment_info_section.dart new file mode 100644 index 00000000..e292ad0f --- /dev/null +++ b/frontend/pweb/lib/pages/payout_page/send/widgets/payment_info_section.dart @@ -0,0 +1 @@ +export 'package:pweb/pages/payout_page/send/widgets/payment_info/section.dart'; diff --git a/frontend/pweb/lib/pages/payment_methods/widgets/recipient_section.dart b/frontend/pweb/lib/pages/payout_page/send/widgets/recipient/section.dart similarity index 74% rename from frontend/pweb/lib/pages/payment_methods/widgets/recipient_section.dart rename to frontend/pweb/lib/pages/payout_page/send/widgets/recipient/section.dart index 72458511..ad5f8f0e 100644 --- a/frontend/pweb/lib/pages/payment_methods/widgets/recipient_section.dart +++ b/frontend/pweb/lib/pages/payout_page/send/widgets/recipient/section.dart @@ -4,9 +4,11 @@ import 'package:pshared/models/recipient/recipient.dart'; import 'package:pshared/provider/recipient/provider.dart'; import 'package:pweb/pages/address_book/page/search.dart'; -import 'package:pweb/pages/payment_methods/widgets/card.dart'; -import 'package:pweb/pages/payment_methods/widgets/search.dart'; -import 'package:pweb/pages/payment_methods/widgets/section_title.dart'; +import 'package:pweb/pages/payout_page/send/widgets/card.dart'; +import 'package:pweb/pages/payout_page/send/widgets/search.dart'; +import 'package:pweb/pages/payout_page/send/widgets/section/title.dart'; +import 'package:pweb/pages/payout_page/send/widgets/add_recipient_tile.dart'; +import 'package:pweb/pages/dashboard/payouts/single/address_book/short_list.dart'; import 'package:pweb/utils/dimensions.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; @@ -14,7 +16,6 @@ import 'package:pweb/generated/i18n/app_localizations.dart'; class RecipientSection extends StatelessWidget { final Recipient? recipient; - final Recipient? previousRecipient; final AppDimensions dimensions; final RecipientsProvider recipientProvider; final String searchQuery; @@ -24,11 +25,11 @@ class RecipientSection extends StatelessWidget { final ValueChanged onSearchChanged; final ValueChanged onRecipientSelected; final VoidCallback onRecipientCleared; + final VoidCallback onAddRecipient; const RecipientSection({ super.key, required this.recipient, - required this.previousRecipient, required this.dimensions, required this.recipientProvider, required this.searchQuery, @@ -38,6 +39,7 @@ class RecipientSection extends StatelessWidget { required this.onSearchChanged, required this.onRecipientSelected, required this.onRecipientCleared, + required this.onAddRecipient, }); @override @@ -55,7 +57,6 @@ class RecipientSection extends StatelessWidget { animation: recipientProvider, builder: (context, _) { final hasQuery = searchQuery.isNotEmpty; - final prev = previousRecipient; return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -67,17 +68,6 @@ class RecipientSection extends StatelessWidget { onChanged: onSearchChanged, focusNode: searchFocusNode, ), - if (prev != null) ...[ - SizedBox(height: dimensions.paddingSmall), - ListTile( - dense: true, - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.undo), - title: Text(loc.back), - subtitle: Text(prev.name), - onTap: () => onRecipientSelected(prev), - ), - ], if (hasQuery) ...[ SizedBox(height: dimensions.paddingMedium), RecipientSearchResults( @@ -86,6 +76,16 @@ class RecipientSection extends StatelessWidget { results: filteredRecipients, onRecipientSelected: onRecipientSelected, ), + ] else ...[ + SizedBox(height: dimensions.paddingMedium), + ShortListAddressBookPayout( + recipients: recipientProvider.recipients, + onSelected: onRecipientSelected, + trailing: AddRecipientTile( + label: loc.addRecipient, + onTap: onAddRecipient, + ), + ), ], ], ); diff --git a/frontend/pweb/lib/pages/payout_page/send/widgets/recipient_details_card.dart b/frontend/pweb/lib/pages/payout_page/send/widgets/recipient_details_card.dart new file mode 100644 index 00000000..f5bd0cc3 --- /dev/null +++ b/frontend/pweb/lib/pages/payout_page/send/widgets/recipient_details_card.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/models/recipient/recipient.dart'; +import 'package:pshared/provider/recipient/provider.dart'; + +import 'package:pweb/pages/payout_page/send/widgets/payment_info/section.dart'; +import 'package:pweb/pages/payout_page/send/widgets/recipient/section.dart'; +import 'package:pweb/pages/payout_page/send/widgets/section/card.dart'; +import 'package:pweb/utils/dimensions.dart'; +import 'package:pweb/models/state/visibility.dart'; + + +class PaymentRecipientDetailsCard extends StatelessWidget { + final AppDimensions dimensions; + final Recipient? recipient; + final RecipientsProvider recipientProvider; + final String searchQuery; + final List filteredRecipients; + final TextEditingController searchController; + final FocusNode searchFocusNode; + final ValueChanged onSearchChanged; + final ValueChanged onRecipientSelected; + final VoidCallback onRecipientCleared; + final VoidCallback onAddRecipient; + final VoidCallback onAddPaymentMethod; + final VisibilityState paymentDetailsVisibility; + final VoidCallback onTogglePaymentDetails; + + const PaymentRecipientDetailsCard({ + super.key, + required this.dimensions, + required this.recipient, + required this.recipientProvider, + required this.searchQuery, + required this.filteredRecipients, + required this.searchController, + required this.searchFocusNode, + required this.onSearchChanged, + required this.onRecipientSelected, + required this.onRecipientCleared, + required this.onAddRecipient, + required this.onAddPaymentMethod, + required this.paymentDetailsVisibility, + required this.onTogglePaymentDetails, + }); + + @override + Widget build(BuildContext context) { + return PaymentSectionCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + RecipientSection( + recipient: recipient, + dimensions: dimensions, + recipientProvider: recipientProvider, + searchQuery: searchQuery, + filteredRecipients: filteredRecipients, + searchController: searchController, + searchFocusNode: searchFocusNode, + onSearchChanged: onSearchChanged, + onRecipientSelected: onRecipientSelected, + onRecipientCleared: onRecipientCleared, + onAddRecipient: onAddRecipient, + ), + SizedBox(height: dimensions.paddingMedium), + PaymentInfoSection( + dimensions: dimensions, + titleVisibility: VisibilityState.hidden, + onAddMethod: onAddPaymentMethod, + paymentDetailsVisibility: paymentDetailsVisibility, + onTogglePaymentDetails: onTogglePaymentDetails, + ), + ], + ), + ); + } +} diff --git a/frontend/pweb/lib/pages/payment_methods/widgets/search.dart b/frontend/pweb/lib/pages/payout_page/send/widgets/search.dart similarity index 100% rename from frontend/pweb/lib/pages/payment_methods/widgets/search.dart rename to frontend/pweb/lib/pages/payout_page/send/widgets/search.dart diff --git a/frontend/pweb/lib/pages/payout_page/send/widgets/section/card.dart b/frontend/pweb/lib/pages/payout_page/send/widgets/section/card.dart new file mode 100644 index 00000000..5553776c --- /dev/null +++ b/frontend/pweb/lib/pages/payout_page/send/widgets/section/card.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/utils/dimensions.dart'; + + +class PaymentSectionCard extends StatelessWidget { + final Widget child; + final EdgeInsetsGeometry? padding; + + const PaymentSectionCard({ + super.key, + required this.child, + this.padding, + }); + + @override + Widget build(BuildContext context) { + final dimensions = AppDimensions(); + final theme = Theme.of(context); + + return Material( + elevation: dimensions.elevationSmall, + borderRadius: BorderRadius.circular(dimensions.borderRadiusMedium), + color: theme.colorScheme.onSecondary, + clipBehavior: Clip.antiAlias, + child: Padding( + padding: padding ?? EdgeInsets.all(dimensions.paddingLarge), + child: child, + ), + ); + } +} diff --git a/frontend/pweb/lib/pages/payment_methods/widgets/section_title.dart b/frontend/pweb/lib/pages/payout_page/send/widgets/section/title.dart similarity index 100% rename from frontend/pweb/lib/pages/payment_methods/widgets/section_title.dart rename to frontend/pweb/lib/pages/payout_page/send/widgets/section/title.dart diff --git a/frontend/pweb/lib/pages/payment_methods/payment_page/send_button.dart b/frontend/pweb/lib/pages/payout_page/send/widgets/send_button.dart similarity index 97% rename from frontend/pweb/lib/pages/payment_methods/payment_page/send_button.dart rename to frontend/pweb/lib/pages/payout_page/send/widgets/send_button.dart index a5542fd6..31bb230d 100644 --- a/frontend/pweb/lib/pages/payment_methods/payment_page/send_button.dart +++ b/frontend/pweb/lib/pages/payout_page/send/widgets/send_button.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:pweb/models/control_state.dart'; +import 'package:pweb/models/state/control_state.dart'; import 'package:pweb/utils/dimensions.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; diff --git a/frontend/pweb/lib/pages/payout_page/send/widgets/send_card.dart b/frontend/pweb/lib/pages/payout_page/send/widgets/send_card.dart new file mode 100644 index 00000000..e5e0fc51 --- /dev/null +++ b/frontend/pweb/lib/pages/payout_page/send/widgets/send_card.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/pages/dashboard/payouts/form.dart'; +import 'package:pweb/pages/payout_page/send/widgets/send_button.dart'; +import 'package:pweb/pages/payout_page/send/widgets/section/card.dart'; +import 'package:pweb/utils/dimensions.dart'; +import 'package:pweb/widgets/cooldown_hint.dart'; +import 'package:pweb/models/state/control_state.dart'; + + +class PaymentSendCard extends StatelessWidget { + final AppDimensions dimensions; + final ControlState sendState; + final int cooldownRemainingSeconds; + final VoidCallback onSend; + + const PaymentSendCard({ + super.key, + required this.dimensions, + required this.sendState, + required this.cooldownRemainingSeconds, + required this.onSend, + }); + + @override + Widget build(BuildContext context) { + return PaymentSectionCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const PaymentFormWidget(), + SizedBox(height: dimensions.paddingXLarge), + Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SendButton( + onPressed: onSend, + state: sendState, + ), + if (sendState == ControlState.disabled && + cooldownRemainingSeconds > 0) ...[ + SizedBox(height: dimensions.paddingSmall), + CooldownHint(seconds: cooldownRemainingSeconds), + ], + ], + ), + ], + ), + ); + } +} diff --git a/frontend/pweb/lib/pages/payout_page/send/widgets/source_of_funds_card.dart b/frontend/pweb/lib/pages/payout_page/send/widgets/source_of_funds_card.dart new file mode 100644 index 00000000..8eeaf440 --- /dev/null +++ b/frontend/pweb/lib/pages/payout_page/send/widgets/source_of_funds_card.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:pshared/controllers/balance_mask/wallets.dart'; +import 'package:pshared/models/payment/wallet.dart'; + +import 'package:pweb/pages/payout_page/send/widgets/method_selector.dart'; +import 'package:pweb/pages/payout_page/send/widgets/section/title.dart'; +import 'package:pweb/pages/payout_page/send/widgets/section/card.dart'; +import 'package:pweb/utils/dimensions.dart'; +import 'package:pweb/widgets/refresh_balance/wallet.dart'; + + +class PaymentSourceOfFundsCard extends StatelessWidget { + final AppDimensions dimensions; + final String title; + final ValueChanged onWalletSelected; + + const PaymentSourceOfFundsCard({ + super.key, + required this.dimensions, + required this.title, + required this.onWalletSelected, + }); + + @override + Widget build(BuildContext context) { + return PaymentSectionCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded(child: SectionTitle(title)), + Consumer( + builder: (context, provider, _) { + final selectedWalletId = provider.selectedWallet?.id; + if (selectedWalletId == null) { + return const SizedBox.shrink(); + } + return WalletBalanceRefreshButton(walletRef: selectedWalletId); + }, + ), + ], + ), + SizedBox(height: dimensions.paddingSmall), + PaymentMethodSelector( + onMethodChanged: onWalletSelected, + ), + ], + ), + ); + } +} diff --git a/frontend/pweb/lib/pages/payment_methods/widgets/state_view.dart b/frontend/pweb/lib/pages/payout_page/send/widgets/state_view.dart similarity index 100% rename from frontend/pweb/lib/pages/payment_methods/widgets/state_view.dart rename to frontend/pweb/lib/pages/payout_page/send/widgets/state_view.dart diff --git a/frontend/pweb/lib/pages/payout_page/wallet/card.dart b/frontend/pweb/lib/pages/payout_page/wallet/card.dart index 4c69ae19..dfb20f57 100644 --- a/frontend/pweb/lib/pages/payout_page/wallet/card.dart +++ b/frontend/pweb/lib/pages/payout_page/wallet/card.dart @@ -6,7 +6,7 @@ import 'package:pshared/controllers/balance_mask/wallets.dart'; import 'package:pshared/utils/currency.dart'; import 'package:pshared/models/payment/wallet.dart'; -import 'package:pweb/models/visibility.dart'; +import 'package:pweb/models/state/visibility.dart'; import 'package:pweb/pages/dashboard/buttons/balance/amount.dart'; import 'package:pweb/widgets/refresh_balance/wallet.dart'; diff --git a/frontend/pweb/lib/pages/payout_page/wallet/edit/buttons/send.dart b/frontend/pweb/lib/pages/payout_page/wallet/edit/buttons/send.dart index 29d64956..819c2e42 100644 --- a/frontend/pweb/lib/pages/payout_page/wallet/edit/buttons/send.dart +++ b/frontend/pweb/lib/pages/payout_page/wallet/edit/buttons/send.dart @@ -1,12 +1,13 @@ import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + import 'package:provider/provider.dart'; import 'package:pshared/controllers/balance_mask/wallets.dart'; import 'package:pshared/models/payment/type.dart'; import 'package:pweb/app/router/payout_routes.dart'; -import 'package:pweb/widgets/sidebar/destinations.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; @@ -27,9 +28,11 @@ class SendPayoutButton extends StatelessWidget { final wallet = wallets.selectedWallet; if (wallet != null) { - context.pushToPayment( - paymentType: PaymentType.wallet, - returnTo: PayoutDestination.editwallet, + context.pushNamed( + PayoutRoutes.payment, + queryParameters: PayoutRoutes.buildQueryParameters( + paymentType: PaymentType.wallet, + ), ); } }, diff --git a/frontend/pweb/lib/pages/payout_page/wallet/edit/buttons/top_up.dart b/frontend/pweb/lib/pages/payout_page/wallet/edit/buttons/top_up.dart index f7573dad..618075eb 100644 --- a/frontend/pweb/lib/pages/payout_page/wallet/edit/buttons/top_up.dart +++ b/frontend/pweb/lib/pages/payout_page/wallet/edit/buttons/top_up.dart @@ -5,7 +5,6 @@ import 'package:provider/provider.dart'; import 'package:pshared/controllers/balance_mask/wallets.dart'; import 'package:pweb/app/router/payout_routes.dart'; -import 'package:pweb/widgets/sidebar/destinations.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; @@ -29,7 +28,7 @@ class TopUpButton extends StatelessWidget{ ); return; } - context.pushToWalletTopUp(returnTo: PayoutDestination.editwallet); + context.pushToWalletTopUp(); }, child: Text(loc.topUpBalance), ); diff --git a/frontend/pweb/lib/pages/payout_page/wallet/history/chip.dart b/frontend/pweb/lib/pages/payout_page/wallet/history/chip.dart index 98be3df6..273f0ea9 100644 --- a/frontend/pweb/lib/pages/payout_page/wallet/history/chip.dart +++ b/frontend/pweb/lib/pages/payout_page/wallet/history/chip.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; -import 'package:pweb/models/wallet_transaction.dart'; +import 'package:pweb/models/wallet/wallet_transaction.dart'; class TypeChip extends StatelessWidget { diff --git a/frontend/pweb/lib/pages/payout_page/wallet/history/filters.dart b/frontend/pweb/lib/pages/payout_page/wallet/history/filters.dart index 7b80d4f1..c689618b 100644 --- a/frontend/pweb/lib/pages/payout_page/wallet/history/filters.dart +++ b/frontend/pweb/lib/pages/payout_page/wallet/history/filters.dart @@ -3,14 +3,14 @@ import 'package:flutter/material.dart'; import 'package:pshared/models/payment/status.dart'; import 'package:pshared/utils/localization.dart'; -import 'package:pweb/models/wallet_transaction.dart'; -import 'package:pweb/providers/wallet_transactions.dart'; +import 'package:pweb/models/wallet/wallet_transaction.dart'; +import 'package:pweb/controllers/operations/wallet_transactions.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; class WalletHistoryFilters extends StatelessWidget { - final WalletTransactionsProvider provider; + final WalletTransactionsController provider; final VoidCallback onPickRange; const WalletHistoryFilters({ diff --git a/frontend/pweb/lib/pages/payout_page/wallet/history/history.dart b/frontend/pweb/lib/pages/payout_page/wallet/history/history.dart index 70d79572..db656400 100644 --- a/frontend/pweb/lib/pages/payout_page/wallet/history/history.dart +++ b/frontend/pweb/lib/pages/payout_page/wallet/history/history.dart @@ -6,6 +6,7 @@ import 'package:pshared/models/payment/wallet.dart'; import 'package:pweb/pages/payout_page/wallet/history/filters.dart'; import 'package:pweb/pages/payout_page/wallet/history/table.dart'; +import 'package:pweb/controllers/operations/wallet_transactions.dart'; import 'package:pweb/providers/wallet_transactions.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; @@ -44,7 +45,7 @@ class _WalletHistoryState extends State { } Future _pickRange() async { - final provider = context.read(); + final provider = context.read(); final now = DateTime.now(); final initial = provider.dateRange ?? DateTimeRange( @@ -69,7 +70,7 @@ class _WalletHistoryState extends State { final theme = Theme.of(context); final loc = AppLocalizations.of(context)!; - return Consumer( + return Consumer( builder: (context, provider, child) { if (provider.isLoading) { return const Padding( diff --git a/frontend/pweb/lib/pages/payout_page/wallet/history/table.dart b/frontend/pweb/lib/pages/payout_page/wallet/history/table.dart index 9ae3d256..bd5a6a3d 100644 --- a/frontend/pweb/lib/pages/payout_page/wallet/history/table.dart +++ b/frontend/pweb/lib/pages/payout_page/wallet/history/table.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:pshared/utils/currency.dart'; -import 'package:pweb/models/wallet_transaction.dart'; +import 'package:pweb/models/wallet/wallet_transaction.dart'; import 'package:pweb/pages/payout_page/wallet/history/chip.dart'; import 'package:pweb/pages/report/table/badge.dart'; diff --git a/frontend/pweb/lib/pages/payout_page/wallet/wigets.dart b/frontend/pweb/lib/pages/payout_page/wallet/widget.dart similarity index 100% rename from frontend/pweb/lib/pages/payout_page/wallet/wigets.dart rename to frontend/pweb/lib/pages/payout_page/wallet/widget.dart diff --git a/frontend/pweb/lib/pages/payout_verification/page.dart b/frontend/pweb/lib/pages/payout_verification/page.dart index f1d5576d..41e20f3a 100644 --- a/frontend/pweb/lib/pages/payout_verification/page.dart +++ b/frontend/pweb/lib/pages/payout_verification/page.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'package:pweb/controllers/payout_verification.dart'; +import 'package:pweb/controllers/payouts/payout_verification.dart'; import 'package:pweb/pages/2fa/error_message.dart'; import 'package:pweb/pages/2fa/input.dart'; import 'package:pweb/pages/2fa/prompt.dart'; diff --git a/frontend/pweb/lib/pages/report/cards/list.dart b/frontend/pweb/lib/pages/report/cards/list.dart index bfe3f08d..4daa5c42 100644 --- a/frontend/pweb/lib/pages/report/cards/list.dart +++ b/frontend/pweb/lib/pages/report/cards/list.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:pshared/models/payment/operation.dart'; -import 'package:pweb/models/load_more_state.dart'; +import 'package:pweb/models/state/load_more_state.dart'; import 'package:pweb/pages/report/cards/items.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; diff --git a/frontend/pweb/lib/pages/report/charts/distribution.dart b/frontend/pweb/lib/pages/report/charts/distribution.dart index 68ec0a55..3f90c7c3 100644 --- a/frontend/pweb/lib/pages/report/charts/distribution.dart +++ b/frontend/pweb/lib/pages/report/charts/distribution.dart @@ -5,7 +5,7 @@ import 'package:syncfusion_flutter_charts/charts.dart' hide ChartPoint; import 'package:pshared/models/payment/operation.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; -import 'package:pweb/models/chart_point.dart'; +import 'package:pweb/models/report/chart_point.dart'; class PayoutDistributionChart extends StatelessWidget { diff --git a/frontend/pweb/lib/pages/report/charts/payout_volumes/chart.dart b/frontend/pweb/lib/pages/report/charts/payout_volumes/chart.dart index 78b808e4..92370b53 100644 --- a/frontend/pweb/lib/pages/report/charts/payout_volumes/chart.dart +++ b/frontend/pweb/lib/pages/report/charts/payout_volumes/chart.dart @@ -4,7 +4,7 @@ import 'package:provider/provider.dart'; import 'package:pshared/models/payment/operation.dart'; -import 'package:pweb/controllers/payout_volumes.dart'; +import 'package:pweb/controllers/payouts/payout_volumes.dart'; import 'package:pweb/pages/report/charts/payout_volumes/aggregator.dart'; import 'package:pweb/pages/report/charts/payout_volumes/pie_chart.dart'; import 'package:pweb/pages/report/charts/payout_volumes/range_label.dart'; diff --git a/frontend/pweb/lib/pages/report/charts/payout_volumes/range_picker.dart b/frontend/pweb/lib/pages/report/charts/payout_volumes/range_picker.dart index ba02d155..f3c9e584 100644 --- a/frontend/pweb/lib/pages/report/charts/payout_volumes/range_picker.dart +++ b/frontend/pweb/lib/pages/report/charts/payout_volumes/range_picker.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:pweb/controllers/payout_volumes.dart'; +import 'package:pweb/controllers/payouts/payout_volumes.dart'; Future pickPayoutVolumesRange( diff --git a/frontend/pweb/lib/pages/report/charts/status.dart b/frontend/pweb/lib/pages/report/charts/status.dart index c96e0503..790f9ae2 100644 --- a/frontend/pweb/lib/pages/report/charts/status.dart +++ b/frontend/pweb/lib/pages/report/charts/status.dart @@ -7,7 +7,7 @@ import 'package:syncfusion_flutter_charts/charts.dart' hide ChartPoint; import 'package:pshared/models/payment/status.dart'; import 'package:pshared/models/payment/operation.dart'; -import 'package:pweb/models/chart_point.dart'; +import 'package:pweb/models/report/chart_point.dart'; class StatusChart extends StatelessWidget { diff --git a/frontend/pweb/lib/pages/report/details/page.dart b/frontend/pweb/lib/pages/report/details/page.dart index 8c8b3977..f7839b9d 100644 --- a/frontend/pweb/lib/pages/report/details/page.dart +++ b/frontend/pweb/lib/pages/report/details/page.dart @@ -7,7 +7,7 @@ import 'package:provider/provider.dart'; import 'package:pshared/provider/payment/payments.dart'; import 'package:pweb/app/router/payout_routes.dart'; -import 'package:pweb/controllers/payment_details.dart'; +import 'package:pweb/controllers/payments/details.dart'; import 'package:pweb/pages/report/details/content.dart'; import 'package:pweb/pages/report/details/states/error.dart'; import 'package:pweb/pages/report/details/states/not_found.dart'; diff --git a/frontend/pweb/lib/pages/report/operations/actions.dart b/frontend/pweb/lib/pages/report/operations/actions.dart new file mode 100644 index 00000000..6a9b7c9c --- /dev/null +++ b/frontend/pweb/lib/pages/report/operations/actions.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/models/payment/operation.dart'; + +import 'package:pweb/controllers/operations/report_operations.dart'; +import 'package:pweb/utils/report/payment_mapper.dart'; +import 'package:pweb/app/router/payout_routes.dart'; + + +Future pickOperationsRange( + BuildContext context, + ReportOperationsController controller, +) async { + final now = DateTime.now(); + final initial = controller.selectedRange ?? + DateTimeRange( + start: now.subtract(const Duration(days: 30)), + end: now, + ); + + final picked = await showDateRangePicker( + context: context, + firstDate: DateTime(2000), + lastDate: now.add(const Duration(days: 1)), + initialDateRange: initial, + ); + + if (picked != null) { + controller.setRange(picked); + } +} + +void openPaymentDetails(BuildContext context, OperationItem operation) { + final paymentId = paymentIdFromOperation(operation); + if (paymentId == null) return; + + context.pushToReportPayment(paymentId); +} diff --git a/frontend/pweb/lib/pages/report/operations/charts_row.dart b/frontend/pweb/lib/pages/report/operations/charts_row.dart new file mode 100644 index 00000000..f14aef02 --- /dev/null +++ b/frontend/pweb/lib/pages/report/operations/charts_row.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/models/payment/operation.dart'; + +import 'package:pweb/pages/report/charts/payout_volumes/chart.dart'; +import 'package:pweb/pages/report/charts/status.dart'; + + +class OperationHistoryChartsRow extends StatelessWidget { + final List operations; + + const OperationHistoryChartsRow({ + super.key, + required this.operations, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 200, + child: Row( + spacing: 16, + children: [ + Expanded( + child: StatusChart(operations: operations), + ), + Expanded( + child: PayoutVolumesChart( + operations: operations, + ), + ), + ], + ), + ); + } +} diff --git a/frontend/pweb/lib/pages/report/operations/content.dart b/frontend/pweb/lib/pages/report/operations/content.dart new file mode 100644 index 00000000..5290af78 --- /dev/null +++ b/frontend/pweb/lib/pages/report/operations/content.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/models/payment/operation.dart'; + +import 'package:pweb/controllers/operations/report_operations.dart'; +import 'package:pweb/models/state/load_more_state.dart'; +import 'package:pweb/pages/report/cards/list.dart'; +import 'package:pweb/pages/report/operations/charts_row.dart'; +import 'package:pweb/pages/report/table/filters.dart'; + + +class OperationHistoryContent extends StatelessWidget { + final ReportOperationsController controller; + final VoidCallback onPickRange; + final ValueChanged onOperationTap; + + const OperationHistoryContent({ + super.key, + required this.controller, + required this.onPickRange, + required this.onOperationTap, + }); + + @override + Widget build(BuildContext context) { + final operations = controller.operations; + final filteredOperations = controller.filteredOperations; + + return Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 16, + children: [ + OperationHistoryChartsRow(operations: operations), + OperationFilters( + selectedRange: controller.selectedRange, + selectedStatuses: controller.selectedStatuses, + onPickRange: onPickRange, + onToggleStatus: controller.toggleStatus, + onClear: controller.clearFilters, + ), + OperationsCardsList( + operations: filteredOperations, + onTap: onOperationTap, + loadMoreState: controller.loadMoreState, + onLoadMore: controller.loadMoreState == LoadMoreState.available + ? () => controller.loadMore() + : null, + ), + ], + ), + ); + } +} diff --git a/frontend/pweb/lib/pages/report/operations/states/error.dart b/frontend/pweb/lib/pages/report/operations/states/error.dart new file mode 100644 index 00000000..ff3b34a1 --- /dev/null +++ b/frontend/pweb/lib/pages/report/operations/states/error.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + + +class OperationHistoryError extends StatelessWidget { + final String message; + final String retryLabel; + final VoidCallback onRetry; + + const OperationHistoryError({ + super.key, + required this.message, + required this.retryLabel, + required this.onRetry, + }); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(message), + ElevatedButton( + onPressed: onRetry, + child: Text(retryLabel), + ), + ], + ), + ); + } +} diff --git a/frontend/pweb/lib/pages/report/operations/states/loading.dart b/frontend/pweb/lib/pages/report/operations/states/loading.dart new file mode 100644 index 00000000..d791d507 --- /dev/null +++ b/frontend/pweb/lib/pages/report/operations/states/loading.dart @@ -0,0 +1,11 @@ +import 'package:flutter/material.dart'; + + +class OperationHistoryLoading extends StatelessWidget { + const OperationHistoryLoading({super.key}); + + @override + Widget build(BuildContext context) { + return const Center(child: CircularProgressIndicator()); + } +} diff --git a/frontend/pweb/lib/pages/report/operations/view.dart b/frontend/pweb/lib/pages/report/operations/view.dart new file mode 100644 index 00000000..3bbde5c3 --- /dev/null +++ b/frontend/pweb/lib/pages/report/operations/view.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:pweb/controllers/operations/report_operations.dart'; +import 'package:pweb/pages/report/operations/actions.dart'; +import 'package:pweb/pages/report/operations/content.dart'; +import 'package:pweb/pages/report/operations/states/error.dart'; +import 'package:pweb/pages/report/operations/states/loading.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class OperationHistoryView extends StatelessWidget { + const OperationHistoryView({super.key}); + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; + return Consumer( + builder: (context, controller, child) { + if (controller.isLoading) { + return const OperationHistoryLoading(); + } + + if (controller.error != null) { + final message = controller.error?.toString() ?? loc.noErrorInformation; + return OperationHistoryError( + message: loc.notificationError(message), + retryLabel: loc.retry, + onRetry: controller.refresh, + ); + } + + return OperationHistoryContent( + controller: controller, + onPickRange: () => pickOperationsRange(context, controller), + onOperationTap: (operation) => + openPaymentDetails(context, operation), + ); + }, + ); + } +} diff --git a/frontend/pweb/lib/pages/report/page.dart b/frontend/pweb/lib/pages/report/page.dart index b36e30a4..df9451c2 100644 --- a/frontend/pweb/lib/pages/report/page.dart +++ b/frontend/pweb/lib/pages/report/page.dart @@ -2,19 +2,10 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'package:pshared/models/payment/operation.dart'; import 'package:pshared/provider/payment/payments.dart'; -import 'package:pweb/pages/report/cards/list.dart'; -import 'package:pweb/pages/report/charts/payout_volumes/chart.dart'; -import 'package:pweb/pages/report/charts/status.dart'; -import 'package:pweb/controllers/report_operations.dart'; -import 'package:pweb/pages/report/table/filters.dart'; -import 'package:pweb/utils/report/payment_mapper.dart'; -import 'package:pweb/app/router/payout_routes.dart'; -import 'package:pweb/models/load_more_state.dart'; - -import 'package:pweb/generated/i18n/app_localizations.dart'; +import 'package:pweb/controllers/operations/report_operations.dart'; +import 'package:pweb/pages/report/operations/view.dart'; class OperationHistoryPage extends StatelessWidget { @@ -25,116 +16,7 @@ class OperationHistoryPage extends StatelessWidget { return ChangeNotifierProxyProvider( create: (_) => ReportOperationsController(), update: (_, payments, controller) => controller!..update(payments), - child: const _OperationHistoryView(), - ); - } -} - -class _OperationHistoryView extends StatelessWidget { - const _OperationHistoryView(); - - Future _pickRange( - BuildContext context, - ReportOperationsController controller, - ) async { - final now = DateTime.now(); - final initial = controller.selectedRange ?? - DateTimeRange( - start: now.subtract(const Duration(days: 30)), - end: now, - ); - - final picked = await showDateRangePicker( - context: context, - firstDate: DateTime(2000), - lastDate: now.add(const Duration(days: 1)), - initialDateRange: initial, - ); - - if (picked != null) { - controller.setRange(picked); - } - } - - void _openPaymentDetails(BuildContext context, OperationItem operation) { - final paymentId = paymentIdFromOperation(operation); - if (paymentId == null) return; - - context.pushToReportPayment(paymentId); - } - - @override - Widget build(BuildContext context) { - final loc = AppLocalizations.of(context)!; - return Consumer( - builder: (context, controller, child) { - if (controller.isLoading) { - return const Center(child: CircularProgressIndicator()); - } - - if (controller.error != null) { - final message = - controller.error?.toString() ?? loc.noErrorInformation; - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text(loc.notificationError(message)), - ElevatedButton( - onPressed: () => controller.refresh(), - child: Text(loc.retry), - ), - ], - ), - ); - } - - final operations = controller.operations; - final filteredOperations = controller.filteredOperations; - - return Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - spacing: 16, - children: [ - //TODO Make charts more useful and re-enable - SizedBox( - height: 200, - child: Row( - spacing: 16, - children: [ - Expanded( - child: StatusChart(operations: operations), - ), - Expanded( - child: PayoutVolumesChart( - operations: operations, - ), - ), - ], - ), - ), - OperationFilters( - selectedRange: controller.selectedRange, - selectedStatuses: controller.selectedStatuses, - onPickRange: () => _pickRange(context, controller), - onToggleStatus: controller.toggleStatus, - onClear: controller.clearFilters, - ), - OperationsCardsList( - operations: filteredOperations, - onTap: (operation) => _openPaymentDetails(context, operation), - loadMoreState: controller.loadMoreState, - onLoadMore: controller.loadMoreState == - LoadMoreState.available - ? () => controller.loadMore() - : null, - ), - ], - ), - ); - }, + child: const OperationHistoryView(), ); } } diff --git a/frontend/pweb/lib/pages/settings/profile/account/locale.dart b/frontend/pweb/lib/pages/settings/profile/account/locale.dart index e1741eb3..93618dcf 100644 --- a/frontend/pweb/lib/pages/settings/profile/account/locale.dart +++ b/frontend/pweb/lib/pages/settings/profile/account/locale.dart @@ -1,13 +1,9 @@ -import 'dart:async'; - import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:pshared/provider/locale.dart'; -import 'package:pweb/services/posthog.dart'; - import 'package:pweb/generated/i18n/app_localizations.dart'; @@ -60,7 +56,6 @@ class LocalePicker extends StatelessWidget { onChanged: (locale) { if (locale != null) { localeProvider.setLocale(locale); - unawaited(PosthogService.localeChanged(locale)); } }, decoration: const InputDecoration( diff --git a/frontend/pweb/lib/pages/settings/profile/account/name/actions.dart b/frontend/pweb/lib/pages/settings/profile/account/name/actions.dart index 68e6dc5b..c36b275e 100644 --- a/frontend/pweb/lib/pages/settings/profile/account/name/actions.dart +++ b/frontend/pweb/lib/pages/settings/profile/account/name/actions.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'package:pweb/providers/account_name.dart'; +import 'package:pweb/controllers/auth/account_name.dart'; class AccountNameActions extends StatelessWidget { @@ -10,7 +10,7 @@ class AccountNameActions extends StatelessWidget { @override Widget build(BuildContext context) { - final state = context.watch(); + final state = context.watch(); final theme = Theme.of(context); if (state.isEditing) { diff --git a/frontend/pweb/lib/pages/settings/profile/account/name/editing.dart b/frontend/pweb/lib/pages/settings/profile/account/name/editing.dart index 57046f70..f4bb87d4 100644 --- a/frontend/pweb/lib/pages/settings/profile/account/name/editing.dart +++ b/frontend/pweb/lib/pages/settings/profile/account/name/editing.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:pweb/providers/account_name.dart'; +import 'package:pweb/controllers/auth/account_name.dart'; class AccountNameEditingFields extends StatelessWidget { @@ -17,7 +17,7 @@ class AccountNameEditingFields extends StatelessWidget { final String lastNameHint; final double inputWidth; final double borderWidth; - final AccountNameState state; + final AccountNameController state; @override Widget build(BuildContext context) { diff --git a/frontend/pweb/lib/pages/settings/profile/account/name/name.dart b/frontend/pweb/lib/pages/settings/profile/account/name/name.dart index f0e8288f..b8eb404a 100644 --- a/frontend/pweb/lib/pages/settings/profile/account/name/name.dart +++ b/frontend/pweb/lib/pages/settings/profile/account/name/name.dart @@ -4,8 +4,8 @@ import 'package:provider/provider.dart'; import 'package:pshared/provider/account.dart'; +import 'package:pweb/controllers/auth/account_name.dart'; import 'package:pweb/pages/settings/profile/account/name/actions.dart'; -import 'package:pweb/providers/account_name.dart'; import 'package:pweb/pages/settings/profile/account/name/text.dart'; @@ -36,13 +36,14 @@ class AccountName extends StatelessWidget { @override Widget build(BuildContext context) { - return ChangeNotifierProvider( - create: (ctx) => AccountNameState( + return ChangeNotifierProxyProvider( + create: (_) => AccountNameController( initialFirstName: firstName, initialLastName: lastName, errorMessage: errorText, - accountProvider: ctx.read(), ), + update: (_, accountProvider, controller) => + controller!..update(accountProvider), child: _AccountNameBody( hintText: hintText, lastNameHint: lastNameHint, @@ -62,14 +63,9 @@ class _AccountNameBody extends StatelessWidget { @override Widget build(BuildContext context) { - final state = context.watch(); - final provider = context.watch(); + final state = context.watch(); final theme = Theme.of(context); - final currentFirstName = provider.account?.name ?? state.initialFirstName; - final currentLastName = provider.account?.lastName ?? state.initialLastName; - state.syncNames(currentFirstName, currentLastName); - return Column( mainAxisSize: MainAxisSize.min, children: [ diff --git a/frontend/pweb/lib/pages/settings/profile/account/name/text.dart b/frontend/pweb/lib/pages/settings/profile/account/name/text.dart index e27ac2f2..ba4d75c4 100644 --- a/frontend/pweb/lib/pages/settings/profile/account/name/text.dart +++ b/frontend/pweb/lib/pages/settings/profile/account/name/text.dart @@ -2,9 +2,9 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'package:pweb/controllers/auth/account_name.dart'; import 'package:pweb/pages/settings/profile/account/name/editing.dart'; import 'package:pweb/pages/settings/profile/account/name/text_view.dart'; -import 'package:pweb/providers/account_name.dart'; class AccountNameText extends StatelessWidget { @@ -23,7 +23,7 @@ class AccountNameText extends StatelessWidget { @override Widget build(BuildContext context) { - final state = context.watch(); + final state = context.watch(); if (state.isEditing) { return AccountNameEditingFields( diff --git a/frontend/pweb/lib/pages/settings/profile/account/password/content.dart b/frontend/pweb/lib/pages/settings/profile/account/password/content.dart index 777bd439..72d16f0e 100644 --- a/frontend/pweb/lib/pages/settings/profile/account/password/content.dart +++ b/frontend/pweb/lib/pages/settings/profile/account/password/content.dart @@ -4,8 +4,8 @@ import 'package:provider/provider.dart'; import 'package:pshared/provider/account.dart'; +import 'package:pweb/controllers/auth/password_form.dart'; import 'package:pweb/pages/settings/profile/account/password/form/form.dart'; -import 'package:pweb/providers/password_form.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; @@ -35,7 +35,7 @@ class AccountPasswordContent extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); - return Consumer2( + return Consumer2( builder: (context, accountProvider, formProvider, _) { final isBusy = accountProvider.isLoading || formProvider.isSaving; diff --git a/frontend/pweb/lib/pages/settings/profile/account/password/form/form.dart b/frontend/pweb/lib/pages/settings/profile/account/password/form/form.dart index ebd4d2c5..46311d24 100644 --- a/frontend/pweb/lib/pages/settings/profile/account/password/form/form.dart +++ b/frontend/pweb/lib/pages/settings/profile/account/password/form/form.dart @@ -4,8 +4,8 @@ import 'package:pshared/provider/account.dart'; import 'package:pshared/widgets/password/fields.dart'; import 'package:pshared/utils/snackbar.dart'; -import 'package:pweb/models/password_field_type.dart'; -import 'package:pweb/providers/password_form.dart'; +import 'package:pweb/models/auth/password_field_type.dart'; +import 'package:pweb/controllers/auth/password_form.dart'; import 'package:pweb/pages/settings/profile/account/password/form/error_text.dart'; import 'package:pweb/pages/settings/profile/account/password/form/submit_button.dart'; import 'package:pweb/utils/error/snackbar.dart'; @@ -32,7 +32,7 @@ class PasswordForm extends StatelessWidget { static const double _gapMedium = 12; static const double _gapSmall = 8; - final PasswordFormProvider formProvider; + final PasswordFormController formProvider; final AccountProvider accountProvider; final bool isBusy; final String oldPasswordLabel; diff --git a/frontend/pweb/lib/pages/settings/profile/account/password/password.dart b/frontend/pweb/lib/pages/settings/profile/account/password/password.dart index a898a2d4..5b66adb2 100644 --- a/frontend/pweb/lib/pages/settings/profile/account/password/password.dart +++ b/frontend/pweb/lib/pages/settings/profile/account/password/password.dart @@ -2,8 +2,8 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'package:pweb/controllers/auth/password_form.dart'; import 'package:pweb/pages/settings/profile/account/password/content.dart'; -import 'package:pweb/providers/password_form.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; @@ -33,7 +33,7 @@ class AccountPassword extends StatelessWidget { final loc = AppLocalizations.of(context)!; return ChangeNotifierProvider( - create: (_) => PasswordFormProvider(), + create: (_) => PasswordFormController(), child: AccountPasswordContent( title: title, successText: successText, @@ -46,4 +46,4 @@ class AccountPassword extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/frontend/pweb/lib/pages/settings/widgets/base.dart b/frontend/pweb/lib/pages/settings/widgets/base.dart index 51d5c8a8..87cc0fd7 100644 --- a/frontend/pweb/lib/pages/settings/widgets/base.dart +++ b/frontend/pweb/lib/pages/settings/widgets/base.dart @@ -4,7 +4,7 @@ import 'package:provider/provider.dart'; import 'package:flutter_settings_ui/flutter_settings_ui.dart'; -import 'package:pweb/models/edit_state.dart'; +import 'package:pweb/models/state/edit_state.dart'; import 'package:pweb/utils/error/snackbar.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; diff --git a/frontend/pweb/lib/pages/signup/confirmation/card/card.dart b/frontend/pweb/lib/pages/signup/confirmation/card/card.dart index 8a784cdf..dd6a9b88 100644 --- a/frontend/pweb/lib/pages/signup/confirmation/card/card.dart +++ b/frontend/pweb/lib/pages/signup/confirmation/card/card.dart @@ -3,8 +3,8 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:pshared/provider/account.dart'; -import 'package:pweb/models/resend/action_result.dart'; -import 'package:pweb/models/resend/avaliability.dart'; +import 'package:pweb/models/auth/resend/action_result.dart'; +import 'package:pweb/models/auth/resend/avaliability.dart'; import 'package:pweb/utils/snackbar.dart'; import 'package:pweb/utils/error/snackbar.dart'; diff --git a/frontend/pweb/lib/pages/signup/confirmation/page.dart b/frontend/pweb/lib/pages/signup/confirmation/page.dart index 523cbed4..7a6b1211 100644 --- a/frontend/pweb/lib/pages/signup/confirmation/page.dart +++ b/frontend/pweb/lib/pages/signup/confirmation/page.dart @@ -7,7 +7,7 @@ import 'package:pshared/provider/account.dart'; import 'package:pweb/app/router/pages.dart'; import 'package:pweb/pages/login/app_bar.dart'; import 'package:pweb/pages/signup/confirmation/card/card.dart'; -import 'package:pweb/controllers/signup/confirmation.dart'; +import 'package:pweb/providers/signup_confirmation.dart'; import 'package:pweb/pages/with_footer.dart'; @@ -26,12 +26,12 @@ class SignUpConfirmationPage extends StatefulWidget { } class _SignUpConfirmationPageState extends State { - late final SignupConfirmationController _controller; + late final SignupConfirmationProvider _provider; @override void initState() { super.initState(); - _controller = SignupConfirmationController( + _provider = SignupConfirmationProvider( accountProvider: context.read(), )..addListener(_handleAuthorizationStatus); WidgetsBinding.instance.addPostFrameCallback((_) => _startPolling()); @@ -39,8 +39,8 @@ class _SignUpConfirmationPageState extends State { @override void dispose() { - _controller.removeListener(_handleAuthorizationStatus); - _controller.dispose(); + _provider.removeListener(_handleAuthorizationStatus); + _provider.dispose(); super.dispose(); } @@ -51,7 +51,7 @@ class _SignUpConfirmationPageState extends State { if (email == null || email.isEmpty || password == null || password.isEmpty) { return; } - _controller.startPolling( + _provider.startPolling( email: email, password: password, locale: Localizations.localeOf(context).toLanguageTag(), @@ -59,7 +59,7 @@ class _SignUpConfirmationPageState extends State { } void _handleAuthorizationStatus() { - if (!_controller.isAuthorized || !mounted) return; + if (!_provider.isAuthorized || !mounted) return; navigateAndReplace(context, Pages.login); } diff --git a/frontend/pweb/lib/pages/signup/form/controllers.dart b/frontend/pweb/lib/pages/signup/form/controllers.dart index a401f510..80d2e96b 100644 --- a/frontend/pweb/lib/pages/signup/form/controllers.dart +++ b/frontend/pweb/lib/pages/signup/form/controllers.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:pweb/controllers/email.dart'; +import 'package:pweb/controllers/auth/email.dart'; class SignUpFormControllers { diff --git a/frontend/pweb/lib/pages/verification/controller.dart b/frontend/pweb/lib/pages/verification/controller.dart index 5d009e06..58f5cfc2 100644 --- a/frontend/pweb/lib/pages/verification/controller.dart +++ b/frontend/pweb/lib/pages/verification/controller.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import 'package:pshared/provider/account.dart'; import 'package:pshared/provider/email_verification.dart'; -import 'package:pweb/models/flow_status.dart'; +import 'package:pweb/models/state/flow_status.dart'; class AccountVerificationController extends ChangeNotifier { diff --git a/frontend/pweb/lib/providers/account.dart b/frontend/pweb/lib/providers/account.dart index 9a220c05..5f1defa9 100644 --- a/frontend/pweb/lib/providers/account.dart +++ b/frontend/pweb/lib/providers/account.dart @@ -1,12 +1,28 @@ import 'dart:async'; import 'package:pshared/models/account/account.dart'; +import 'package:pshared/models/auth/login_outcome.dart'; import 'package:pshared/provider/account.dart'; import 'package:pweb/services/posthog.dart'; class PwebAccountProvider extends AccountProvider { + @override + Future login({ + required String email, + required String password, + required String locale, + }) async { + final outcome = await super.login( + email: email, + password: password, + locale: locale, + ); + unawaited(PosthogService.login(pending: outcome.isPending)); + return outcome; + } + @override Future onAccountChanged(Account? previous, Account? current) { if (current != null) { diff --git a/frontend/pweb/lib/providers/address_book_recipient_form.dart b/frontend/pweb/lib/providers/address_book_recipient_form.dart index 73f55247..8104f751 100644 --- a/frontend/pweb/lib/providers/address_book_recipient_form.dart +++ b/frontend/pweb/lib/providers/address_book_recipient_form.dart @@ -2,127 +2,35 @@ import 'package:flutter/foundation.dart'; import 'package:pshared/data/mapper/recipient/recipient.dart'; import 'package:pshared/models/describable.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:pshared/provider/recipient/provider.dart'; -import 'package:pweb/models/seed_state.dart'; - class AddressBookRecipientFormProvider extends ChangeNotifier { RecipientMethodsCacheProvider? _methodsCache; RecipientsProvider? _recipientsProvider; final Recipient? _recipient; - final List _supportedTypes; - - final Map> _methods; - SeedState _seedState = SeedState.idle; AddressBookRecipientFormProvider({ - required List supportedTypes, Recipient? recipient, - }) : _recipient = recipient, - _supportedTypes = supportedTypes, - _methods = { - for (final type in supportedTypes) type: [], - }; + }) : _recipient = recipient; void updateProviders({ required RecipientMethodsCacheProvider methodsCache, required RecipientsProvider recipientsProvider, }) { _recipientsProvider = recipientsProvider; - if (identical(_methodsCache, methodsCache)) return; - _methodsCache?.removeListener(_handleCacheChange); _methodsCache = methodsCache; - _methodsCache?.addListener(_handleCacheChange); - _maybeSeedFromCache(); - } - - List get supportedTypes => List.unmodifiable(_supportedTypes); - Map> get methods => { - for (final entry in _methods.entries) - entry.key: List.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), - ); - - List 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 _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 existing) { - if (existing.isEmpty) return; - final next = >{ - for (final type in _supportedTypes) type: [], - }; - for (final method in existing) { - final type = method.type; - final entries = next[type]; - if (entries == null) continue; - entries.add( - RecipientMethodDraft( - type: type, - existing: method, - data: method.data, - ), - ); - } - _methods - ..clear() - ..addAll(next); - notifyListeners(); } Future save({ required String name, required String email, required Map methodNames, + required List methodDrafts, }) async { final recipientsProvider = _recipientsProvider; final methodsCache = _methodsCache; @@ -140,7 +48,7 @@ class AddressBookRecipientFormProvider extends ChangeNotifier { ); await methodsCache.syncRecipientMethods( recipientId: created.id, - methods: allDrafts(), + methods: methodDrafts, names: methodNames, ); return created; @@ -156,15 +64,9 @@ class AddressBookRecipientFormProvider extends ChangeNotifier { await recipientsProvider.update(updated.toDTO().toJson()); await methodsCache.syncRecipientMethods( recipientId: updated.id, - methods: allDrafts(), + methods: methodDrafts, names: methodNames, ); return updated; } - - @override - void dispose() { - _methodsCache?.removeListener(_handleCacheChange); - super.dispose(); - } } diff --git a/frontend/pweb/lib/providers/locale.dart b/frontend/pweb/lib/providers/locale.dart new file mode 100644 index 00000000..c3fef5d4 --- /dev/null +++ b/frontend/pweb/lib/providers/locale.dart @@ -0,0 +1,19 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import 'package:pshared/provider/locale.dart'; + +import 'package:pweb/services/posthog.dart'; + + +class PwebLocaleProvider extends LocaleProvider { + PwebLocaleProvider(super.localeCode); + + @override + void setLocale(Locale locale) { + if (this.locale == locale) return; + super.setLocale(locale); + unawaited(PosthogService.localeChanged(locale)); + } +} diff --git a/frontend/pweb/lib/providers/multiple_payouts.dart b/frontend/pweb/lib/providers/multiple_payouts.dart index 4b5a29cc..ecaa7406 100644 --- a/frontend/pweb/lib/providers/multiple_payouts.dart +++ b/frontend/pweb/lib/providers/multiple_payouts.dart @@ -6,12 +6,11 @@ import 'package:pshared/models/payment/quote/status_type.dart'; import 'package:pshared/models/payment/wallet.dart'; import 'package:pshared/provider/payment/multiple/provider.dart'; import 'package:pshared/provider/payment/multiple/quotation.dart'; -import 'package:pshared/provider/payment/payments.dart'; import 'package:pshared/utils/currency.dart'; import 'package:pshared/utils/money.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/utils/payment/multiple_csv_parser.dart'; import 'package:pweb/utils/payment/multiple_intent_builder.dart'; @@ -22,7 +21,6 @@ class MultiplePayoutsProvider extends ChangeNotifier { MultiQuotationProvider? _quotation; MultiPaymentProvider? _payment; - PaymentsProvider? _payments; MultiplePayoutsState _state = MultiplePayoutsState.idle; String? _selectedFileName; @@ -39,11 +37,9 @@ class MultiplePayoutsProvider extends ChangeNotifier { void update( MultiQuotationProvider quotation, MultiPaymentProvider payment, - PaymentsProvider payments, ) { _bindQuotation(quotation); _payment = payment; - _payments = payments; } MultiplePayoutsState get state => _state; diff --git a/frontend/pweb/lib/providers/operatioins.dart b/frontend/pweb/lib/providers/operatioins.dart deleted file mode 100644 index 08b73f44..00000000 --- a/frontend/pweb/lib/providers/operatioins.dart +++ /dev/null @@ -1,82 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:pshared/models/payment/operation.dart'; -import 'package:pshared/models/payment/status.dart'; - -import 'package:pweb/services/operations.dart'; - - -class OperationProvider extends ChangeNotifier { - final OperationService _service; - - OperationProvider(this._service); - - List _allOperations = []; - List _filteredOperations = []; - DateTimeRange? _dateRange; - final Set _selectedStatuses = {}; - bool _isLoading = false; - String? _error; - - // Getters - List get allOperations => _allOperations; - List get filteredOperations => _filteredOperations; - DateTimeRange? get dateRange => _dateRange; - Set get selectedStatuses => _selectedStatuses; - bool get isLoading => _isLoading; - String? get error => _error; - bool get hasFileName => _allOperations.any((op) => op.fileName != null); - - Future loadOperations() async { - _isLoading = true; - _error = null; - notifyListeners(); - - try { - _allOperations = await _service.fetchOperations(); - _filteredOperations = List.from(_allOperations); - _isLoading = false; - notifyListeners(); - } catch (e) { - _error = e.toString(); - _isLoading = false; - notifyListeners(); - } - } - - void setDateRange(DateTimeRange? range) { - _dateRange = range; - notifyListeners(); - } - - void toggleStatus(String status) { - if (_selectedStatuses.contains(status)) { - _selectedStatuses.remove(status); - } else { - _selectedStatuses.add(status); - } - notifyListeners(); - } - - void applyFilters(BuildContext context) { - _filteredOperations = _allOperations.where((op) { - final statusMatch = _selectedStatuses.isEmpty || - _selectedStatuses.contains(op.status.localized(context)); - - final dateMatch = _dateRange == null || - (op.date.isAfter(_dateRange!.start.subtract(const Duration(seconds: 1))) && - op.date.isBefore(_dateRange!.end.add(const Duration(seconds: 1)))); - - return statusMatch && dateMatch; - }).toList(); - - notifyListeners(); - } - - void resetFilters() { - _dateRange = null; - _selectedStatuses.clear(); - _filteredOperations = List.from(_allOperations); - notifyListeners(); - } -} diff --git a/frontend/pweb/lib/controllers/signup/confirmation.dart b/frontend/pweb/lib/providers/signup_confirmation.dart similarity index 95% rename from frontend/pweb/lib/controllers/signup/confirmation.dart rename to frontend/pweb/lib/providers/signup_confirmation.dart index 6c25c82a..ce0c931f 100644 --- a/frontend/pweb/lib/controllers/signup/confirmation.dart +++ b/frontend/pweb/lib/providers/signup_confirmation.dart @@ -6,8 +6,8 @@ import 'package:pshared/models/auth/probe_result.dart'; import 'package:pshared/provider/account.dart'; -class SignupConfirmationController extends ChangeNotifier { - SignupConfirmationController({ +class SignupConfirmationProvider extends ChangeNotifier { + SignupConfirmationProvider({ required AccountProvider accountProvider, Duration pollInterval = const Duration(seconds: 10), }) : _accountProvider = accountProvider, diff --git a/frontend/pweb/lib/providers/two_factor.dart b/frontend/pweb/lib/providers/two_factor.dart index 309fd4c7..8382bfef 100644 --- a/frontend/pweb/lib/providers/two_factor.dart +++ b/frontend/pweb/lib/providers/two_factor.dart @@ -8,7 +8,7 @@ import 'package:pshared/models/auth/pending_login.dart'; import 'package:pshared/provider/account.dart'; import 'package:pshared/service/verification.dart'; -import 'package:pweb/models/flow_status.dart'; +import 'package:pweb/models/state/flow_status.dart'; class TwoFactorProvider extends ChangeNotifier { diff --git a/frontend/pweb/lib/providers/wallet_transactions.dart b/frontend/pweb/lib/providers/wallet_transactions.dart index 09995320..e9053e1a 100644 --- a/frontend/pweb/lib/providers/wallet_transactions.dart +++ b/frontend/pweb/lib/providers/wallet_transactions.dart @@ -1,7 +1,6 @@ -import 'package:flutter/material.dart'; -import 'package:pshared/models/payment/status.dart'; +import 'package:flutter/foundation.dart'; -import 'package:pweb/models/wallet_transaction.dart'; +import 'package:pweb/models/wallet/wallet_transaction.dart'; import 'package:pweb/services/wallet_transactions.dart'; @@ -10,27 +9,15 @@ class WalletTransactionsProvider extends ChangeNotifier { WalletTransactionsProvider(this._service); - List _transactions = []; - List _filteredTransactions = []; - DateTimeRange? _dateRange; - final Set _selectedStatuses = {}; - final Set _selectedTypes = {}; - String? _walletId; + List _transactions = const []; bool _isLoading = false; String? _error; + String? _walletId; - List get transactions => _transactions; - List get filteredTransactions => _filteredTransactions; - DateTimeRange? get dateRange => _dateRange; - Set get selectedStatuses => _selectedStatuses; - Set get selectedTypes => _selectedTypes; - String? get walletId => _walletId; + List get transactions => List.unmodifiable(_transactions); bool get isLoading => _isLoading; String? get error => _error; - bool get hasFilters => - _dateRange != null || - _selectedStatuses.isNotEmpty || - _selectedTypes.isNotEmpty; + String? get walletId => _walletId; Future load({String? walletId}) async { _isLoading = true; @@ -40,65 +27,11 @@ class WalletTransactionsProvider extends ChangeNotifier { try { _walletId = walletId ?? _walletId; _transactions = await _service.fetchHistory(walletId: _walletId); - _applyFilters(notify: false); - _isLoading = false; - notifyListeners(); } catch (e) { _error = e.toString(); + } finally { _isLoading = false; notifyListeners(); } } - - void setWallet(String walletId) { - _walletId = walletId; - _applyFilters(); - } - - void setDateRange(DateTimeRange? range) { - _dateRange = range; - _applyFilters(); - } - - void toggleStatus(OperationStatus status) { - if (_selectedStatuses.contains(status)) { - _selectedStatuses.remove(status); - } else { - _selectedStatuses.add(status); - } - _applyFilters(); - } - - void toggleType(WalletTransactionType type) { - if (_selectedTypes.contains(type)) { - _selectedTypes.remove(type); - } else { - _selectedTypes.add(type); - } - _applyFilters(); - } - - void resetFilters() { - _dateRange = null; - _selectedStatuses.clear(); - _selectedTypes.clear(); - _applyFilters(); - } - - void _applyFilters({bool notify = true}) { - _filteredTransactions = _transactions.where((tx) { - final walletMatch = _walletId == null || tx.walletId == _walletId; - 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 walletMatch && statusMatch && typeMatch && dateMatch; - }).toList(); - - if (notify) notifyListeners(); - } } diff --git a/frontend/pweb/lib/services/wallet_transactions.dart b/frontend/pweb/lib/services/wallet_transactions.dart index 086bab1c..84b7979b 100644 --- a/frontend/pweb/lib/services/wallet_transactions.dart +++ b/frontend/pweb/lib/services/wallet_transactions.dart @@ -1,7 +1,7 @@ import 'package:pshared/models/payment/status.dart'; import 'package:pshared/models/currency.dart'; -import 'package:pweb/models/wallet_transaction.dart'; +import 'package:pweb/models/wallet/wallet_transaction.dart'; abstract class WalletTransactionsService { diff --git a/frontend/pweb/lib/utils/payment/label.dart b/frontend/pweb/lib/utils/payment/label.dart index 077ea819..5f60595e 100644 --- a/frontend/pweb/lib/utils/payment/label.dart +++ b/frontend/pweb/lib/utils/payment/label.dart @@ -25,21 +25,34 @@ String getPaymentTypeLabel(BuildContext context, PaymentType type) { }; } -String? _displayString(PaymentMethod m) => switch (m.type) { +String? getPaymentMethodMaskedValue(PaymentMethod m) => switch (m.type) { PaymentType.card => maskCardNumber(m.cardData?.pan), PaymentType.cardToken => m.dataAsOrNull()?.maskedPan, + PaymentType.bankAccount => maskAccountEdge(m.bankAccountData?.accountNumber), + PaymentType.iban => maskAccountEdge(m.ibanData?.iban), + PaymentType.wallet => maskAccountEdge(m.walletData?.walletId), + PaymentType.managedWallet => () { + final data = m.dataAsOrNull(); + if (data == null) return null; + return data.asset?.tokenSymbol ?? maskAccountEdge(data.managedWalletRef); + }(), + PaymentType.externalChain => maskAccountEdge(m.cryptoAddressData?.address), + PaymentType.ledger => maskAccountEdge(m.dataAsOrNull()?.ledgerAccountRef), +}; + +String getPaymentTypeDescription(BuildContext context, PaymentMethod m) { + return getPaymentMethodMaskedValue(m) ?? AppLocalizations.of(context)!.notSet; +} + +String? getPaymentMethodAccountId(PaymentMethod m) => _accountIdentifier(m); + +String? _accountIdentifier(PaymentMethod m) => switch (m.type) { + PaymentType.card => m.cardData?.pan, + PaymentType.cardToken => m.dataAsOrNull()?.maskedPan, PaymentType.bankAccount => m.bankAccountData?.accountNumber, PaymentType.iban => m.ibanData?.iban, PaymentType.wallet => m.walletData?.walletId, - PaymentType.managedWallet => () { - final data = m.dataAsOrNull(); - if (data == null) return null; - return data.asset?.tokenSymbol ?? data.managedWalletRef; - }(), + PaymentType.managedWallet => m.dataAsOrNull()?.managedWalletRef, PaymentType.externalChain => m.cryptoAddressData?.address, PaymentType.ledger => m.dataAsOrNull()?.ledgerAccountRef, }; - -String getPaymentTypeDescription(BuildContext context, PaymentMethod m) { - return _displayString(m) ?? AppLocalizations.of(context)!.notSet; -} diff --git a/frontend/pweb/lib/utils/payment/masking.dart b/frontend/pweb/lib/utils/payment/masking.dart index 2e4825b9..a199abb0 100644 --- a/frontend/pweb/lib/utils/payment/masking.dart +++ b/frontend/pweb/lib/utils/payment/masking.dart @@ -23,3 +23,13 @@ String? maskCardNumber(String? pan) { return groups.join(' '); } + +String? maskAccountEdge(String? value, {int head = 2, int tail = 2}) { + if (value == null) return null; + final trimmed = value.replaceAll(RegExp(r'\s+'), ''); + if (trimmed.isEmpty) return null; + if (trimmed.length <= head + tail) return trimmed; + final start = trimmed.substring(0, head); + final end = trimmed.substring(trimmed.length - tail); + return '$start*****$end'; +} diff --git a/frontend/pweb/lib/utils/payment/multiple_csv_parser.dart b/frontend/pweb/lib/utils/payment/multiple_csv_parser.dart index 1281eca0..f2a0af1c 100644 --- a/frontend/pweb/lib/utils/payment/multiple_csv_parser.dart +++ b/frontend/pweb/lib/utils/payment/multiple_csv_parser.dart @@ -1,6 +1,6 @@ import 'package:pshared/utils/money.dart'; -import 'package:pweb/models/multiple_payouts/csv_row.dart'; +import 'package:pweb/models/payment/multiple_payouts/csv_row.dart'; class MultipleCsvParser { diff --git a/frontend/pweb/lib/utils/payment/multiple_intent_builder.dart b/frontend/pweb/lib/utils/payment/multiple_intent_builder.dart index 675527d4..ea615a07 100644 --- a/frontend/pweb/lib/utils/payment/multiple_intent_builder.dart +++ b/frontend/pweb/lib/utils/payment/multiple_intent_builder.dart @@ -10,7 +10,7 @@ import 'package:pshared/models/payment/wallet.dart'; import 'package:pshared/utils/currency.dart'; import 'package:pshared/utils/payment/fx_helpers.dart'; -import 'package:pweb/models/multiple_payouts/csv_row.dart'; +import 'package:pweb/models/payment/multiple_payouts/csv_row.dart'; class MultipleIntentBuilder { diff --git a/frontend/pweb/lib/utils/payment/payout_verification_flow.dart b/frontend/pweb/lib/utils/payment/payout_verification_flow.dart index c7c03e02..81ac98a5 100644 --- a/frontend/pweb/lib/utils/payment/payout_verification_flow.dart +++ b/frontend/pweb/lib/utils/payment/payout_verification_flow.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'package:pweb/controllers/payout_verification.dart'; +import 'package:pweb/controllers/payouts/payout_verification.dart'; import 'package:pweb/pages/payout_verification/page.dart'; import 'package:pweb/utils/error/snackbar.dart'; @@ -12,10 +12,12 @@ import 'package:pweb/generated/i18n/app_localizations.dart'; Future runPayoutVerification({ required BuildContext context, required PayoutVerificationController controller, + required String? contextKey, }) async { final localizations = AppLocalizations.of(context)!; - if (controller.isCooldownActive) return false; + controller.setContextKey(contextKey); + if (controller.isCooldownActiveFor(contextKey)) return false; try { await controller.requestCode(); diff --git a/frontend/pweb/lib/utils/report/payment_mapper.dart b/frontend/pweb/lib/utils/report/payment_mapper.dart index 8300b115..f091fb17 100644 --- a/frontend/pweb/lib/utils/report/payment_mapper.dart +++ b/frontend/pweb/lib/utils/report/payment_mapper.dart @@ -3,7 +3,7 @@ import 'package:pshared/models/payment/payment.dart'; import 'package:pshared/models/payment/status.dart'; import 'package:pshared/utils/money.dart'; -import 'package:pweb/models/payment_state.dart'; +import 'package:pweb/models/payment/payment_state.dart'; OperationItem mapPaymentToOperation(Payment payment) { diff --git a/frontend/pweb/lib/widgets/payment/source_wallet_selector.dart b/frontend/pweb/lib/widgets/payment/source_wallet_selector.dart index a004bc77..a78eac92 100644 --- a/frontend/pweb/lib/widgets/payment/source_wallet_selector.dart +++ b/frontend/pweb/lib/widgets/payment/source_wallet_selector.dart @@ -30,8 +30,13 @@ class SourceWalletSelector extends StatelessWidget { return Text(l10n.noWalletsAvailable, style: theme.textTheme.bodySmall); } + final effectiveSelectedWalletRef = selectedWalletRef != null && + wallets.any((wallet) => wallet.id == selectedWalletRef) + ? selectedWalletRef + : null; + return DropdownButtonFormField( - initialValue: selectedWalletRef, + initialValue: effectiveSelectedWalletRef, isExpanded: true, decoration: InputDecoration( labelText: l10n.whereGetMoney, diff --git a/frontend/pweb/lib/widgets/refresh_balance/button.dart b/frontend/pweb/lib/widgets/refresh_balance/button.dart index 982d8cbe..fa7c890c 100644 --- a/frontend/pweb/lib/widgets/refresh_balance/button.dart +++ b/frontend/pweb/lib/widgets/refresh_balance/button.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:pweb/models/visibility.dart'; +import 'package:pweb/models/state/visibility.dart'; class BalanceRefreshButton extends StatelessWidget { diff --git a/frontend/pweb/lib/widgets/refresh_balance/ledger.dart b/frontend/pweb/lib/widgets/refresh_balance/ledger.dart index 7a96913f..cb36c1e4 100644 --- a/frontend/pweb/lib/widgets/refresh_balance/ledger.dart +++ b/frontend/pweb/lib/widgets/refresh_balance/ledger.dart @@ -4,7 +4,7 @@ import 'package:provider/provider.dart'; import 'package:pshared/provider/ledger.dart'; -import 'package:pweb/models/visibility.dart'; +import 'package:pweb/models/state/visibility.dart'; import 'package:pweb/widgets/refresh_balance/button.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; diff --git a/frontend/pweb/lib/widgets/refresh_balance/wallet.dart b/frontend/pweb/lib/widgets/refresh_balance/wallet.dart index b3fc1f1a..7ba7e1e4 100644 --- a/frontend/pweb/lib/widgets/refresh_balance/wallet.dart +++ b/frontend/pweb/lib/widgets/refresh_balance/wallet.dart @@ -4,7 +4,7 @@ import 'package:provider/provider.dart'; import 'package:pshared/provider/payment/wallets.dart'; -import 'package:pweb/models/visibility.dart'; +import 'package:pweb/models/state/visibility.dart'; import 'package:pweb/widgets/refresh_balance/button.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; diff --git a/frontend/pweb/lib/widgets/roles/create_role_dialog.dart b/frontend/pweb/lib/widgets/roles/create_role_dialog.dart deleted file mode 100644 index 482ebba1..00000000 --- a/frontend/pweb/lib/widgets/roles/create_role_dialog.dart +++ /dev/null @@ -1,85 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:pweb/models/role_draft.dart'; -import 'package:pweb/generated/i18n/app_localizations.dart'; - - -Future showCreateRoleDialog(BuildContext context) { - return showDialog( - context: context, - builder: (dialogContext) => const _CreateRoleDialog(), - ); -} - -class _CreateRoleDialog extends StatefulWidget { - const _CreateRoleDialog(); - - @override - State<_CreateRoleDialog> createState() => _CreateRoleDialogState(); -} - -class _CreateRoleDialogState extends State<_CreateRoleDialog> { - final GlobalKey _formKey = GlobalKey(); - final TextEditingController _nameController = TextEditingController(); - final TextEditingController _descriptionController = TextEditingController(); - - @override - void dispose() { - _nameController.dispose(); - _descriptionController.dispose(); - super.dispose(); - } - - void _submit() { - final form = _formKey.currentState; - if (form == null || !form.validate()) return; - Navigator.of(context).pop(RoleDraft( - name: _nameController.text.trim(), - description: _descriptionController.text.trim(), - )); - } - - @override - Widget build(BuildContext context) { - final loc = AppLocalizations.of(context)!; - return AlertDialog( - title: Text(loc.invitationAddRoleTitle), - content: Form( - key: _formKey, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextFormField( - controller: _nameController, - decoration: InputDecoration( - labelText: loc.invitationRoleNameLabel, - ), - validator: (value) => (value == null || value.trim().isEmpty) - ? loc.invitationRoleNameRequired - : null, - ), - const SizedBox(height: 12), - TextFormField( - controller: _descriptionController, - minLines: 2, - maxLines: 3, - decoration: InputDecoration( - labelText: loc.invitationRoleDescriptionLabel, - ), - ), - ], - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: Text(loc.cancel), - ), - ElevatedButton( - onPressed: _submit, - child: Text(loc.add), - ), - ], - ); - } -} diff --git a/frontend/pweb/lib/widgets/sidebar/page.dart b/frontend/pweb/lib/widgets/sidebar/page.dart index da667f77..36c0aae0 100644 --- a/frontend/pweb/lib/widgets/sidebar/page.dart +++ b/frontend/pweb/lib/widgets/sidebar/page.dart @@ -85,7 +85,7 @@ class PageSelector extends StatelessWidget { final byName = PayoutRoutes.destinationFor(state.name); if (byName != null) return byName; - final location = state.matchedLocation; + final location = state.uri.path; if (location.startsWith(PayoutRoutes.editWalletPath)) { return PayoutDestination.editwallet; } @@ -98,6 +98,9 @@ class PageSelector extends StatelessWidget { if (location.startsWith(PayoutRoutes.paymentPath)) { return PayoutDestination.payment; } + if (location.startsWith(PayoutRoutes.editRecipientPath)) { + return PayoutDestination.addrecipient; + } if (location.startsWith(PayoutRoutes.addRecipientPath)) { return PayoutDestination.addrecipient; } diff --git a/frontend/pweb/lib/widgets/username.dart b/frontend/pweb/lib/widgets/username.dart index efc43fff..39bf2236 100644 --- a/frontend/pweb/lib/widgets/username.dart +++ b/frontend/pweb/lib/widgets/username.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:pweb/controllers/email.dart'; +import 'package:pweb/controllers/auth/email.dart'; import 'package:pweb/generated/i18n/app_localizations.dart';