Fixed payment method deletion in recipient settings with better flow
This commit is contained in:
146
frontend/pshared/lib/provider/recipient/methods_cache.dart
Normal file
146
frontend/pshared/lib/provider/recipient/methods_cache.dart
Normal 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));
|
||||
}
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
33
frontend/pweb/lib/widgets/dialogs/confirmation_dialog.dart
Normal file
33
frontend/pweb/lib/widgets/dialogs/confirmation_dialog.dart
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user