Fixed payment method deletion in recipient settings with better flow #316

Merged
tech merged 1 commits from SEND034 into main 2026-01-23 21:26:56 +00:00
7 changed files with 265 additions and 82 deletions

View File

@@ -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<String, List<PaymentMethod>> _methodsByRecipient = {};
final Map<String, bool> _loadingByRecipient = {};
final Map<String, Object?> _errorByRecipient = {};
Set<String> _trackedRecipients = {};
List<PaymentMethod> methodsForRecipient(String recipientId) =>
List<PaymentMethod>.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<void> refreshRecipient(String recipientId) => _loadRecipientMethods(recipientId, force: true);
Future<void> syncRecipientMethods({
required String recipientId,
required Map<PaymentType, PaymentMethodData> methods,
required Map<PaymentType, String> names,
}) async {
await _ensureLoaded(recipientId);
final current = List<PaymentMethod>.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<void> _ensureLoaded(String recipientId) async {
if (_methodsByRecipient.containsKey(recipientId)) return;
await _loadRecipientMethods(recipientId);
}
Future<void> _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<PaymentMethod> _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<PaymentMethod> _sortedMethods(List<PaymentMethod> methods) =>
methods.toList()..sort((a, b) => a.storable.createdAt.compareTo(b.storable.createdAt));
}

View File

@@ -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<OrganizationsProvider, RecipientsProvider, RecipientMethodsCacheProvider>(
create: (_) => RecipientMethodsCacheProvider(),
update: (context, organizations, recipients, provider) => provider!..updateProviders(organizations, recipients),
),
ChangeNotifierProxyProvider2<RecipientsProvider, PaymentMethodsProvider, PaymentFlowProvider>(
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<bool>(
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 =>

View File

@@ -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<AddressBookPaymentMethodTile> {
Future<void> _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<AddressBookPaymentMethodT
if (isAdded)
IconButton(
icon: Icon(Icons.delete, color: theme.colorScheme.error),
onPressed: () {
widget.onChanged(null);
},
onPressed: _confirmDelete,
),
Icon(
isAdded ? Icons.check_circle : Icons.add_circle_outline,

View File

@@ -2,8 +2,6 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:provider/provider.dart';
import 'package:pshared/models/payment/methods/data.dart';
@@ -11,8 +9,7 @@ import 'package:pshared/models/payment/type.dart';
import 'package:pshared/models/recipient/recipient.dart';
import 'package:pshared/models/recipient/status.dart';
import 'package:pshared/models/recipient/type.dart';
import 'package:pshared/provider/organizations.dart';
import 'package:pshared/provider/recipient/pmethods.dart';
import 'package:pshared/provider/recipient/methods_cache.dart';
import 'package:pshared/provider/recipient/provider.dart';
import 'package:pweb/pages/address_book/form/view.dart';
@@ -41,27 +38,8 @@ class _AddressBookRecipientFormState extends State<AddressBookRecipientForm> {
RecipientType _type = RecipientType.internal;
RecipientStatus _status = RecipientStatus.ready;
final MethodMap _methods = {};
late PaymentMethodsProvider _methodsProvider;
Future<void> _loadMethods() async {
_methodsProvider = PaymentMethodsProvider()..addListener(_onProviderChanged);
await _methodsProvider.loadMethods(
context.read<OrganizationsProvider>(),
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<AddressBookRecipientForm> {
_emailCtrl = TextEditingController(text: r?.email ?? '');
_type = r?.type ?? RecipientType.internal;
_status = r?.status ?? RecipientStatus.ready;
_loadMethods();
_methodsCacheProvider = context.read<RecipientMethodsCacheProvider>()
..addListener(_onProviderChanged);
if (r != null) {
_methodsCacheProvider.refreshRecipient(r.id);
_syncMethodsFromCache();
}
}
Future<Recipient?> _doSave() async {
final recipients = context.read<RecipientsProvider>();
final methods = PaymentMethodsProvider();
final recipient = widget.recipient == null
? await recipients.create(
name: _nameCtrl.text,
@@ -84,25 +66,22 @@ class _AddressBookRecipientFormState extends State<AddressBookRecipientForm> {
)
: widget.recipient!;
recipients.setCurrentObject(recipient.id);
//TODO : redesign work with recipients / payment methods
await methods.loadMethods(context.read<OrganizationsProvider>(), 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 = <PaymentType, PaymentMethodData>{};
final names = <PaymentType, String>{};
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<void> _save() async {
final l10n = AppLocalizations.of(context)!;
@@ -134,12 +113,39 @@ class _AddressBookRecipientFormState extends State<AddressBookRecipientForm> {
@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(

View File

@@ -19,7 +19,7 @@ class OwnerField extends StatelessWidget {
@override
Widget build(BuildContext context) => DropdownButtonFormField<String?>(
value: value,
initialValue: value,
decoration: getInputDecoration(context, AppLocalizations.of(context)!.assetOwner, true),
items: items,
onChanged: onChanged,

View File

@@ -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<void> deleteMethod(PaymentMethod method) async {
final methodsProvider = context.read<PaymentMethodsProvider>();
final l10n = AppLocalizations.of(context)!;
final confirmed = await showDialog<bool>(
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);
}
}

View File

@@ -0,0 +1,33 @@
import 'package:flutter/material.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
Future<bool> showConfirmationDialog({
required BuildContext context,
required String title,
required String message,
String? confirmLabel,
String? cancelLabel,
}) async {
final l10n = AppLocalizations.of(context)!;
final confirmed = await showDialog<bool>(
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;
}