From efa69b43b294558fdb5faf388f24c75cdccd1a24 Mon Sep 17 00:00:00 2001 From: Arseni Date: Thu, 29 Jan 2026 19:22:30 +0300 Subject: [PATCH 1/2] refactoring for recipient addition page --- .../lib/models/payment/methods/type.dart | 2 + .../recipient/payment_method_draft.dart | 16 ++ .../lib/provider/recipient/methods_cache.dart | 63 ++++--- .../lib/provider/recipient/provider.dart | 70 +------ .../pweb/lib/app/router/payout_shell.dart | 3 +- frontend/pweb/lib/l10n/en.arb | 23 +++ frontend/pweb/lib/l10n/ru.arb | 23 +++ frontend/pweb/lib/models/button_state.dart | 1 - frontend/pweb/lib/models/control_state.dart | 1 + .../payment_method_tile/availability.dart | 1 + .../models/payment_method_tile/selection.dart | 1 + frontend/pweb/lib/models/seed_state.dart | 1 + .../lib/pages/address_book/form/body.dart | 120 ++++++++++++ .../pages/address_book/form/method_tile.dart | 105 ----------- .../lib/pages/address_book/form/page.dart | 178 +++++------------- .../lib/pages/address_book/form/view.dart | 76 +++++--- .../form/widgets/choice_chips.dart | 62 ------ .../{email_field.dart => feilds/email.dart} | 20 +- .../{name_field.dart => feilds/name.dart} | 17 +- .../widgets/feilds/recipient_text_field.dart | 86 +++++++++ .../widgets/payment_methods/add_button.dart | 95 ++++++++++ .../form/widgets/payment_methods/panel.dart | 108 +++++++++++ .../widgets/payment_methods/selector_row.dart | 81 ++++++++ .../form/widgets/payment_methods/tile.dart | 113 +++++++++++ .../widgets/{button.dart => save_button.dart} | 8 +- .../lib/pages/address_book/page/page.dart | 50 ++--- .../page/recipient/payment_row.dart | 44 ++--- .../payouts/single/address_book/widget.dart | 24 ++- .../payouts/single/new_recipient/payout.dart | 7 +- .../payouts/single/new_recipient/type.dart | 30 ++- .../lib/pages/payment_methods/add/ledger.dart | 121 ++++++++++++ .../payment_methods/add/method_selector.dart | 16 +- .../lib/pages/payment_methods/add/widget.dart | 5 +- .../pweb/lib/pages/payment_methods/form.dart | 7 + .../pweb/lib/pages/payment_methods/page.dart | 28 ++- .../payment_methods/payment_page/body.dart | 9 + .../payment_methods/payment_page/content.dart | 9 + .../payment_methods/payment_page/page.dart | 9 + .../payment_page/send_button.dart | 10 +- .../widgets/payment_info_section.dart | 7 +- .../widgets/recipient_section.dart | 17 +- .../pages/payment_methods/widgets/search.dart | 4 +- .../pweb/lib/pages/verification/page.dart | 1 + .../address_book_recipient_form.dart | 170 +++++++++++++++++ .../pweb/lib/utils/payment/availability.dart | 26 +++ .../pweb/lib/utils/payment/selector_type.dart | 12 +- .../pweb/lib/utils/recipient/filtering.dart | 28 +++ 47 files changed, 1376 insertions(+), 532 deletions(-) create mode 100644 frontend/pshared/lib/models/recipient/payment_method_draft.dart delete mode 100644 frontend/pweb/lib/models/button_state.dart create mode 100644 frontend/pweb/lib/models/control_state.dart create mode 100644 frontend/pweb/lib/models/payment_method_tile/availability.dart create mode 100644 frontend/pweb/lib/models/payment_method_tile/selection.dart create mode 100644 frontend/pweb/lib/models/seed_state.dart create mode 100644 frontend/pweb/lib/pages/address_book/form/body.dart delete mode 100644 frontend/pweb/lib/pages/address_book/form/method_tile.dart delete mode 100644 frontend/pweb/lib/pages/address_book/form/widgets/choice_chips.dart rename frontend/pweb/lib/pages/address_book/form/widgets/{email_field.dart => feilds/email.dart} (55%) rename frontend/pweb/lib/pages/address_book/form/widgets/{name_field.dart => feilds/name.dart} (62%) create mode 100644 frontend/pweb/lib/pages/address_book/form/widgets/feilds/recipient_text_field.dart create mode 100644 frontend/pweb/lib/pages/address_book/form/widgets/payment_methods/add_button.dart create mode 100644 frontend/pweb/lib/pages/address_book/form/widgets/payment_methods/panel.dart create mode 100644 frontend/pweb/lib/pages/address_book/form/widgets/payment_methods/selector_row.dart create mode 100644 frontend/pweb/lib/pages/address_book/form/widgets/payment_methods/tile.dart rename frontend/pweb/lib/pages/address_book/form/widgets/{button.dart => save_button.dart} (86%) create mode 100644 frontend/pweb/lib/pages/payment_methods/add/ledger.dart create mode 100644 frontend/pweb/lib/providers/address_book_recipient_form.dart create mode 100644 frontend/pweb/lib/utils/payment/availability.dart create mode 100644 frontend/pweb/lib/utils/recipient/filtering.dart diff --git a/frontend/pshared/lib/models/payment/methods/type.dart b/frontend/pshared/lib/models/payment/methods/type.dart index ec859d1d..444c5114 100644 --- a/frontend/pshared/lib/models/payment/methods/type.dart +++ b/frontend/pshared/lib/models/payment/methods/type.dart @@ -3,6 +3,7 @@ import 'package:pshared/models/payment/methods/card.dart'; import 'package:pshared/models/payment/methods/crypto_address.dart'; import 'package:pshared/models/payment/methods/data.dart'; import 'package:pshared/models/payment/methods/iban.dart'; +import 'package:pshared/models/payment/methods/ledger.dart'; import 'package:pshared/models/payment/methods/managed_wallet.dart'; import 'package:pshared/models/payment/methods/russian_bank.dart'; import 'package:pshared/models/payment/methods/wallet.dart'; @@ -46,6 +47,7 @@ class PaymentMethod implements PermissionBoundStorable, Describable { WalletPaymentMethod? get walletData => dataAsOrNull(); ManagedWalletPaymentMethod? get managedWalletData => dataAsOrNull(); CryptoAddressPaymentMethod? get cryptoAddressData => dataAsOrNull(); + LedgerPaymentMethod? get ledgerData => dataAsOrNull(); @override String get id => storable.id; diff --git a/frontend/pshared/lib/models/recipient/payment_method_draft.dart b/frontend/pshared/lib/models/recipient/payment_method_draft.dart new file mode 100644 index 00000000..4d9ba974 --- /dev/null +++ b/frontend/pshared/lib/models/recipient/payment_method_draft.dart @@ -0,0 +1,16 @@ +import 'package:pshared/models/payment/methods/data.dart'; +import 'package:pshared/models/payment/methods/type.dart'; +import 'package:pshared/models/payment/type.dart'; + + +class RecipientMethodDraft { + final PaymentType type; + final PaymentMethod? existing; + PaymentMethodData? data; + + RecipientMethodDraft({ + required this.type, + this.existing, + PaymentMethodData? data, + }) : data = data ?? existing?.data; +} diff --git a/frontend/pshared/lib/provider/recipient/methods_cache.dart b/frontend/pshared/lib/provider/recipient/methods_cache.dart index b7d2323c..7bc74496 100644 --- a/frontend/pshared/lib/provider/recipient/methods_cache.dart +++ b/frontend/pshared/lib/provider/recipient/methods_cache.dart @@ -1,14 +1,16 @@ import 'dart:async'; -import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; +import 'package:collection/collection.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/recipient/payment_method_draft.dart'; import 'package:pshared/models/storable.dart'; import 'package:pshared/provider/organizations.dart'; import 'package:pshared/provider/recipient/provider.dart'; @@ -58,42 +60,49 @@ class RecipientMethodsCacheProvider extends ChangeNotifier { Future syncRecipientMethods({ required String recipientId, - required Map methods, + required List 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}; + final currentById = {for (final method in current) method.id: method}; + final desired = methods.where((m) => m.data != null).toList(); + final desiredExisting = desired.where((m) => m.existing != null).toList(); + final desiredExistingIds = desiredExisting.map((m) => m.existing!.id).toSet(); - 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 method in current.toList()) { + if (!desiredExistingIds.contains(method.id)) { + await PaymentMethodService.delete(method); + current.removeWhere((m) => m.id == method.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); + for (final entry in desiredExisting) { + final existing = entry.existing; + final data = entry.data; + if (existing == null || data == null) continue; + final currentMethod = currentById[existing.id] ?? existing; + final updated = currentMethod.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; } } + for (final entry in desired.where((m) => m.existing == null)) { + final data = entry.data; + if (data == null) continue; + final type = entry.type; + final created = await _createMethod( + recipientId: recipientId, + data: data, + name: names[type] ?? type.name, + ); + current.add(created); + } + _methodsByRecipient[recipientId] = _sortedMethods(current); notifyListeners(); } @@ -143,4 +152,4 @@ class RecipientMethodsCacheProvider extends ChangeNotifier { List _sortedMethods(List methods) => methods.toList()..sort((a, b) => a.storable.createdAt.compareTo(b.storable.createdAt)); -} +} \ No newline at end of file diff --git a/frontend/pshared/lib/provider/recipient/provider.dart b/frontend/pshared/lib/provider/recipient/provider.dart index f4f35e62..6d550c45 100644 --- a/frontend/pshared/lib/provider/recipient/provider.dart +++ b/frontend/pshared/lib/provider/recipient/provider.dart @@ -1,6 +1,5 @@ import 'package:pshared/data/mapper/recipient/recipient.dart'; -import 'package:pshared/models/recipient/filter.dart'; import 'package:pshared/models/recipient/recipient.dart'; import 'package:pshared/models/recipient/status.dart'; import 'package:pshared/models/recipient/type.dart'; @@ -11,71 +10,12 @@ import 'package:pshared/service/recipient/service.dart'; class RecipientsProvider extends GenericProvider { late OrganizationsProvider _organizations; - - RecipientFilter _selectedFilter = RecipientFilter.all; - String _query = ''; - String? _previousRecipientRef; - - RecipientFilter get selectedFilter => _selectedFilter; - String get query => _query; + String? _organizationRef; List get recipients => List.unmodifiable(items.toList()..sort((a, b) => a.storable.createdAt.compareTo(b.storable.createdAt))); RecipientsProvider() : super(service: RecipientService.basicService); - Recipient? get previousRecipient => _previousRecipientRef == null - ? null - : getItemByRef(_previousRecipientRef!); - - List get filteredRecipients { - List filtered = recipients.where((r) { - switch (_selectedFilter) { - case RecipientFilter.ready: - return r.status == RecipientStatus.ready; - case RecipientFilter.registered: - return r.status == RecipientStatus.registered; - case RecipientFilter.notRegistered: - return r.status == RecipientStatus.notRegistered; - case RecipientFilter.all: - return true; - } - }).toList(); - - if (_query.isNotEmpty) { - filtered = filtered.where((r) => r.matchesQuery(_query)).toList(); - } - - return filtered; - } - - void setFilter(RecipientFilter filter) { - _selectedFilter = filter; - notifyListeners(); - } - - void setQuery(String query) { - _query = query.trim().toLowerCase(); - notifyListeners(); - } - - @override - bool setCurrentObject(String? objectRef) { - final currentRef = currentObject?.id; - final didUpdate = super.setCurrentObject(objectRef); - - if (didUpdate && currentRef != null && currentRef != objectRef) { - _previousRecipientRef = currentRef; - } - - return didUpdate; - } - - void restorePreviousRecipient() { - if (_previousRecipientRef != null) { - setCurrentObject(_previousRecipientRef); - } - } - Future create({ required String name, required String email, @@ -92,8 +32,10 @@ class RecipientsProvider extends GenericProvider { void updateProviders(OrganizationsProvider organizations) { _organizations = organizations; - if (_organizations.isOrganizationSet) { - load(_organizations.current.id, _organizations.current.id); - } + if (!_organizations.isOrganizationSet) return; + final organizationRef = _organizations.current.id; + if (_organizationRef == organizationRef) return; + _organizationRef = organizationRef; + load(organizationRef, organizationRef); } } diff --git a/frontend/pweb/lib/app/router/payout_shell.dart b/frontend/pweb/lib/app/router/payout_shell.dart index 34913edc..6d95d8df 100644 --- a/frontend/pweb/lib/app/router/payout_shell.dart +++ b/frontend/pweb/lib/app/router/payout_shell.dart @@ -34,6 +34,7 @@ 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'; +import 'package:pweb/utils/payment/availability.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; @@ -50,7 +51,7 @@ RouteBase payoutShellRoute() => ShellRoute( update: (context, organizations, recipients, provider) => provider!..updateProviders(organizations, recipients), ), ChangeNotifierProxyProvider2( - create: (_) => PaymentFlowProvider(initialType: PaymentType.bankAccount), + create: (_) => PaymentFlowProvider(initialType: enabledPaymentTypes.first), update: (context, recipients, methods, provider) => provider!..update( recipients, methods, diff --git a/frontend/pweb/lib/l10n/en.arb b/frontend/pweb/lib/l10n/en.arb index 8890331c..9e1bde86 100644 --- a/frontend/pweb/lib/l10n/en.arb +++ b/frontend/pweb/lib/l10n/en.arb @@ -372,6 +372,22 @@ "paymentConfigTitle": "Where to receive money", "paymentConfigSubtitle": "Add multiple methods and choose your primary one.", "addPaymentMethod": "Add payment method", + "paymentMethodAdded": "Added", + "@paymentMethodAdded": { + "description": "Status badge text for a payment method that is already configured" + }, + "paymentMethodNotAdded": "Not added", + "@paymentMethodNotAdded": { + "description": "Status badge text for a payment method that has no details yet" + }, + "paymentMethodComingSoon": "Coming soon", + "@paymentMethodComingSoon": { + "description": "Status badge text for a payment method that is visible but not available yet" + }, + "paymentMethodDetails": "Payment details", + "@paymentMethodDetails": { + "description": "Title above the selected payment method form" + }, "makeMain": "Make primary", "advanced": "Advanced", "fallbackExplanation": "If the primary method is unavailable, we will try the next enabled one in the list.", @@ -436,9 +452,16 @@ "walletId": "Wallet ID", "enterWalletId": "Enter wallet ID", + "ledgerAccountRef": "Ledger account reference", + "enterLedgerAccountRef": "Enter ledger account reference", + "contraLedgerAccountRef": "Contra ledger account reference (optional)", "recipients": "Recipients", "recipientName": "Recipient Name", + "recipientNameHint": "e.g. Alex Johnson", + "@recipientNameHint": { + "description": "Hint shown in the recipient name field" + }, "enterRecipientName": "Enter recipient name", "inn": "INN", "enterInn": "Enter INN", diff --git a/frontend/pweb/lib/l10n/ru.arb b/frontend/pweb/lib/l10n/ru.arb index a051eb65..aaff4ae0 100644 --- a/frontend/pweb/lib/l10n/ru.arb +++ b/frontend/pweb/lib/l10n/ru.arb @@ -372,6 +372,22 @@ "paymentConfigTitle": "Куда получать деньги", "paymentConfigSubtitle": "Добавьте несколько методов и выберите основной.", "addPaymentMethod": "Добавить способ оплаты", + "paymentMethodAdded": "Добавлено", + "@paymentMethodAdded": { + "description": "Текст статуса для способа оплаты, который уже настроен" + }, + "paymentMethodNotAdded": "Не добавлено", + "@paymentMethodNotAdded": { + "description": "Текст статуса для способа оплаты без заполненных реквизитов" + }, + "paymentMethodComingSoon": "Скоро", + "@paymentMethodComingSoon": { + "description": "Текст статуса для способа оплаты, который виден, но пока недоступен" + }, + "paymentMethodDetails": "Реквизиты", + "@paymentMethodDetails": { + "description": "Заголовок над формой выбранного способа оплаты" + }, "makeMain": "Сделать основным", "advanced": "Дополнительно", "fallbackExplanation": "Если основной метод недоступен, мы попробуем следующий включенный метод в списке.", @@ -436,9 +452,16 @@ "walletId": "ID кошелька", "enterWalletId": "Введите ID кошелька", + "ledgerAccountRef": "Референс леджер-счета", + "enterLedgerAccountRef": "Введите референс леджер-счета", + "contraLedgerAccountRef": "Референс контр-счета (необязательно)", "recipients": "Получатели", "recipientName": "Имя получателя", + "recipientNameHint": "например, Алексей Иванов", + "@recipientNameHint": { + "description": "Подсказка в поле имени получателя" + }, "enterRecipientName": "Введите имя получателя", "inn": "ИНН", "enterInn": "Введите ИНН", diff --git a/frontend/pweb/lib/models/button_state.dart b/frontend/pweb/lib/models/button_state.dart deleted file mode 100644 index 859dd78c..00000000 --- a/frontend/pweb/lib/models/button_state.dart +++ /dev/null @@ -1 +0,0 @@ -enum ButtonState { enabled, disabled, loading } diff --git a/frontend/pweb/lib/models/control_state.dart b/frontend/pweb/lib/models/control_state.dart new file mode 100644 index 00000000..73fea6d7 --- /dev/null +++ b/frontend/pweb/lib/models/control_state.dart @@ -0,0 +1 @@ +enum ControlState { enabled, disabled, loading } diff --git a/frontend/pweb/lib/models/payment_method_tile/availability.dart b/frontend/pweb/lib/models/payment_method_tile/availability.dart new file mode 100644 index 00000000..fed60570 --- /dev/null +++ b/frontend/pweb/lib/models/payment_method_tile/availability.dart @@ -0,0 +1 @@ +enum PaymentMethodTileAvailability { added, available, comingSoon } \ No newline at end of file diff --git a/frontend/pweb/lib/models/payment_method_tile/selection.dart b/frontend/pweb/lib/models/payment_method_tile/selection.dart new file mode 100644 index 00000000..e8af2fce --- /dev/null +++ b/frontend/pweb/lib/models/payment_method_tile/selection.dart @@ -0,0 +1 @@ +enum PaymentMethodTileSelection { selected, idle } \ No newline at end of file diff --git a/frontend/pweb/lib/models/seed_state.dart b/frontend/pweb/lib/models/seed_state.dart new file mode 100644 index 00000000..681d1e59 --- /dev/null +++ b/frontend/pweb/lib/models/seed_state.dart @@ -0,0 +1 @@ +enum SeedState { idle, seeded } diff --git a/frontend/pweb/lib/pages/address_book/form/body.dart b/frontend/pweb/lib/pages/address_book/form/body.dart new file mode 100644 index 00000000..8bb1da1f --- /dev/null +++ b/frontend/pweb/lib/pages/address_book/form/body.dart @@ -0,0 +1,120 @@ +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/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 { + final GlobalKey formKey; + final TextEditingController nameCtrl; + final TextEditingController emailCtrl; + final bool isEditing; + final Future Function(AddressBookRecipientFormProvider) onSave; + final VoidCallback onBack; + + const AddressBookRecipientFormBody({ + required this.formKey, + required this.nameCtrl, + required this.emailCtrl, + required this.isEditing, + required this.onSave, + 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 selectedType = _selectedType ?? formState.supportedTypes.first; + return FormView( + formKey: widget.formKey, + nameCtrl: widget.nameCtrl, + emailCtrl: widget.emailCtrl, + types: formState.supportedTypes, + selectedType: selectedType, + selectedIndex: _selectedIndex, + methods: formState.methods, + onMethodSelected: _onMethodSelected, + onMethodAdd: (type) => _onMethodAdd(formState, type), + disabledTypes: disabledPaymentTypes, + onMethodRemove: (index) => _onMethodRemove(formState, index), + onMethodChanged: (index, data) => _onMethodChanged(formState, index, data), + onSave: () => widget.onSave(formState), + isEditing: widget.isEditing, + onBack: widget.onBack, + ); + } +} diff --git a/frontend/pweb/lib/pages/address_book/form/method_tile.dart b/frontend/pweb/lib/pages/address_book/form/method_tile.dart deleted file mode 100644 index aee9aba3..00000000 --- a/frontend/pweb/lib/pages/address_book/form/method_tile.dart +++ /dev/null @@ -1,105 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:pshared/models/payment/methods/data.dart'; -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 { - final PaymentType type; - final String title; - final MethodMap methods; - final ValueChanged onChanged; - - final double spacingM; - final double spacingS; - final double sizeM; - final TextStyle? titleTextStyle; - - const AddressBookPaymentMethodTile({ - super.key, - required this.type, - required this.title, - required this.methods, - required this.onChanged, - this.spacingM = 12, - this.spacingS = 8, - this.sizeM = 20, - this.titleTextStyle, - }); - - @override - State createState() => _AddressBookPaymentMethodTileState(); -} - -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); - final isAdded = widget.methods.containsKey(widget.type); - - return ExpansionTile( - title: Row( - children: [ - Icon( - iconForPaymentType(widget.type), - size: widget.sizeM, - color: isAdded - ? theme.colorScheme.primary - : theme.colorScheme.onSurface, - ), - SizedBox(width: widget.spacingS), - Text( - widget.title, - style: widget.titleTextStyle ?? - theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - color: isAdded ? theme.colorScheme.primary : null, - ), - ), - ], - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (isAdded) - IconButton( - icon: Icon(Icons.delete, color: theme.colorScheme.error), - onPressed: _confirmDelete, - ), - Icon( - isAdded ? Icons.check_circle : Icons.add_circle_outline, - color: isAdded ? theme.colorScheme.primary : null, - ), - ], - ), - children: [ - PaymentMethodForm( - key: ValueKey(widget.type), - selectedType: widget.type, - initialData: widget.methods[widget.type], - onChanged: widget.onChanged, - ), - SizedBox(height: widget.spacingM), - ], - ); - } -} diff --git a/frontend/pweb/lib/pages/address_book/form/page.dart b/frontend/pweb/lib/pages/address_book/form/page.dart index 8a810552..ac939266 100644 --- a/frontend/pweb/lib/pages/address_book/form/page.dart +++ b/frontend/pweb/lib/pages/address_book/form/page.dart @@ -1,22 +1,16 @@ -import 'dart:async'; - 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:pshared/models/recipient/recipient.dart'; -import 'package:pshared/models/recipient/status.dart'; -import 'package:pshared/models/recipient/type.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'; -import 'package:pweb/services/posthog.dart'; -import 'package:pweb/utils/error/snackbar.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/utils/snackbar.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; @@ -35,11 +29,8 @@ class _AddressBookRecipientFormState extends State { final _formKey = GlobalKey(); late TextEditingController _nameCtrl; late TextEditingController _emailCtrl; - RecipientType _type = RecipientType.internal; - RecipientStatus _status = RecipientStatus.ready; - final MethodMap _methods = {}; - late RecipientMethodsCacheProvider _methodsCacheProvider; - bool _hasInitializedMethods = false; + + static const List _supportedTypes = visiblePaymentTypes; @override void initState() { @@ -47,129 +38,60 @@ class _AddressBookRecipientFormState extends State { final r = widget.recipient; _nameCtrl = TextEditingController(text: r?.name ?? ''); _emailCtrl = TextEditingController(text: r?.email ?? ''); - _type = r?.type ?? RecipientType.internal; - _status = r?.status ?? RecipientStatus.ready; - _methodsCacheProvider = context.read() - ..addListener(_onProviderChanged); - if (r != null) { - _methodsCacheProvider.refreshRecipient(r.id); - _syncMethodsFromCache(); - } } - Future _doSave() async { - final recipients = context.read(); - final recipient = widget.recipient == null - ? await recipients.create( - name: _nameCtrl.text, - email: _emailCtrl.text, - ) - : widget.recipient!; - recipients.setCurrentObject(recipient.id); - 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; - } + Map _methodNames(BuildContext context) => { + for (final type in _supportedTypes) type: getPaymentTypeLabel(context, type), + }; - Future _save() async { + Future _save(AddressBookRecipientFormProvider formState) async { final l10n = AppLocalizations.of(context)!; - - if (!_formKey.currentState!.validate()) { - notifyUser(context, l10n.recipientFormValidationError); + if (!_formKey.currentState!.validate() || !formState.hasAnyMethod) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.recipientFormRule)), + ); return; } - if (_methods.isEmpty) { - notifyUser(context, 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))), + ); } + } - unawaited(PosthogService.recipientAddCompleted( - _type, - _status, - _methods.keys.toSet(), - )); - final recipient = await executeActionWithNotification( - context: context, - action: _doSave, - errorMessage: l10n.errorSaveRecipient, - successMessage: l10n.recipientSavedSuccessfully, + @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, + ), + child: AddressBookRecipientFormBody( + formKey: _formKey, + nameCtrl: _nameCtrl, + emailCtrl: _emailCtrl, + isEditing: widget.recipient != null, + onSave: _save, + onBack: () => widget.onSaved?.call(null), + ), ); - - - widget.onSaved?.call(recipient); } - - @override - void dispose() { - _methodsCacheProvider.removeListener(_onProviderChanged); - super.dispose(); - } - - 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( - formKey: _formKey, - nameCtrl: _nameCtrl, - emailCtrl: _emailCtrl, - type: _type, - status: _status, - methods: _methods, - onTypeChanged: (t) => setState(() => _type = t), - onStatusChanged: (s) => setState(() => _status = s), - onMethodsChanged: (type, data) { - setState(() { - if (data != null) { - _methods[type] = data; - } else { - _methods.remove(type); - } - }); - }, - onSave: _save, - isEditing: widget.recipient != null, - onBack: () { - widget.onSaved?.call(null); - }, - ); } diff --git a/frontend/pweb/lib/pages/address_book/form/view.dart b/frontend/pweb/lib/pages/address_book/form/view.dart index 6ecb4b02..27cbf49b 100644 --- a/frontend/pweb/lib/pages/address_book/form/view.dart +++ b/frontend/pweb/lib/pages/address_book/form/view.dart @@ -1,16 +1,15 @@ import 'package:flutter/material.dart'; -import 'package:pshared/models/payment/methods/data.dart'; import 'package:pshared/models/payment/type.dart'; -import 'package:pshared/models/recipient/status.dart'; -import 'package:pshared/models/recipient/type.dart'; +import 'package:pshared/models/payment/methods/data.dart'; +import 'package:pshared/models/recipient/payment_method_draft.dart'; -import 'package:pweb/pages/address_book/form/method_tile.dart'; -import 'package:pweb/pages/address_book/form/widgets/button.dart'; -import 'package:pweb/pages/address_book/form/widgets/email_field.dart'; +import 'package:pweb/pages/address_book/form/widgets/feilds/email.dart'; import 'package:pweb/pages/address_book/form/widgets/header.dart'; -import 'package:pweb/pages/address_book/form/widgets/name_field.dart'; -import 'package:pweb/utils/payment/label.dart'; +import 'package:pweb/pages/address_book/form/widgets/feilds/name.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/address_book/form/widgets/save_button.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; @@ -19,12 +18,15 @@ class FormView extends StatelessWidget { final GlobalKey formKey; final TextEditingController nameCtrl; final TextEditingController emailCtrl; - final RecipientType type; - final RecipientStatus status; - final MethodMap methods; - final ValueChanged onTypeChanged; - final ValueChanged onStatusChanged; - final void Function(PaymentType, PaymentMethodData?) onMethodsChanged; + final List types; + final PaymentType selectedType; + final int? selectedIndex; + final Map> methods; + final void Function(PaymentType type, int index) onMethodSelected; + final ValueChanged onMethodAdd; + final Set disabledTypes; + final ValueChanged onMethodRemove; + final void Function(int, PaymentMethodData) onMethodChanged; final VoidCallback onSave; final bool isEditing; final VoidCallback onBack; @@ -45,16 +47,19 @@ class FormView extends StatelessWidget { required this.formKey, required this.nameCtrl, required this.emailCtrl, - required this.type, - required this.status, + required this.types, + required this.selectedType, + required this.selectedIndex, required this.methods, - required this.onTypeChanged, - required this.onStatusChanged, - required this.onMethodsChanged, + required this.onMethodSelected, + required this.onMethodAdd, + this.disabledTypes = const {}, + required this.onMethodRemove, + required this.onMethodChanged, required this.onSave, required this.isEditing, required this.onBack, - this.maxWidth = 500, + this.maxWidth = 800, this.elevation = 4, this.borderRadius = 16, this.padding = const EdgeInsets.all(20), @@ -69,6 +74,10 @@ class FormView extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); + final entries = methods[selectedType] ?? const []; + final hasSelection = selectedIndex != null && + selectedIndex! >= 0 && + selectedIndex! < entries.length; return Align( alignment: Alignment.topCenter, @@ -102,14 +111,25 @@ class FormView extends StatelessWidget { ?.copyWith(fontWeight: FontWeight.bold), ), SizedBox(height: spacingFields), - ...PaymentType.values.map( - (p) => AddressBookPaymentMethodTile( - type: p, - title: getPaymentTypeLabel(context, p), - methods: methods, - onChanged: (data) => onMethodsChanged(p, data), - ), + PaymentMethodSelectorRow( + types: types, + selectedType: selectedType, + selectedIndex: selectedIndex, + methods: methods, + onSelected: onMethodSelected, + onAdd: onMethodAdd, + disabledTypes: disabledTypes, ), + if (hasSelection) ...[ + SizedBox(height: spacingFields), + PaymentMethodPanel( + selectedType: selectedType, + selectedIndex: selectedIndex!, + entries: entries, + onRemove: onMethodRemove, + onChanged: onMethodChanged, + ), + ], SizedBox(height: spacingSave), SaveButton(onSave: onSave), SizedBox(height: spacingBottom), @@ -122,4 +142,4 @@ class FormView extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/frontend/pweb/lib/pages/address_book/form/widgets/choice_chips.dart b/frontend/pweb/lib/pages/address_book/form/widgets/choice_chips.dart deleted file mode 100644 index fe65c91d..00000000 --- a/frontend/pweb/lib/pages/address_book/form/widgets/choice_chips.dart +++ /dev/null @@ -1,62 +0,0 @@ -import 'package:flutter/material.dart'; - - -class ChoiceChips extends StatelessWidget { - final String label; - final List values; - final T selected; - final ValueChanged onChanged; - - final double spacing; - final double runSpacing; - final double labelSpacing; - - const ChoiceChips({ - super.key, - required this.label, - required this.values, - required this.selected, - required this.onChanged, - this.spacing = 8, - this.runSpacing = 8, - this.labelSpacing = 8, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: theme.textTheme.titleMedium!.copyWith(fontWeight: FontWeight.bold), - ), - SizedBox(height: labelSpacing), - Wrap( - spacing: spacing, - runSpacing: runSpacing, - children: values.map((v) { - final isSelected = v == selected; - return ChoiceChip( - selectedColor: theme.colorScheme.primary, - backgroundColor: theme.colorScheme.onSecondary, - showCheckmark: false, - label: Text( - v.toString().split('.').last, - style: TextStyle( - color: isSelected - ? theme.colorScheme.onSecondary - : theme.colorScheme.inverseSurface, - ), - ), - selected: isSelected, - onSelected: (_) => onChanged(v), - ); - }).toList(), - ), - ], - ); - } -} diff --git a/frontend/pweb/lib/pages/address_book/form/widgets/email_field.dart b/frontend/pweb/lib/pages/address_book/form/widgets/feilds/email.dart similarity index 55% rename from frontend/pweb/lib/pages/address_book/form/widgets/email_field.dart rename to frontend/pweb/lib/pages/address_book/form/widgets/feilds/email.dart index 6def3e20..8db5207d 100644 --- a/frontend/pweb/lib/pages/address_book/form/widgets/email_field.dart +++ b/frontend/pweb/lib/pages/address_book/form/widgets/feilds/email.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; +import 'package:pweb/pages/address_book/form/widgets/feilds/recipient_text_field.dart'; class EmailField extends StatelessWidget { @@ -20,17 +21,16 @@ class EmailField extends StatelessWidget { Widget build(BuildContext context) { final loc = AppLocalizations.of(context)!; - return TextFormField( + return RecipientTextField( controller: controller, - decoration: InputDecoration( - labelText: loc.username, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(borderRadius), - ), - contentPadding: contentPadding, - ), - validator: (v) => - v == null || v.isEmpty ? loc.usernameErrorInvalid : null, + labelText: loc.username, + hintText: loc.usernameHint, + icon: Icons.alternate_email, + keyboardType: TextInputType.emailAddress, + autofillHints: const [AutofillHints.email], + validator: (v) => v == null || v.isEmpty ? loc.usernameErrorInvalid : null, + borderRadius: borderRadius, + contentPadding: contentPadding, ); } } diff --git a/frontend/pweb/lib/pages/address_book/form/widgets/name_field.dart b/frontend/pweb/lib/pages/address_book/form/widgets/feilds/name.dart similarity index 62% rename from frontend/pweb/lib/pages/address_book/form/widgets/name_field.dart rename to frontend/pweb/lib/pages/address_book/form/widgets/feilds/name.dart index 9c1f5f57..65f31512 100644 --- a/frontend/pweb/lib/pages/address_book/form/widgets/name_field.dart +++ b/frontend/pweb/lib/pages/address_book/form/widgets/feilds/name.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; +import 'package:pweb/pages/address_book/form/widgets/feilds/recipient_text_field.dart'; class NameField extends StatelessWidget { @@ -20,16 +21,16 @@ class NameField extends StatelessWidget { Widget build(BuildContext context) { final loc = AppLocalizations.of(context)!; - return TextFormField( + return RecipientTextField( controller: controller, - decoration: InputDecoration( - labelText: loc.recipientName, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(borderRadius), - ), - contentPadding: contentPadding, - ), + labelText: loc.recipientName, + hintText: loc.recipientNameHint, + icon: Icons.person_outline, + textCapitalization: TextCapitalization.words, + autofillHints: const [AutofillHints.name], validator: (v) => v == null || v.isEmpty ? loc.enterRecipientName : null, + borderRadius: borderRadius, + contentPadding: contentPadding, ); } } diff --git a/frontend/pweb/lib/pages/address_book/form/widgets/feilds/recipient_text_field.dart b/frontend/pweb/lib/pages/address_book/form/widgets/feilds/recipient_text_field.dart new file mode 100644 index 00000000..0eecc36b --- /dev/null +++ b/frontend/pweb/lib/pages/address_book/form/widgets/feilds/recipient_text_field.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; + + +class RecipientTextField extends StatelessWidget { + final TextEditingController controller; + final String labelText; + final String hintText; + final String? helperText; + final IconData icon; + final TextInputType keyboardType; + final TextInputAction textInputAction; + final TextCapitalization textCapitalization; + final Iterable? autofillHints; + final FormFieldValidator? validator; + final double borderRadius; + final EdgeInsetsGeometry contentPadding; + + const RecipientTextField({ + super.key, + required this.controller, + required this.labelText, + required this.hintText, + required this.icon, + this.helperText, + this.keyboardType = TextInputType.text, + this.textInputAction = TextInputAction.next, + this.textCapitalization = TextCapitalization.none, + this.autofillHints, + this.validator, + this.borderRadius = 12, + this.contentPadding = const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return ValueListenableBuilder( + valueListenable: controller, + builder: (context, value, _) { + final isEmpty = value.text.trim().isEmpty; + final errorColor = theme.colorScheme.error; + final neutralBorder = theme.colorScheme.onSurface.withAlpha(40); + final borderColor = isEmpty ? errorColor : neutralBorder; + final focusedColor = isEmpty ? errorColor : theme.colorScheme.primary; + + return TextFormField( + controller: controller, + keyboardType: keyboardType, + textInputAction: textInputAction, + textCapitalization: textCapitalization, + autofillHints: autofillHints, + decoration: InputDecoration( + labelText: labelText, + hintText: hintText, + helperText: helperText, + prefixIcon: Icon(icon, color: isEmpty ? errorColor : null), + filled: true, + fillColor: theme.colorScheme.onSecondary, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(borderRadius), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(borderRadius), + borderSide: BorderSide(color: borderColor), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(borderRadius), + borderSide: BorderSide(color: focusedColor, width: 1.4), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(borderRadius), + borderSide: BorderSide(color: errorColor), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(borderRadius), + borderSide: BorderSide(color: errorColor, width: 1.4), + ), + contentPadding: contentPadding, + ), + validator: validator, + ); + }, + ); + } +} 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 new file mode 100644 index 00000000..39d405c0 --- /dev/null +++ b/frontend/pweb/lib/pages/address_book/form/widgets/payment_methods/add_button.dart @@ -0,0 +1,95 @@ + +import 'package:flutter/material.dart'; + +import 'package:pshared/models/payment/type.dart'; + +import 'package:pweb/pages/payment_methods/icon.dart'; +import 'package:pweb/utils/payment/label.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class AddPaymentMethodButton extends StatelessWidget { + final List types; + final Set disabledTypes; + final ValueChanged onAdd; + + static const double _borderRadius = 14; + static const double _iconSize = 18; + static const double _iconTextSpacing = 8; + 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 FontWeight _labelWeight = FontWeight.w600; + + const AddPaymentMethodButton({ + required this.types, + required this.disabledTypes, + required this.onAdd, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l10n = AppLocalizations.of(context)!; + final hasEnabled = types.any((type) => !disabledTypes.contains(type)); + final borderColor = hasEnabled + ? theme.colorScheme.primary.withValues(alpha: 0.5) + : theme.colorScheme.onSurface.withValues(alpha: 0.2); + final textColor = hasEnabled + ? theme.colorScheme.primary + : theme.colorScheme.onSurface.withValues(alpha: 0.4); + + return PopupMenuButton( + enabled: hasEnabled, + onSelected: onAdd, + itemBuilder: (context) => types + .map((type) { + final isDisabled = disabledTypes.contains(type); + return PopupMenuItem( + value: type, + enabled: !isDisabled, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + iconForPaymentType(type), + size: _menuIconSize, + color: isDisabled + ? theme.colorScheme.onSurface.withValues(alpha: 0.4) + : theme.colorScheme.onSurface, + ), + const SizedBox(width: _menuIconTextSpacing), + Text(getPaymentTypeLabel(context, type)), + ], + ), + ); + }) + .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, + ), + ), + ], + ), + ), + ); + } +} 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 new file mode 100644 index 00000000..217ad9f5 --- /dev/null +++ b/frontend/pweb/lib/pages/address_book/form/widgets/payment_methods/panel.dart @@ -0,0 +1,108 @@ +import 'package:flutter/material.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'; + +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 PaymentMethodPanel extends StatelessWidget { + final PaymentType selectedType; + final int selectedIndex; + final List entries; + final ValueChanged onRemove; + final void Function(int, PaymentMethodData) onChanged; + + final double padding; + + const PaymentMethodPanel({ + super.key, + required this.selectedType, + required this.selectedIndex, + required this.entries, + required this.onRemove, + required this.onChanged, + this.padding = 16, + }); + + Future _confirmDelete(BuildContext context, VoidCallback onConfirmed) async { + final l10n = AppLocalizations.of(context)!; + final confirmed = await showConfirmationDialog( + context: context, + title: l10n.delete, + message: l10n.deletePaymentConfirmation, + confirmLabel: l10n.delete, + ); + if (confirmed) { + onConfirmed(); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l10n = AppLocalizations.of(context)!; + final label = l10n.paymentMethodDetails; + final entry = selectedIndex >= 0 && selectedIndex < entries.length + ? entries[selectedIndex] + : null; + + return AnimatedContainer( + duration: const Duration(milliseconds: 3000), + padding: EdgeInsets.all(padding), + decoration: BoxDecoration( + color: theme.colorScheme.onSecondary, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: theme.colorScheme.onSurface.withAlpha(30)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + iconForPaymentType(selectedType), + size: 18, + color: theme.colorScheme.primary, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + label, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + if (entry != null) + TextButton.icon( + onPressed: () => _confirmDelete(context, () => onRemove(selectedIndex)), + icon: Icon(Icons.delete, color: theme.colorScheme.error), + label: Text( + l10n.delete, + style: TextStyle(color: theme.colorScheme.error), + ), + ), + ], + ), + const SizedBox(height: 12), + if (entry != null) + PaymentMethodForm( + key: ValueKey('${selectedType.name}-${entry.existing?.id ?? selectedIndex}-form'), + selectedType: selectedType, + initialData: entry.data, + 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 new file mode 100644 index 00000000..3b8c6fd2 --- /dev/null +++ b/frontend/pweb/lib/pages/address_book/form/widgets/payment_methods/selector_row.dart @@ -0,0 +1,81 @@ +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/pages/address_book/form/widgets/payment_methods/add_button.dart'; +import 'package:pweb/pages/address_book/form/widgets/payment_methods/tile.dart'; + + +class PaymentMethodSelectorRow extends StatelessWidget { + final List types; + final PaymentType selectedType; + final int? selectedIndex; + final Map> methods; + final void Function(PaymentType type, int index) onSelected; + final ValueChanged onAdd; + final Set disabledTypes; + + final double spacing; + final double tilePadding; + final double runSpacing; + + const PaymentMethodSelectorRow({ + super.key, + required this.types, + required this.selectedType, + required this.selectedIndex, + required this.methods, + required this.onSelected, + required this.onAdd, + this.disabledTypes = const {}, + this.spacing = 12, + this.tilePadding = 10, + this.runSpacing = 12, + }); + + @override + Widget build(BuildContext context) { + final tiles = []; + for (final type in types) { + final entries = methods[type] ?? const []; + for (var index = 0; index < entries.length; index += 1) { + final entry = entries[index]; + final isSelected = type == selectedType && selectedIndex == index; + final selection = isSelected + ? PaymentMethodTileSelection.selected + : PaymentMethodTileSelection.idle; + final isAdded = entry.data != null || entry.existing != null; + final availability = isAdded + ? PaymentMethodTileAvailability.added + : PaymentMethodTileAvailability.available; + tiles.add( + PaymentMethodTile( + type: type, + selection: selection, + availability: availability, + padding: tilePadding, + onTap: () => onSelected(type, index), + ), + ); + } + } + + tiles.add( + AddPaymentMethodButton( + types: types, + disabledTypes: disabledTypes, + onAdd: onAdd, + ), + ); + + return Wrap( + spacing: spacing, + runSpacing: runSpacing, + alignment: WrapAlignment.start, + 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 new file mode 100644 index 00000000..46f336ec --- /dev/null +++ b/frontend/pweb/lib/pages/address_book/form/widgets/payment_methods/tile.dart @@ -0,0 +1,113 @@ +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/pages/payment_methods/icon.dart'; +import 'package:pweb/utils/payment/label.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class PaymentMethodTile extends StatelessWidget { + final PaymentType type; + final PaymentMethodTileSelection selection; + final PaymentMethodTileAvailability availability; + final double padding; + final VoidCallback? onTap; + + const PaymentMethodTile({ + required this.type, + required this.selection, + required this.availability, + required this.padding, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l10n = AppLocalizations.of(context)!; + final label = getPaymentTypeLabel(context, type); + final badgeLabel = switch (availability) { + PaymentMethodTileAvailability.added => l10n.paymentMethodAdded, + PaymentMethodTileAvailability.comingSoon => l10n.paymentMethodComingSoon, + PaymentMethodTileAvailability.available => l10n.paymentMethodNotAdded, + }; + final isSelected = selection == PaymentMethodTileSelection.selected; + final isDisabled = availability == PaymentMethodTileAvailability.comingSoon; + final disabledOpacity = isDisabled ? 0.55 : 1.0; + final badgeColor = availability == PaymentMethodTileAvailability.added + ? theme.colorScheme.primary.withValues(alpha: 0.12) + : theme.colorScheme.onSurface.withValues(alpha: isDisabled ? 0.06 : 0.08); + final badgeTextColor = availability == PaymentMethodTileAvailability.added + ? theme.colorScheme.primary + : theme.colorScheme.onSurface.withValues(alpha: isDisabled ? 0.6 : 1.0); + final borderColor = isSelected + ? theme.colorScheme.primary + : theme.colorScheme.onSurface.withValues(alpha: 0.12); + final backgroundColor = isSelected + ? theme.colorScheme.primary.withValues(alpha: 0.08) + : theme.colorScheme.onSecondary; + + return IntrinsicWidth( + child: Opacity( + opacity: disabledOpacity, + child: Material( + color: backgroundColor, + borderRadius: BorderRadius.circular(14), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(14), + child: AnimatedContainer( + duration: const Duration(milliseconds: 160), + padding: EdgeInsets.all(padding), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + border: Border.all(color: borderColor), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + iconForPaymentType(type), + size: 20, + 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, + ), + ), + ], + ), + 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/form/widgets/button.dart b/frontend/pweb/lib/pages/address_book/form/widgets/save_button.dart similarity index 86% rename from frontend/pweb/lib/pages/address_book/form/widgets/button.dart rename to frontend/pweb/lib/pages/address_book/form/widgets/save_button.dart index d0ad1b36..540d0234 100644 --- a/frontend/pweb/lib/pages/address_book/form/widgets/button.dart +++ b/frontend/pweb/lib/pages/address_book/form/widgets/save_button.dart @@ -42,10 +42,10 @@ class SaveButton extends StatelessWidget { child: Text( text ?? AppLocalizations.of(context)!.saveRecipient, style: textStyle ?? - theme.textTheme.labelLarge?.copyWith( - color: theme.colorScheme.onPrimary, - fontWeight: FontWeight.w600, - ), + theme.textTheme.labelLarge?.copyWith( + color: theme.colorScheme.onPrimary, + fontWeight: FontWeight.w600, + ), ), ), ), diff --git a/frontend/pweb/lib/pages/address_book/page/page.dart b/frontend/pweb/lib/pages/address_book/page/page.dart index 927a51d2..85c56e4e 100644 --- a/frontend/pweb/lib/pages/address_book/page/page.dart +++ b/frontend/pweb/lib/pages/address_book/page/page.dart @@ -11,6 +11,7 @@ import 'package:pweb/pages/address_book/page/filter_button.dart'; import 'package:pweb/pages/address_book/page/header.dart'; import 'package:pweb/pages/address_book/page/list.dart'; import 'package:pweb/pages/address_book/page/search.dart'; +import 'package:pweb/utils/recipient/filtering.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; @@ -41,12 +42,13 @@ class RecipientAddressBookPage extends StatefulWidget { class _RecipientAddressBookPageState extends State { late final TextEditingController _searchController; late final FocusNode _searchFocusNode; + RecipientFilter _selectedFilter = RecipientFilter.all; + String _query = ''; @override void initState() { super.initState(); - final provider = context.read(); - _searchController = TextEditingController(text: provider.query); + _searchController = TextEditingController(); _searchFocusNode = FocusNode(); } @@ -57,23 +59,27 @@ class _RecipientAddressBookPageState extends State { super.dispose(); } - void _syncSearchField(RecipientsProvider provider) { - final query = provider.query; - if (_searchController.text == query) return; + void _setQuery(String query) { + setState(() { + _query = query; + }); + } - _searchController.value = TextEditingValue( - text: query, - selection: TextSelection.collapsed(offset: query.length), - ); + void _setFilter(RecipientFilter filter) { + setState(() { + _selectedFilter = filter; + }); } @override Widget build(BuildContext context) { - final loc = AppLocalizations.of(context)!; final provider = context.watch(); - _syncSearchField(provider); - final filteredRecipients = provider.filteredRecipients; + final filteredRecipients = filterRecipients( + recipients: provider.recipients, + filter: _selectedFilter, + query: _query, + ); if (provider.isLoading) { return const Center(child: CircularProgressIndicator()); @@ -91,7 +97,7 @@ class _RecipientAddressBookPageState extends State { RecipientSearchField( controller: _searchController, focusNode: _searchFocusNode, - onChanged: provider.setQuery, + onChanged: _setQuery, ), const SizedBox(height: RecipientAddressBookPage._bigBox), Row( @@ -99,26 +105,26 @@ class _RecipientAddressBookPageState extends State { RecipientFilterButton( text: loc.allStatus, filter: RecipientFilter.all, - selected: provider.selectedFilter, - onTap: provider.setFilter, + selected: _selectedFilter, + onTap: _setFilter, ), RecipientFilterButton( text: loc.readyStatus, filter: RecipientFilter.ready, - selected: provider.selectedFilter, - onTap: provider.setFilter, + selected: _selectedFilter, + onTap: _setFilter, ), RecipientFilterButton( text: loc.registeredStatus, filter: RecipientFilter.registered, - selected: provider.selectedFilter, - onTap: provider.setFilter, + selected: _selectedFilter, + onTap: _setFilter, ), RecipientFilterButton( text: loc.notRegisteredStatus, filter: RecipientFilter.notRegistered, - selected: provider.selectedFilter, - onTap: provider.setFilter, + selected: _selectedFilter, + onTap: _setFilter, ), ], ), @@ -145,4 +151,4 @@ class _RecipientAddressBookPageState extends State { ], ); } -} \ No newline at end of file +} 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 afc554b9..1e3c4f0b 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 @@ -3,14 +3,13 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:pshared/models/recipient/recipient.dart'; -import 'package:pshared/provider/organizations.dart'; -import 'package:pshared/provider/recipient/pmethods.dart'; +import 'package:pshared/provider/recipient/methods_cache.dart'; import 'package:pweb/pages/address_book/page/recipient/info_row.dart'; import 'package:pweb/utils/payment/label.dart'; -class RecipientPaymentRow extends StatefulWidget { +class RecipientPaymentRow extends StatelessWidget { final Recipient recipient; final double spacing; @@ -20,40 +19,19 @@ class RecipientPaymentRow extends StatefulWidget { this.spacing = 18 }); - @override - State createState() => _RecipientPaymentRowState(); -} - -class _RecipientPaymentRowState extends State { - late final PaymentMethodsProvider _methodsProvider; - - @override - void initState() { - super.initState(); - _methodsProvider = PaymentMethodsProvider() - ..addListener(_onProviderChanged) - ..loadMethods( - context.read(), - widget.recipient.id, - ); - } - - @override - void dispose() { - _methodsProvider.removeListener(_onProviderChanged); - _methodsProvider.dispose(); - super.dispose(); - } - - void _onProviderChanged() => setState(() {}); - @override Widget build(BuildContext context) { - if (!_methodsProvider.isReady) return const Center(child: CircularProgressIndicator()); + final cacheProvider = context.watch(); + final recipientId = recipient.id; + final isLoading = cacheProvider.isLoadingFor(recipientId); + + if (isLoading && !cacheProvider.hasMethodsFor(recipientId)) { + return const Center(child: CircularProgressIndicator()); + } return Row( - spacing: widget.spacing, - children: _methodsProvider.methods.map((m) => RecipientAddressBookInfoRow( + spacing: spacing, + children: cacheProvider.methodsForRecipient(recipientId).map((m) => RecipientAddressBookInfoRow( type: m.type, value: getPaymentTypeDescription(context, m), )).toList(), diff --git a/frontend/pweb/lib/pages/dashboard/payouts/single/address_book/widget.dart b/frontend/pweb/lib/pages/dashboard/payouts/single/address_book/widget.dart index ad8fdb7e..519936fd 100644 --- a/frontend/pweb/lib/pages/dashboard/payouts/single/address_book/widget.dart +++ b/frontend/pweb/lib/pages/dashboard/payouts/single/address_book/widget.dart @@ -9,6 +9,7 @@ import 'package:pweb/pages/address_book/page/search.dart'; import 'package:pweb/pages/dashboard/payouts/single/address_book/long_list/widget.dart'; import 'package:pweb/pages/dashboard/payouts/single/address_book/placeholder.dart'; import 'package:pweb/pages/dashboard/payouts/single/address_book/short_list.dart'; +import 'package:pweb/utils/recipient/filtering.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; @@ -34,18 +35,14 @@ class _AddressBookPayoutState extends State { final FocusNode _searchFocusNode = FocusNode(); late final TextEditingController _searchController; + String _query = ''; bool get _isExpanded => _searchFocusNode.hasFocus; @override void initState() { super.initState(); - final provider = context.read(); - _searchController = TextEditingController(text: provider.query); - - _searchController.addListener(() { - provider.setQuery(_searchController.text); - }); + _searchController = TextEditingController(); } @override @@ -55,12 +52,21 @@ class _AddressBookPayoutState extends State { super.dispose(); } + void _setQuery(String query) { + setState(() { + _query = query; + }); + } + @override Widget build(BuildContext context) { final loc = AppLocalizations.of(context)!; final provider = context.watch(); final recipients = provider.recipients; - final filteredRecipients = provider.filteredRecipients; + final filteredRecipients = filterRecipients( + recipients: recipients, + query: _query, + ); if (provider.isLoading) { return const Center(child: CircularProgressIndicator()); @@ -86,7 +92,7 @@ class _AddressBookPayoutState extends State { RecipientSearchField( controller: _searchController, focusNode: _searchFocusNode, - onChanged: (_) {}, + onChanged: _setQuery, ), const SizedBox(height: _spacingBetween), Expanded( @@ -110,4 +116,4 @@ class _AddressBookPayoutState extends State { ), ); } -} \ No newline at end of file +} diff --git a/frontend/pweb/lib/pages/dashboard/payouts/single/new_recipient/payout.dart b/frontend/pweb/lib/pages/dashboard/payouts/single/new_recipient/payout.dart index 9523db6e..cdad3294 100644 --- a/frontend/pweb/lib/pages/dashboard/payouts/single/new_recipient/payout.dart +++ b/frontend/pweb/lib/pages/dashboard/payouts/single/new_recipient/payout.dart @@ -2,7 +2,9 @@ import 'package:flutter/material.dart'; import 'package:pshared/models/payment/type.dart'; +import 'package:pweb/models/control_state.dart'; import 'package:pweb/pages/dashboard/payouts/single/new_recipient/type.dart'; +import 'package:pweb/utils/payment/availability.dart'; class SinglePayout extends StatelessWidget { @@ -17,7 +19,7 @@ class SinglePayout extends StatelessWidget { @override Widget build(BuildContext context) { - final paymentTypes = PaymentType.values; + final paymentTypes = visiblePaymentTypes; final dividerColor = Theme.of(context).dividerColor; return SizedBox( @@ -36,6 +38,9 @@ class SinglePayout extends StatelessWidget { PaymentTypeTile( type: paymentTypes[i], onSelected: onGoToPayment, + state: disabledPaymentTypes.contains(paymentTypes[i]) + ? ControlState.disabled + : ControlState.enabled, ), if (i < paymentTypes.length - 1) Padding( diff --git a/frontend/pweb/lib/pages/dashboard/payouts/single/new_recipient/type.dart b/frontend/pweb/lib/pages/dashboard/payouts/single/new_recipient/type.dart index a62bc6b4..c191fda1 100644 --- a/frontend/pweb/lib/pages/dashboard/payouts/single/new_recipient/type.dart +++ b/frontend/pweb/lib/pages/dashboard/payouts/single/new_recipient/type.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:pshared/models/payment/type.dart'; +import 'package:pweb/models/control_state.dart'; import 'package:pweb/pages/payment_methods/icon.dart'; import 'package:pweb/utils/payment/label.dart'; @@ -9,30 +10,53 @@ import 'package:pweb/utils/payment/label.dart'; class PaymentTypeTile extends StatelessWidget { final PaymentType type; final void Function(PaymentType type) onSelected; + final ControlState state; const PaymentTypeTile({ super.key, required this.type, required this.onSelected, + this.state = ControlState.enabled, }); @override Widget build(BuildContext context) { final label = getPaymentTypeLabel(context, type); + final theme = Theme.of(context); + final isEnabled = state == ControlState.enabled; + final isDisabled = state == ControlState.disabled; + final isLoading = state == ControlState.loading; + final textColor = isDisabled + ? theme.colorScheme.onSurface.withValues(alpha: 0.55) + : theme.colorScheme.onSurface; + return InkWell( borderRadius: BorderRadius.circular(8), - onTap: () => onSelected(type), + onTap: isEnabled ? () => onSelected(type) : null, child: Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Row( children: [ - Icon(iconForPaymentType(type), size: 24), + Icon(iconForPaymentType(type), size: 24, color: textColor), const SizedBox(width: 12), Text( label, - style: Theme.of(context).textTheme.bodyMedium, + style: theme.textTheme.bodyMedium?.copyWith(color: textColor), ), + if (isLoading) ...[ + const SizedBox(width: 12), + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + theme.colorScheme.primary, + ), + ), + ), + ], ], ), ), diff --git a/frontend/pweb/lib/pages/payment_methods/add/ledger.dart b/frontend/pweb/lib/pages/payment_methods/add/ledger.dart new file mode 100644 index 00000000..eb793ca1 --- /dev/null +++ b/frontend/pweb/lib/pages/payment_methods/add/ledger.dart @@ -0,0 +1,121 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/models/payment/methods/ledger.dart'; + +import 'package:pweb/utils/text_field_styles.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class LedgerForm extends StatefulWidget { + final void Function(LedgerPaymentMethod) onChanged; + final LedgerPaymentMethod? initialData; + final bool isEditable; + + const LedgerForm({ + super.key, + required this.onChanged, + this.initialData, + required this.isEditable, + }); + + @override + State createState() => _LedgerFormState(); +} + +class _LedgerFormState extends State { + final _formKey = GlobalKey(); + + late final TextEditingController _ledgerAccountRefController; + late final TextEditingController _contraLedgerAccountRefController; + + @override + void initState() { + super.initState(); + _ledgerAccountRefController = TextEditingController( + text: widget.initialData?.ledgerAccountRef ?? '', + ); + _contraLedgerAccountRefController = TextEditingController( + text: widget.initialData?.contraLedgerAccountRef ?? '', + ); + + WidgetsBinding.instance.addPostFrameCallback((_) => _emitIfValid()); + } + + void _emitIfValid() { + if (_formKey.currentState?.validate() ?? false) { + final contraRef = _contraLedgerAccountRefController.text.trim(); + widget.onChanged( + LedgerPaymentMethod( + ledgerAccountRef: _ledgerAccountRefController.text.trim(), + contraLedgerAccountRef: contraRef.isEmpty ? null : contraRef, + ), + ); + } + } + + @override + void didUpdateWidget(covariant LedgerForm oldWidget) { + super.didUpdateWidget(oldWidget); + final newData = widget.initialData; + final oldData = oldWidget.initialData; + + if (newData == null && oldData != null) { + _ledgerAccountRefController.clear(); + _contraLedgerAccountRefController.clear(); + return; + } + + if (newData != null && newData != oldData) { + final hasLedgerRefChange = newData.ledgerAccountRef != _ledgerAccountRefController.text; + final hasContraRefChange = (newData.contraLedgerAccountRef ?? '') != _contraLedgerAccountRefController.text; + + if (hasLedgerRefChange) _ledgerAccountRefController.text = newData.ledgerAccountRef; + if (hasContraRefChange) { + _contraLedgerAccountRefController.text = newData.contraLedgerAccountRef ?? ''; + } + + if (hasLedgerRefChange || hasContraRefChange) { + WidgetsBinding.instance.addPostFrameCallback((_) => _emitIfValid()); + } + } + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + + return Form( + key: _formKey, + onChanged: _emitIfValid, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextFormField( + readOnly: !widget.isEditable, + controller: _ledgerAccountRefController, + decoration: getInputDecoration(context, l10n.ledgerAccountRef, widget.isEditable), + style: getTextFieldStyle(context, widget.isEditable), + validator: (val) => (val == null || val.trim().isEmpty) + ? l10n.enterLedgerAccountRef + : null, + ), + const SizedBox(height: 12), + TextFormField( + readOnly: !widget.isEditable, + controller: _contraLedgerAccountRefController, + decoration: getInputDecoration(context, l10n.contraLedgerAccountRef, widget.isEditable), + style: getTextFieldStyle(context, widget.isEditable), + ), + ], + ), + ); + } + + @override + void dispose() { + _ledgerAccountRefController.dispose(); + _contraLedgerAccountRefController.dispose(); + super.dispose(); + } +} diff --git a/frontend/pweb/lib/pages/payment_methods/add/method_selector.dart b/frontend/pweb/lib/pages/payment_methods/add/method_selector.dart index 09fb6a27..4423c675 100644 --- a/frontend/pweb/lib/pages/payment_methods/add/method_selector.dart +++ b/frontend/pweb/lib/pages/payment_methods/add/method_selector.dart @@ -9,11 +9,15 @@ import 'package:pweb/generated/i18n/app_localizations.dart'; class PaymentMethodTypeSelector extends StatelessWidget { final PaymentType? value; + final List types; + final Set disabledTypes; final ValueChanged onChanged; const PaymentMethodTypeSelector({ super.key, required this.value, + required this.types, + this.disabledTypes = const {}, required this.onChanged, }); @@ -24,12 +28,18 @@ class PaymentMethodTypeSelector extends StatelessWidget { return DropdownButtonFormField( initialValue: value, decoration: InputDecoration(labelText: l10n.paymentType), - items: PaymentType.values.map((type) { + items: types.map((type) { final label = getPaymentTypeLabel(context, type); - return DropdownMenuItem(value: type, child: Text(label)); + final isDisabled = disabledTypes.contains(type); + final effectiveLabel = isDisabled ? '$label - ${l10n.paymentMethodComingSoon}' : label; + return DropdownMenuItem( + value: type, + enabled: !isDisabled, + child: Text(effectiveLabel), + ); }).toList(), onChanged: onChanged, validator: (val) => val == null ? l10n.selectPaymentType : null, ); } -} \ No newline at end of file +} diff --git a/frontend/pweb/lib/pages/payment_methods/add/widget.dart b/frontend/pweb/lib/pages/payment_methods/add/widget.dart index a1f3e90e..11f65165 100644 --- a/frontend/pweb/lib/pages/payment_methods/add/widget.dart +++ b/frontend/pweb/lib/pages/payment_methods/add/widget.dart @@ -4,6 +4,7 @@ import 'package:pshared/models/payment/type.dart'; import 'package:pweb/pages/payment_methods/add/method_selector.dart'; import 'package:pweb/pages/payment_methods/form.dart'; +import 'package:pweb/utils/payment/availability.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; @@ -46,6 +47,8 @@ class _AddPaymentMethodDialogState extends State { children: [ PaymentMethodTypeSelector( value: _selectedType, + types: visiblePaymentTypes, + disabledTypes: disabledPaymentTypes, onChanged: (val) => setState(() { _selectedType = val; _currentMethod = null; @@ -73,4 +76,4 @@ class _AddPaymentMethodDialogState extends State { ], ); } -} \ No newline at end of file +} diff --git a/frontend/pweb/lib/pages/payment_methods/form.dart b/frontend/pweb/lib/pages/payment_methods/form.dart index cee66f40..886dc386 100644 --- a/frontend/pweb/lib/pages/payment_methods/form.dart +++ b/frontend/pweb/lib/pages/payment_methods/form.dart @@ -4,6 +4,7 @@ import 'package:pshared/models/payment/methods/card.dart'; import 'package:pshared/models/payment/methods/crypto_address.dart'; import 'package:pshared/models/payment/methods/data.dart'; import 'package:pshared/models/payment/methods/iban.dart'; +import 'package:pshared/models/payment/methods/ledger.dart'; import 'package:pshared/models/payment/methods/russian_bank.dart'; import 'package:pshared/models/payment/methods/wallet.dart'; import 'package:pshared/models/payment/type.dart'; @@ -11,6 +12,7 @@ import 'package:pshared/models/payment/type.dart'; import 'package:pweb/pages/payment_methods/add/card.dart'; import 'package:pweb/pages/payment_methods/add/crypto_address.dart'; import 'package:pweb/pages/payment_methods/add/iban.dart'; +import 'package:pweb/pages/payment_methods/add/ledger.dart'; import 'package:pweb/pages/payment_methods/add/russian_bank.dart'; import 'package:pweb/pages/payment_methods/add/wallet.dart'; @@ -57,6 +59,11 @@ class PaymentMethodForm extends StatelessWidget { initialData: initialData as CryptoAddressPaymentMethod?, isEditable: isEditable, ), + PaymentType.ledger => LedgerForm( + onChanged: onChanged, + initialData: initialData as LedgerPaymentMethod?, + isEditable: isEditable, + ), _ => const SizedBox.shrink(), }; } diff --git a/frontend/pweb/lib/pages/payment_methods/page.dart b/frontend/pweb/lib/pages/payment_methods/page.dart index 2cfd9729..6b1770de 100644 --- a/frontend/pweb/lib/pages/payment_methods/page.dart +++ b/frontend/pweb/lib/pages/payment_methods/page.dart @@ -11,6 +11,7 @@ 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/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'; @@ -35,6 +36,8 @@ class PaymentPage extends StatefulWidget { class _PaymentPageState extends State { late final TextEditingController _searchController; late final FocusNode _searchFocusNode; + Recipient? _previousRecipient; + String _query = ''; @override void initState() { @@ -58,17 +61,25 @@ class _PaymentPageState extends State { } void _handleSearchChanged(String query) { - context.read().setQuery(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(); } @@ -76,7 +87,9 @@ class _PaymentPageState extends State { void _clearSearchField() { _searchController.clear(); _searchFocusNode.unfocus(); - context.read().setQuery(''); + setState(() { + _query = ''; + }); } void _handleSendPayment() { @@ -97,16 +110,21 @@ class _PaymentPageState extends State { @override Widget build(BuildContext context) { final methodsProvider = context.watch(); - final recipientProvider = context.read(); - final recipient = context.select( - (provider) => provider.currentObject, + final recipientProvider = context.watch(); + final recipient = recipientProvider.currentObject; + final filteredRecipients = filterRecipients( + recipients: recipientProvider.recipients, + query: _query, ); return PaymentPageBody( onBack: widget.onBack, fallbackDestination: widget.fallbackDestination, recipient: recipient, + previousRecipient: _previousRecipient, recipientProvider: recipientProvider, + searchQuery: _query, + filteredRecipients: filteredRecipients, methodsProvider: methodsProvider, onWalletSelected: context.read().selectWallet, searchController: _searchController, diff --git a/frontend/pweb/lib/pages/payment_methods/payment_page/body.dart b/frontend/pweb/lib/pages/payment_methods/payment_page/body.dart index 97416063..954d360d 100644 --- a/frontend/pweb/lib/pages/payment_methods/payment_page/body.dart +++ b/frontend/pweb/lib/pages/payment_methods/payment_page/body.dart @@ -15,7 +15,10 @@ 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; final PaymentMethodsProvider methodsProvider; final ValueChanged onWalletSelected; final PayoutDestination fallbackDestination; @@ -30,7 +33,10 @@ class PaymentPageBody extends StatelessWidget { super.key, required this.onBack, required this.recipient, + required this.previousRecipient, required this.recipientProvider, + required this.searchQuery, + required this.filteredRecipients, required this.methodsProvider, required this.onWalletSelected, required this.fallbackDestination, @@ -59,7 +65,10 @@ class PaymentPageBody extends StatelessWidget { return PaymentPageContent( onBack: onBack, recipient: recipient, + previousRecipient: previousRecipient, recipientProvider: recipientProvider, + searchQuery: searchQuery, + filteredRecipients: filteredRecipients, onWalletSelected: onWalletSelected, fallbackDestination: fallbackDestination, searchController: searchController, diff --git a/frontend/pweb/lib/pages/payment_methods/payment_page/content.dart b/frontend/pweb/lib/pages/payment_methods/payment_page/content.dart index 1932138d..c2f52577 100644 --- a/frontend/pweb/lib/pages/payment_methods/payment_page/content.dart +++ b/frontend/pweb/lib/pages/payment_methods/payment_page/content.dart @@ -25,7 +25,10 @@ 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; @@ -39,7 +42,10 @@ class PaymentPageContent extends StatelessWidget { 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, @@ -99,8 +105,11 @@ class PaymentPageContent extends StatelessWidget { SizedBox(height: dimensions.paddingXLarge), RecipientSection( recipient: recipient, + previousRecipient: previousRecipient, dimensions: dimensions, recipientProvider: recipientProvider, + searchQuery: searchQuery, + filteredRecipients: filteredRecipients, searchController: searchController, searchFocusNode: searchFocusNode, onSearchChanged: onSearchChanged, diff --git a/frontend/pweb/lib/pages/payment_methods/payment_page/page.dart b/frontend/pweb/lib/pages/payment_methods/payment_page/page.dart index dd6101fb..5d7d4ded 100644 --- a/frontend/pweb/lib/pages/payment_methods/payment_page/page.dart +++ b/frontend/pweb/lib/pages/payment_methods/payment_page/page.dart @@ -21,7 +21,10 @@ 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; @@ -35,7 +38,10 @@ class PaymentPageContent extends StatelessWidget { 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, @@ -82,8 +88,11 @@ class PaymentPageContent extends StatelessWidget { SizedBox(height: dimensions.paddingXLarge), RecipientSection( recipient: recipient, + previousRecipient: previousRecipient, dimensions: dimensions, recipientProvider: recipientProvider, + searchQuery: searchQuery, + filteredRecipients: filteredRecipients, searchController: searchController, searchFocusNode: searchFocusNode, onSearchChanged: onSearchChanged, diff --git a/frontend/pweb/lib/pages/payment_methods/payment_page/send_button.dart b/frontend/pweb/lib/pages/payment_methods/payment_page/send_button.dart index dd0cab66..a5542fd6 100644 --- a/frontend/pweb/lib/pages/payment_methods/payment_page/send_button.dart +++ b/frontend/pweb/lib/pages/payment_methods/payment_page/send_button.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:pweb/models/button_state.dart'; +import 'package:pweb/models/control_state.dart'; import 'package:pweb/utils/dimensions.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; @@ -8,12 +8,12 @@ import 'package:pweb/generated/i18n/app_localizations.dart'; class SendButton extends StatelessWidget { final VoidCallback onPressed; - final ButtonState state; + final ControlState state; const SendButton({ super.key, required this.onPressed, - this.state = ButtonState.enabled, + this.state = ControlState.enabled, }); @override @@ -21,8 +21,8 @@ class SendButton extends StatelessWidget { final theme = Theme.of(context); final dimensions = AppDimensions(); - final isEnabled = state == ButtonState.enabled; - final isLoading = state == ButtonState.loading; + final isEnabled = state == ControlState.enabled; + final isLoading = state == ControlState.loading; final backgroundColor = isEnabled || isLoading ? theme.colorScheme.primary : theme.colorScheme.onSurface.withValues(alpha: 0.12); 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 index bb1228ee..745cd046 100644 --- a/frontend/pweb/lib/pages/payment_methods/widgets/payment_info_section.dart +++ b/frontend/pweb/lib/pages/payment_methods/widgets/payment_info_section.dart @@ -8,6 +8,7 @@ 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/generated/i18n/app_localizations.dart'; @@ -26,7 +27,10 @@ class PaymentInfoSection extends StatelessWidget { final loc = AppLocalizations.of(context)!; final flowProvider = context.watch(); final hasRecipient = flowProvider.hasRecipient; - final MethodMap resolvedAvailableTypes = flowProvider.availableTypes; + final MethodMap resolvedAvailableTypes = filterVisiblePaymentTypes(flowProvider.availableTypes); + final disabledTypesForSelection = hasRecipient + ? disabledPaymentTypes.difference(resolvedAvailableTypes.keys.toSet()) + : disabledPaymentTypes; if (hasRecipient && resolvedAvailableTypes.isEmpty) { return Text(loc.recipientNoPaymentDetails); @@ -42,6 +46,7 @@ class PaymentInfoSection extends StatelessWidget { PaymentTypeSelector( availableTypes: resolvedAvailableTypes, selectedType: selectedType, + disabledTypes: disabledTypesForSelection, onSelected: (type) => flowProvider.selectType( type, resetManualData: !hasRecipient, diff --git a/frontend/pweb/lib/pages/payment_methods/widgets/recipient_section.dart b/frontend/pweb/lib/pages/payment_methods/widgets/recipient_section.dart index c2ad7891..72458511 100644 --- a/frontend/pweb/lib/pages/payment_methods/widgets/recipient_section.dart +++ b/frontend/pweb/lib/pages/payment_methods/widgets/recipient_section.dart @@ -14,8 +14,11 @@ 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; + final List filteredRecipients; final TextEditingController searchController; final FocusNode searchFocusNode; final ValueChanged onSearchChanged; @@ -25,8 +28,11 @@ class RecipientSection extends StatelessWidget { const RecipientSection({ super.key, required this.recipient, + required this.previousRecipient, required this.dimensions, required this.recipientProvider, + required this.searchQuery, + required this.filteredRecipients, required this.searchController, required this.searchFocusNode, required this.onSearchChanged, @@ -48,8 +54,8 @@ class RecipientSection extends StatelessWidget { return AnimatedBuilder( animation: recipientProvider, builder: (context, _) { - final previousRecipient = recipientProvider.previousRecipient; - final hasQuery = recipientProvider.query.isNotEmpty; + final hasQuery = searchQuery.isNotEmpty; + final prev = previousRecipient; return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -61,15 +67,15 @@ class RecipientSection extends StatelessWidget { onChanged: onSearchChanged, focusNode: searchFocusNode, ), - if (previousRecipient != null) ...[ + if (prev != null) ...[ SizedBox(height: dimensions.paddingSmall), ListTile( dense: true, contentPadding: EdgeInsets.zero, leading: const Icon(Icons.undo), title: Text(loc.back), - subtitle: Text(previousRecipient.name), - onTap: () => onRecipientSelected(previousRecipient), + subtitle: Text(prev.name), + onTap: () => onRecipientSelected(prev), ), ], if (hasQuery) ...[ @@ -77,6 +83,7 @@ class RecipientSection extends StatelessWidget { RecipientSearchResults( dimensions: dimensions, recipientProvider: recipientProvider, + results: filteredRecipients, onRecipientSelected: onRecipientSelected, ), ], diff --git a/frontend/pweb/lib/pages/payment_methods/widgets/search.dart b/frontend/pweb/lib/pages/payment_methods/widgets/search.dart index c23dda43..fa794f21 100644 --- a/frontend/pweb/lib/pages/payment_methods/widgets/search.dart +++ b/frontend/pweb/lib/pages/payment_methods/widgets/search.dart @@ -11,12 +11,14 @@ import 'package:pweb/generated/i18n/app_localizations.dart'; class RecipientSearchResults extends StatelessWidget { final AppDimensions dimensions; final RecipientsProvider recipientProvider; + final List results; final ValueChanged onRecipientSelected; const RecipientSearchResults({ super.key, required this.dimensions, required this.recipientProvider, + required this.results, required this.onRecipientSelected, }); @@ -38,8 +40,6 @@ class RecipientSearchResults extends StatelessWidget { return Text(loc.noRecipientsYet); } - final results = recipientProvider.filteredRecipients; - if (results.isEmpty) { return Text(loc.noRecipientsFound); } diff --git a/frontend/pweb/lib/pages/verification/page.dart b/frontend/pweb/lib/pages/verification/page.dart index baf5fcb2..c0e901fe 100644 --- a/frontend/pweb/lib/pages/verification/page.dart +++ b/frontend/pweb/lib/pages/verification/page.dart @@ -9,6 +9,7 @@ import 'package:pweb/app/router/pages.dart'; import 'package:pweb/pages/errors/error.dart'; import 'package:pweb/pages/status/success.dart'; import 'package:pweb/pages/with_footer.dart'; + import 'package:pweb/generated/i18n/app_localizations.dart'; diff --git a/frontend/pweb/lib/providers/address_book_recipient_form.dart b/frontend/pweb/lib/providers/address_book_recipient_form.dart new file mode 100644 index 00000000..73f55247 --- /dev/null +++ b/frontend/pweb/lib/providers/address_book_recipient_form.dart @@ -0,0 +1,170 @@ +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: [], + }; + + 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, + }) async { + final recipientsProvider = _recipientsProvider; + final methodsCache = _methodsCache; + if (recipientsProvider == null || methodsCache == null) { + throw StateError('Form provider dependencies are not set'); + } + + final trimmedName = name.trim(); + final trimmedEmail = email.trim(); + + if (_recipient == null) { + final created = await recipientsProvider.create( + name: trimmedName, + email: trimmedEmail, + ); + await methodsCache.syncRecipientMethods( + recipientId: created.id, + methods: allDrafts(), + names: methodNames, + ); + return created; + } + + final updated = _recipient.copyWith( + describable: newDescribable( + name: trimmedName, + description: _recipient.description, + ), + email: trimmedEmail, + ); + await recipientsProvider.update(updated.toDTO().toJson()); + await methodsCache.syncRecipientMethods( + recipientId: updated.id, + methods: allDrafts(), + names: methodNames, + ); + return updated; + } + + @override + void dispose() { + _methodsCache?.removeListener(_handleCacheChange); + super.dispose(); + } +} diff --git a/frontend/pweb/lib/utils/payment/availability.dart b/frontend/pweb/lib/utils/payment/availability.dart new file mode 100644 index 00000000..b444ebf5 --- /dev/null +++ b/frontend/pweb/lib/utils/payment/availability.dart @@ -0,0 +1,26 @@ +import 'package:pshared/models/payment/methods/data.dart'; +import 'package:pshared/models/payment/type.dart'; + +const List enabledPaymentTypes = [ + PaymentType.card, + PaymentType.ledger, + PaymentType.externalChain, +]; + +const List previewPaymentTypes = [ + PaymentType.bankAccount, +]; + +const List visiblePaymentTypes = [ + ...enabledPaymentTypes, + ...previewPaymentTypes, +]; + +const Set disabledPaymentTypes = { + PaymentType.bankAccount, +}; + +MethodMap filterVisiblePaymentTypes(MethodMap source) => { + for (final type in visiblePaymentTypes) + if (source.containsKey(type)) type: source[type], + }; diff --git a/frontend/pweb/lib/utils/payment/selector_type.dart b/frontend/pweb/lib/utils/payment/selector_type.dart index ff0e5f1d..6bfbd993 100644 --- a/frontend/pweb/lib/utils/payment/selector_type.dart +++ b/frontend/pweb/lib/utils/payment/selector_type.dart @@ -10,12 +10,14 @@ class PaymentTypeSelector extends StatelessWidget { final MethodMap availableTypes; final PaymentType selectedType; final ValueChanged onSelected; + final Set disabledTypes; const PaymentTypeSelector({ super.key, required this.availableTypes, required this.selectedType, required this.onSelected, + this.disabledTypes = const {}, }); static const double _chipSpacing = 12.0; @@ -30,14 +32,16 @@ class PaymentTypeSelector extends StatelessWidget { runSpacing: _chipSpacing, children: availableTypes.keys.map((type) { final isSelected = selectedType == type; + final isDisabled = disabledTypes.contains(type); + final labelColor = isSelected + ? theme.colorScheme.onPrimary + : theme.colorScheme.onSurface.withValues(alpha: isDisabled ? 0.55 : 1.0); return ChoiceChip( label: Text( getPaymentTypeLabel(context, type), style: theme.textTheme.titleMedium!.copyWith( - color: isSelected - ? theme.colorScheme.onPrimary - : theme.colorScheme.onSurface, + color: labelColor, ), ), selected: isSelected, @@ -47,7 +51,7 @@ class PaymentTypeSelector extends StatelessWidget { shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(_chipBorderRadius), ), - onSelected: (_) => onSelected(type), + onSelected: isDisabled ? null : (_) => onSelected(type), ); }).toList(), ); diff --git a/frontend/pweb/lib/utils/recipient/filtering.dart b/frontend/pweb/lib/utils/recipient/filtering.dart new file mode 100644 index 00000000..4b690946 --- /dev/null +++ b/frontend/pweb/lib/utils/recipient/filtering.dart @@ -0,0 +1,28 @@ +import 'package:pshared/models/recipient/filter.dart'; +import 'package:pshared/models/recipient/recipient.dart'; +import 'package:pshared/models/recipient/status.dart'; + + +List filterRecipients({ + required List recipients, + RecipientFilter filter = RecipientFilter.all, + String query = '', +}) { + var filtered = recipients.where((r) { + switch (filter) { + case RecipientFilter.ready: + return r.status == RecipientStatus.ready; + case RecipientFilter.registered: + return r.status == RecipientStatus.registered; + case RecipientFilter.notRegistered: + return r.status == RecipientStatus.notRegistered; + case RecipientFilter.all: + return true; + } + }).toList(); + + final normalizedQuery = query.trim().toLowerCase(); + if (normalizedQuery.isEmpty) return filtered; + + return filtered.where((r) => r.matchesQuery(normalizedQuery)).toList(); +} -- 2.49.1 From a3908cebba04d466590e3f199596608d176f9265 Mon Sep 17 00:00:00 2001 From: Arseni Date: Thu, 29 Jan 2026 19:55:01 +0300 Subject: [PATCH 2/2] hotfix for payment page to see more then one payment type --- .../pshared/lib/provider/payment/flow.dart | 64 ++++++++++++++++++- .../widgets/payment_info_section.dart | 31 ++++++++- 2 files changed, 91 insertions(+), 4 deletions(-) diff --git a/frontend/pshared/lib/provider/payment/flow.dart b/frontend/pshared/lib/provider/payment/flow.dart index 1f805523..f0760070 100644 --- a/frontend/pshared/lib/provider/payment/flow.dart +++ b/frontend/pshared/lib/provider/payment/flow.dart @@ -15,6 +15,7 @@ class PaymentFlowProvider extends ChangeNotifier { PaymentMethodData? _manualPaymentData; List _recipientMethods = []; Recipient? _recipient; + String? _selectedMethodId; PaymentFlowProvider({ required PaymentType initialType, @@ -25,9 +26,14 @@ class PaymentFlowProvider extends ChangeNotifier { PaymentType get selectedType => _selectedType; PaymentMethodData? get manualPaymentData => _manualPaymentData; Recipient? get recipient => _recipient; - PaymentMethod? get selectedMethod => hasRecipient - ? _recipientMethods.firstWhereOrNull((method) => method.type == _selectedType) - : null; + PaymentMethod? get selectedMethod { + if (!hasRecipient) return null; + if (_selectedMethodId != null) { + final byId = _recipientMethods.firstWhereOrNull((method) => method.id == _selectedMethodId); + if (byId != null) return byId; + } + return _preferredMethodForType(_selectedType, _recipientMethods); + } bool get hasRecipient => _recipient != null; @@ -42,6 +48,12 @@ class PaymentFlowProvider extends ChangeNotifier { ? List.unmodifiable(_recipientMethods) : const []; + List get methodsForSelectedType => hasRecipient + ? List.unmodifiable( + _recipientMethods.where((method) => method.type == _selectedType).toList(), + ) + : const []; + void update( RecipientsProvider recipientsProvider, PaymentMethodsProvider methodsProvider, @@ -63,12 +75,25 @@ class PaymentFlowProvider extends ChangeNotifier { } _selectedType = type; + if (hasRecipient) { + _selectedMethodId = _preferredMethodForType(type, _recipientMethods)?.id; + } if (resetManualData) { _manualPaymentData = null; } notifyListeners(); } + void selectMethod(PaymentMethod method) { + if (!hasRecipient) return; + if (_selectedMethodId == method.id && _selectedType == method.type) return; + _selectedMethodId = method.id; + if (_selectedType != method.type) { + _selectedType = method.type; + } + notifyListeners(); + } + void setManualPaymentData(PaymentMethodData? data) { _manualPaymentData = data; notifyListeners(); @@ -124,6 +149,12 @@ class PaymentFlowProvider extends ChangeNotifier { availableTypes: availableTypes, preferredType: preferredType, ); + final resolvedMethod = _resolveSelectedMethod( + recipient: recipient, + methods: methods, + selectedType: resolvedType, + selectedMethodId: _selectedMethodId, + ); var hasChanges = false; @@ -142,6 +173,11 @@ class PaymentFlowProvider extends ChangeNotifier { hasChanges = true; } + if ((resolvedMethod?.id ?? _selectedMethodId) != _selectedMethodId) { + _selectedMethodId = resolvedMethod?.id; + hasChanges = true; + } + if ((recipient != null || forceResetManualData) && _manualPaymentData != null) { _manualPaymentData = null; hasChanges = true; @@ -154,6 +190,28 @@ class PaymentFlowProvider extends ChangeNotifier { for (final method in methods) method.type: method.data, }; + PaymentMethod? _preferredMethodForType(PaymentType type, List methods) { + final forType = methods.where((method) => method.type == type).toList(); + if (forType.isEmpty) return null; + return forType.firstWhereOrNull((method) => method.isMain) ?? forType.first; + } + + PaymentMethod? _resolveSelectedMethod({ + required Recipient? recipient, + required List methods, + required PaymentType selectedType, + required String? selectedMethodId, + }) { + if (recipient == null) return null; + final forType = methods.where((method) => method.type == selectedType).toList(); + if (forType.isEmpty) return null; + if (selectedMethodId != null) { + final byId = forType.firstWhereOrNull((method) => method.id == selectedMethodId); + if (byId != null) return byId; + } + return _preferredMethodForType(selectedType, methods); + } + bool _hasSameMethods(List methods) { if (_recipientMethods.length != methods.length) return false; for (var i = 0; i < methods.length; i++) { 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 index 745cd046..705b45e3 100644 --- a/frontend/pweb/lib/pages/payment_methods/widgets/payment_info_section.dart +++ b/frontend/pweb/lib/pages/payment_methods/widgets/payment_info_section.dart @@ -3,6 +3,7 @@ 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'; @@ -10,10 +11,11 @@ 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; @@ -31,6 +33,9 @@ class PaymentInfoSection extends StatelessWidget { 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); @@ -53,6 +58,30 @@ class PaymentInfoSection extends StatelessWidget { ), ), SizedBox(height: dimensions.paddingMedium), + if (hasRecipient && methodsForSelectedType.length > 1) + DropdownButtonFormField( + value: 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) { -- 2.49.1