From 3765780a4d23b51b212d94902b3115bb264d10dd Mon Sep 17 00:00:00 2001 From: Arseni Date: Fri, 23 Jan 2026 16:28:09 +0300 Subject: [PATCH] Fixed payment method deletion in recipient settings with better flow --- .../lib/provider/recipient/methods_cache.dart | 146 ++++++++++++++++++ .../pweb/lib/app/router/payout_shell.dart | 27 ++-- .../pages/address_book/form/method_tile.dart | 20 ++- .../lib/pages/address_book/form/page.dart | 96 ++++++------ .../dashboard/buttons/balance/add/owner.dart | 2 +- .../pages/payout_page/methods/controller.dart | 23 +-- .../widgets/dialogs/confirmation_dialog.dart | 33 ++++ 7 files changed, 265 insertions(+), 82 deletions(-) create mode 100644 frontend/pshared/lib/provider/recipient/methods_cache.dart create mode 100644 frontend/pweb/lib/widgets/dialogs/confirmation_dialog.dart diff --git a/frontend/pshared/lib/provider/recipient/methods_cache.dart b/frontend/pshared/lib/provider/recipient/methods_cache.dart new file mode 100644 index 00000000..b7d2323c --- /dev/null +++ b/frontend/pshared/lib/provider/recipient/methods_cache.dart @@ -0,0 +1,146 @@ +import 'dart:async'; + +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; + +import 'package:pshared/models/describable.dart'; +import 'package:pshared/models/organization/bound.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/permissions/bound.dart'; +import 'package:pshared/models/storable.dart'; +import 'package:pshared/provider/organizations.dart'; +import 'package:pshared/provider/recipient/provider.dart'; +import 'package:pshared/service/recipient/pmethods.dart'; +import 'package:pshared/utils/exception.dart'; + + +class RecipientMethodsCacheProvider extends ChangeNotifier { + late OrganizationsProvider _organizations; + + final Map> _methodsByRecipient = {}; + final Map _loadingByRecipient = {}; + final Map _errorByRecipient = {}; + Set _trackedRecipients = {}; + + List methodsForRecipient(String recipientId) => + List.unmodifiable(_methodsByRecipient[recipientId] ?? const []); + + bool isLoadingFor(String recipientId) => _loadingByRecipient[recipientId] == true; + Object? errorFor(String recipientId) => _errorByRecipient[recipientId]; + bool hasMethodsFor(String recipientId) => _methodsByRecipient.containsKey(recipientId); + bool isTrackingRecipient(String recipientId) => _trackedRecipients.contains(recipientId); + + void updateProviders(OrganizationsProvider organizations, RecipientsProvider recipients) { + _organizations = organizations; + if (!_organizations.isOrganizationSet) return; + + final nextRecipients = recipients.items.map((r) => r.id).toSet(); + if (setEquals(_trackedRecipients, nextRecipients)) return; + + final removed = _trackedRecipients.difference(nextRecipients); + for (final recipientId in removed) { + _methodsByRecipient.remove(recipientId); + _loadingByRecipient.remove(recipientId); + _errorByRecipient.remove(recipientId); + } + + final added = nextRecipients.difference(_trackedRecipients); + _trackedRecipients = nextRecipients; + + for (final recipientId in added) { + unawaited(_loadRecipientMethods(recipientId)); + } + } + + Future refreshRecipient(String recipientId) => _loadRecipientMethods(recipientId, force: true); + + Future syncRecipientMethods({ + required String recipientId, + required Map methods, + required Map names, + }) async { + await _ensureLoaded(recipientId); + final current = List.from(_methodsByRecipient[recipientId] ?? const []); + final currentByType = {for (final method in current) method.type: method}; + + for (final entry in currentByType.entries) { + if (!methods.containsKey(entry.key)) { + await PaymentMethodService.delete(entry.value); + current.removeWhere((method) => method.id == entry.value.id); + } + } + + for (final entry in methods.entries) { + final type = entry.key; + final data = entry.value; + final existing = currentByType[type]; + if (existing != null) { + final updated = existing.copyWith(data: data); + final updatedList = await PaymentMethodService.update(updated); + final updatedMethod = updatedList.firstWhereOrNull((m) => m.id == updated.id) ?? updated; + final index = current.indexWhere((m) => m.id == updatedMethod.id); + if (index != -1) { + current[index] = updatedMethod; + } + } else { + final created = await _createMethod( + recipientId: recipientId, + data: data, + name: names[type] ?? type.name, + ); + current.add(created); + } + } + + _methodsByRecipient[recipientId] = _sortedMethods(current); + notifyListeners(); + } + + Future _ensureLoaded(String recipientId) async { + if (_methodsByRecipient.containsKey(recipientId)) return; + await _loadRecipientMethods(recipientId); + } + + Future _loadRecipientMethods(String recipientId, {bool force = false}) async { + if (!force && _loadingByRecipient[recipientId] == true) return; + _setLoading(recipientId, true); + try { + final list = await PaymentMethodService.list(_organizations.current.id, recipientId); + _methodsByRecipient[recipientId] = _sortedMethods(list); + _errorByRecipient.remove(recipientId); + } catch (e) { + _errorByRecipient[recipientId] = toException(e); + } finally { + _setLoading(recipientId, false); + } + } + + Future _createMethod({ + required String recipientId, + required PaymentMethodData data, + required String name, + }) async { + final organizationRef = _organizations.current.id; + final method = PaymentMethod( + storable: newStorable(), + permissionBound: newPermissionBound( + organizationBound: newOrganizationBound(organizationRef: organizationRef), + ), + recipientRef: recipientId, + data: data, + describable: newDescribable(name: name), + ); + final created = await PaymentMethodService.create(organizationRef, method); + return created.first; + } + + void _setLoading(String recipientId, bool value) { + _loadingByRecipient[recipientId] = value; + notifyListeners(); + } + + List _sortedMethods(List methods) => + methods.toList()..sort((a, b) => a.storable.createdAt.compareTo(b.storable.createdAt)); +} diff --git a/frontend/pweb/lib/app/router/payout_shell.dart b/frontend/pweb/lib/app/router/payout_shell.dart index 1eced15c..34913edc 100644 --- a/frontend/pweb/lib/app/router/payout_shell.dart +++ b/frontend/pweb/lib/app/router/payout_shell.dart @@ -13,6 +13,7 @@ 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:pshared/provider/recipient/methods_cache.dart'; import 'package:pshared/provider/recipient/pmethods.dart'; import 'package:pweb/app/router/pages.dart'; @@ -29,6 +30,7 @@ import 'package:pweb/pages/payout_page/wallet/edit/page.dart'; import 'package:pweb/pages/report/page.dart'; import 'package:pweb/pages/settings/profile/page.dart'; import 'package:pweb/pages/wallet_top_up/page.dart'; +import 'package:pweb/widgets/dialogs/confirmation_dialog.dart'; import 'package:pweb/widgets/error/snackbar.dart'; import 'package:pweb/widgets/sidebar/destinations.dart'; import 'package:pweb/widgets/sidebar/page.dart'; @@ -43,6 +45,10 @@ RouteBase payoutShellRoute() => ShellRoute( create: (_) => PaymentMethodsProvider(), update: (context, organizations, recipients, provider) => provider!..updateProviders(organizations, recipients), ), + ChangeNotifierProxyProvider2( + create: (_) => RecipientMethodsCacheProvider(), + update: (context, organizations, recipients, provider) => provider!..updateProviders(organizations, recipients), + ), ChangeNotifierProxyProvider2( create: (_) => PaymentFlowProvider(initialType: PaymentType.bankAccount), update: (context, recipients, methods, provider) => provider!..update( @@ -118,24 +124,13 @@ RouteBase payoutShellRoute() => ShellRoute( recipient: recipient, ), onDeleteRecipient: (recipient) async { - final confirmed = await showDialog( + final confirmed = await showConfirmationDialog( context: context, - builder: (dialogContext) => AlertDialog( - title: Text(loc.delete), - content: Text(loc.deleteRecipientConfirmation), - actions: [ - TextButton( - onPressed: () => Navigator.pop(dialogContext, false), - child: Text(loc.cancel), - ), - ElevatedButton( - onPressed: () => Navigator.pop(dialogContext, true), - child: Text(loc.delete), - ), - ], - ), + title: loc.delete, + message: loc.deleteRecipientConfirmation, + confirmLabel: loc.delete, ); - if (confirmed != true) return; + if (!confirmed) return; await executeActionWithNotification( context: context, action: () async => diff --git a/frontend/pweb/lib/pages/address_book/form/method_tile.dart b/frontend/pweb/lib/pages/address_book/form/method_tile.dart index fb0dba73..aee9aba3 100644 --- a/frontend/pweb/lib/pages/address_book/form/method_tile.dart +++ b/frontend/pweb/lib/pages/address_book/form/method_tile.dart @@ -5,6 +5,9 @@ import 'package:pshared/models/payment/type.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/generated/i18n/app_localizations.dart'; class AddressBookPaymentMethodTile extends StatefulWidget { @@ -35,6 +38,19 @@ class AddressBookPaymentMethodTile extends StatefulWidget { } class _AddressBookPaymentMethodTileState extends State { + Future _confirmDelete() async { + final l10n = AppLocalizations.of(context)!; + final confirmed = await showConfirmationDialog( + context: context, + title: l10n.delete, + message: l10n.deletePaymentConfirmation, + confirmLabel: l10n.delete, + ); + if (confirmed) { + widget.onChanged(null); + } + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -67,9 +83,7 @@ class _AddressBookPaymentMethodTileState extends State { RecipientType _type = RecipientType.internal; RecipientStatus _status = RecipientStatus.ready; final MethodMap _methods = {}; - late PaymentMethodsProvider _methodsProvider; - - Future _loadMethods() async { - _methodsProvider = PaymentMethodsProvider()..addListener(_onProviderChanged); - await _methodsProvider.loadMethods( - context.read(), - widget.recipient?.id, - ); - - for (final m in _methodsProvider.methods) { - _methods[m.type] = switch (m.type) { - PaymentType.card => m.cardData, - PaymentType.iban => m.ibanData, - PaymentType.wallet => m.walletData, - PaymentType.bankAccount => m.bankAccountData, - PaymentType.externalChain => m.cryptoAddressData, - //TODO: support new payment methods - _ => throw UnimplementedError('Payment method ${m.type} is not supported yet'), - }; - } - } + late RecipientMethodsCacheProvider _methodsCacheProvider; + bool _hasInitializedMethods = false; @override void initState() { @@ -71,12 +49,16 @@ class _AddressBookRecipientFormState extends State { _emailCtrl = TextEditingController(text: r?.email ?? ''); _type = r?.type ?? RecipientType.internal; _status = r?.status ?? RecipientStatus.ready; - _loadMethods(); + _methodsCacheProvider = context.read() + ..addListener(_onProviderChanged); + if (r != null) { + _methodsCacheProvider.refreshRecipient(r.id); + _syncMethodsFromCache(); + } } Future _doSave() async { final recipients = context.read(); - final methods = PaymentMethodsProvider(); final recipient = widget.recipient == null ? await recipients.create( name: _nameCtrl.text, @@ -84,25 +66,22 @@ class _AddressBookRecipientFormState extends State { ) : widget.recipient!; recipients.setCurrentObject(recipient.id); - //TODO : redesign work with recipients / payment methods - await methods.loadMethods(context.read(), recipient.id); - for (final type in _methods.keys) { - final data = _methods[type]!; - final exising = methods.methods.firstWhereOrNull((m) => m.type == type); - if (exising != null) { - await methods.updateMethod(exising.copyWith(data: data)); - } else { - await methods.create( - reacipientRef: recipient.id, - name: getPaymentTypeLabel(context, type), - data: data, - ); - } + final methods = {}; + final names = {}; + for (final entry in _methods.entries) { + final data = entry.value; + if (data == null) continue; + methods[entry.key] = data; + names[entry.key] = getPaymentTypeLabel(context, entry.key); } + await _methodsCacheProvider.syncRecipientMethods( + recipientId: recipient.id, + methods: methods, + names: names, + ); return recipient; } - //TODO: Change when registration is ready Future _save() async { final l10n = AppLocalizations.of(context)!; @@ -134,12 +113,39 @@ class _AddressBookRecipientFormState extends State { @override void dispose() { - _methodsProvider.removeListener(_onProviderChanged); - _methodsProvider.dispose(); + _methodsCacheProvider.removeListener(_onProviderChanged); super.dispose(); } - void _onProviderChanged() => setState(() {}); + void _onProviderChanged() => _syncMethodsFromCache(); + + void _syncMethodsFromCache() { + final recipient = widget.recipient; + if (recipient == null || _hasInitializedMethods) return; + if (!_methodsCacheProvider.hasMethodsFor(recipient.id)) return; + final list = _methodsCacheProvider.methodsForRecipient(recipient.id); + if (list.isEmpty) { + _hasInitializedMethods = true; + return; + } + setState(() { + _methods + ..clear() + ..addEntries(list.map((m) { + final data = switch (m.type) { + PaymentType.card => m.cardData, + PaymentType.iban => m.ibanData, + PaymentType.wallet => m.walletData, + PaymentType.bankAccount => m.bankAccountData, + PaymentType.externalChain => m.cryptoAddressData, + //TODO: support new payment methods + _ => throw UnimplementedError('Payment method ${m.type} is not supported yet'), + }; + return MapEntry(m.type, data); + })); + _hasInitializedMethods = true; + }); + } @override Widget build(BuildContext context) => FormView( diff --git a/frontend/pweb/lib/pages/dashboard/buttons/balance/add/owner.dart b/frontend/pweb/lib/pages/dashboard/buttons/balance/add/owner.dart index e3523e05..34e70e4f 100644 --- a/frontend/pweb/lib/pages/dashboard/buttons/balance/add/owner.dart +++ b/frontend/pweb/lib/pages/dashboard/buttons/balance/add/owner.dart @@ -19,7 +19,7 @@ class OwnerField extends StatelessWidget { @override Widget build(BuildContext context) => DropdownButtonFormField( - value: value, + initialValue: value, decoration: getInputDecoration(context, AppLocalizations.of(context)!.assetOwner, true), items: items, onChanged: onChanged, diff --git a/frontend/pweb/lib/pages/payout_page/methods/controller.dart b/frontend/pweb/lib/pages/payout_page/methods/controller.dart index 90c18093..217134c7 100644 --- a/frontend/pweb/lib/pages/payout_page/methods/controller.dart +++ b/frontend/pweb/lib/pages/payout_page/methods/controller.dart @@ -7,6 +7,7 @@ import 'package:pshared/models/payment/methods/type.dart'; import 'package:pshared/provider/recipient/pmethods.dart'; import 'package:pweb/pages/payment_methods/add/widget.dart'; +import 'package:pweb/widgets/dialogs/confirmation_dialog.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; @@ -28,25 +29,13 @@ class PaymentConfigController { Future deleteMethod(PaymentMethod method) async { final methodsProvider = context.read(); final l10n = AppLocalizations.of(context)!; - final confirmed = await showDialog( + final confirmed = await showConfirmationDialog( context: context, - builder: (dialogContext) => AlertDialog( - title: Text(l10n.delete), - content: Text(l10n.deletePaymentConfirmation), - actions: [ - TextButton( - onPressed: () => Navigator.pop(dialogContext, false), - child: Text(l10n.cancel), - ), - ElevatedButton( - onPressed: () => Navigator.pop(dialogContext, true), - child: Text(l10n.delete), - ), - ], - ), + title: l10n.delete, + message: l10n.deletePaymentConfirmation, + confirmLabel: l10n.delete, ); - - if (confirmed == true) { + if (confirmed) { methodsProvider.delete(method.id); } } diff --git a/frontend/pweb/lib/widgets/dialogs/confirmation_dialog.dart b/frontend/pweb/lib/widgets/dialogs/confirmation_dialog.dart new file mode 100644 index 00000000..d21f0ae8 --- /dev/null +++ b/frontend/pweb/lib/widgets/dialogs/confirmation_dialog.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +Future showConfirmationDialog({ + required BuildContext context, + required String title, + required String message, + String? confirmLabel, + String? cancelLabel, +}) async { + final l10n = AppLocalizations.of(context)!; + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(title), + content: Text(message), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: Text(cancelLabel ?? l10n.cancel), + ), + ElevatedButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: Text(confirmLabel ?? l10n.confirm), + ), + ], + ), + ); + + return confirmed == true; +}