Merge pull request 'refactoring for recipient addition page' (#344) from SEND039 into main
All checks were successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/discovery Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/gateway_mntx Pipeline was successful
ci/woodpecker/push/gateway_chain Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful

Reviewed-on: #344
Reviewed-by: tech <tech.sendico@proton.me>
This commit was merged in pull request #344.
This commit is contained in:
2026-01-29 22:44:37 +00:00
48 changed files with 1467 additions and 536 deletions

View File

@@ -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/crypto_address.dart';
import 'package:pshared/models/payment/methods/data.dart'; import 'package:pshared/models/payment/methods/data.dart';
import 'package:pshared/models/payment/methods/iban.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/managed_wallet.dart';
import 'package:pshared/models/payment/methods/russian_bank.dart'; import 'package:pshared/models/payment/methods/russian_bank.dart';
import 'package:pshared/models/payment/methods/wallet.dart'; import 'package:pshared/models/payment/methods/wallet.dart';
@@ -46,6 +47,7 @@ class PaymentMethod implements PermissionBoundStorable, Describable {
WalletPaymentMethod? get walletData => dataAsOrNull<WalletPaymentMethod>(); WalletPaymentMethod? get walletData => dataAsOrNull<WalletPaymentMethod>();
ManagedWalletPaymentMethod? get managedWalletData => dataAsOrNull<ManagedWalletPaymentMethod>(); ManagedWalletPaymentMethod? get managedWalletData => dataAsOrNull<ManagedWalletPaymentMethod>();
CryptoAddressPaymentMethod? get cryptoAddressData => dataAsOrNull<CryptoAddressPaymentMethod>(); CryptoAddressPaymentMethod? get cryptoAddressData => dataAsOrNull<CryptoAddressPaymentMethod>();
LedgerPaymentMethod? get ledgerData => dataAsOrNull<LedgerPaymentMethod>();
@override @override
String get id => storable.id; String get id => storable.id;

View File

@@ -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;
}

View File

@@ -15,6 +15,7 @@ class PaymentFlowProvider extends ChangeNotifier {
PaymentMethodData? _manualPaymentData; PaymentMethodData? _manualPaymentData;
List<PaymentMethod> _recipientMethods = []; List<PaymentMethod> _recipientMethods = [];
Recipient? _recipient; Recipient? _recipient;
String? _selectedMethodId;
PaymentFlowProvider({ PaymentFlowProvider({
required PaymentType initialType, required PaymentType initialType,
@@ -25,9 +26,14 @@ class PaymentFlowProvider extends ChangeNotifier {
PaymentType get selectedType => _selectedType; PaymentType get selectedType => _selectedType;
PaymentMethodData? get manualPaymentData => _manualPaymentData; PaymentMethodData? get manualPaymentData => _manualPaymentData;
Recipient? get recipient => _recipient; Recipient? get recipient => _recipient;
PaymentMethod? get selectedMethod => hasRecipient PaymentMethod? get selectedMethod {
? _recipientMethods.firstWhereOrNull((method) => method.type == _selectedType) if (!hasRecipient) return null;
: 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; bool get hasRecipient => _recipient != null;
@@ -42,6 +48,12 @@ class PaymentFlowProvider extends ChangeNotifier {
? List<PaymentMethod>.unmodifiable(_recipientMethods) ? List<PaymentMethod>.unmodifiable(_recipientMethods)
: const []; : const [];
List<PaymentMethod> get methodsForSelectedType => hasRecipient
? List<PaymentMethod>.unmodifiable(
_recipientMethods.where((method) => method.type == _selectedType).toList(),
)
: const [];
void update( void update(
RecipientsProvider recipientsProvider, RecipientsProvider recipientsProvider,
PaymentMethodsProvider methodsProvider, PaymentMethodsProvider methodsProvider,
@@ -63,12 +75,25 @@ class PaymentFlowProvider extends ChangeNotifier {
} }
_selectedType = type; _selectedType = type;
if (hasRecipient) {
_selectedMethodId = _preferredMethodForType(type, _recipientMethods)?.id;
}
if (resetManualData) { if (resetManualData) {
_manualPaymentData = null; _manualPaymentData = null;
} }
notifyListeners(); 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) { void setManualPaymentData(PaymentMethodData? data) {
_manualPaymentData = data; _manualPaymentData = data;
notifyListeners(); notifyListeners();
@@ -124,6 +149,12 @@ class PaymentFlowProvider extends ChangeNotifier {
availableTypes: availableTypes, availableTypes: availableTypes,
preferredType: preferredType, preferredType: preferredType,
); );
final resolvedMethod = _resolveSelectedMethod(
recipient: recipient,
methods: methods,
selectedType: resolvedType,
selectedMethodId: _selectedMethodId,
);
var hasChanges = false; var hasChanges = false;
@@ -142,6 +173,11 @@ class PaymentFlowProvider extends ChangeNotifier {
hasChanges = true; hasChanges = true;
} }
if ((resolvedMethod?.id ?? _selectedMethodId) != _selectedMethodId) {
_selectedMethodId = resolvedMethod?.id;
hasChanges = true;
}
if ((recipient != null || forceResetManualData) && _manualPaymentData != null) { if ((recipient != null || forceResetManualData) && _manualPaymentData != null) {
_manualPaymentData = null; _manualPaymentData = null;
hasChanges = true; hasChanges = true;
@@ -154,6 +190,28 @@ class PaymentFlowProvider extends ChangeNotifier {
for (final method in methods) method.type: method.data, for (final method in methods) method.type: method.data,
}; };
PaymentMethod? _preferredMethodForType(PaymentType type, List<PaymentMethod> 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<PaymentMethod> 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<PaymentMethod> methods) { bool _hasSameMethods(List<PaymentMethod> methods) {
if (_recipientMethods.length != methods.length) return false; if (_recipientMethods.length != methods.length) return false;
for (var i = 0; i < methods.length; i++) { for (var i = 0; i < methods.length; i++) {

View File

@@ -1,14 +1,16 @@
import 'dart:async'; import 'dart:async';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:collection/collection.dart';
import 'package:pshared/models/describable.dart'; import 'package:pshared/models/describable.dart';
import 'package:pshared/models/organization/bound.dart'; import 'package:pshared/models/organization/bound.dart';
import 'package:pshared/models/payment/methods/data.dart'; import 'package:pshared/models/payment/methods/data.dart';
import 'package:pshared/models/payment/methods/type.dart'; import 'package:pshared/models/payment/methods/type.dart';
import 'package:pshared/models/payment/type.dart'; import 'package:pshared/models/payment/type.dart';
import 'package:pshared/models/permissions/bound.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/models/storable.dart';
import 'package:pshared/provider/organizations.dart'; import 'package:pshared/provider/organizations.dart';
import 'package:pshared/provider/recipient/provider.dart'; import 'package:pshared/provider/recipient/provider.dart';
@@ -58,42 +60,49 @@ class RecipientMethodsCacheProvider extends ChangeNotifier {
Future<void> syncRecipientMethods({ Future<void> syncRecipientMethods({
required String recipientId, required String recipientId,
required Map<PaymentType, PaymentMethodData> methods, required List<RecipientMethodDraft> methods,
required Map<PaymentType, String> names, required Map<PaymentType, String> names,
}) async { }) async {
await _ensureLoaded(recipientId); await _ensureLoaded(recipientId);
final current = List<PaymentMethod>.from(_methodsByRecipient[recipientId] ?? const []); final current = List<PaymentMethod>.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) { for (final method in current.toList()) {
if (!methods.containsKey(entry.key)) { if (!desiredExistingIds.contains(method.id)) {
await PaymentMethodService.delete(entry.value); await PaymentMethodService.delete(method);
current.removeWhere((method) => method.id == entry.value.id); current.removeWhere((m) => m.id == method.id);
} }
} }
for (final entry in methods.entries) { for (final entry in desiredExisting) {
final type = entry.key; final existing = entry.existing;
final data = entry.value; final data = entry.data;
final existing = currentByType[type]; if (existing == null || data == null) continue;
if (existing != null) { final currentMethod = currentById[existing.id] ?? existing;
final updated = existing.copyWith(data: data); final updated = currentMethod.copyWith(data: data);
final updatedList = await PaymentMethodService.update(updated); final updatedList = await PaymentMethodService.update(updated);
final updatedMethod = updatedList.firstWhereOrNull((m) => m.id == updated.id) ?? updated; final updatedMethod = updatedList.firstWhereOrNull((m) => m.id == updated.id) ?? updated;
final index = current.indexWhere((m) => m.id == updatedMethod.id); final index = current.indexWhere((m) => m.id == updatedMethod.id);
if (index != -1) { if (index != -1) {
current[index] = updatedMethod; current[index] = updatedMethod;
}
} else {
final created = await _createMethod(
recipientId: recipientId,
data: data,
name: names[type] ?? type.name,
);
current.add(created);
} }
} }
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); _methodsByRecipient[recipientId] = _sortedMethods(current);
notifyListeners(); notifyListeners();
} }
@@ -143,4 +152,4 @@ class RecipientMethodsCacheProvider extends ChangeNotifier {
List<PaymentMethod> _sortedMethods(List<PaymentMethod> methods) => List<PaymentMethod> _sortedMethods(List<PaymentMethod> methods) =>
methods.toList()..sort((a, b) => a.storable.createdAt.compareTo(b.storable.createdAt)); methods.toList()..sort((a, b) => a.storable.createdAt.compareTo(b.storable.createdAt));
} }

View File

@@ -1,6 +1,5 @@
import 'package:pshared/data/mapper/recipient/recipient.dart'; 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/recipient.dart';
import 'package:pshared/models/recipient/status.dart'; import 'package:pshared/models/recipient/status.dart';
import 'package:pshared/models/recipient/type.dart'; import 'package:pshared/models/recipient/type.dart';
@@ -11,71 +10,12 @@ import 'package:pshared/service/recipient/service.dart';
class RecipientsProvider extends GenericProvider<Recipient> { class RecipientsProvider extends GenericProvider<Recipient> {
late OrganizationsProvider _organizations; late OrganizationsProvider _organizations;
String? _organizationRef;
RecipientFilter _selectedFilter = RecipientFilter.all;
String _query = '';
String? _previousRecipientRef;
RecipientFilter get selectedFilter => _selectedFilter;
String get query => _query;
List<Recipient> get recipients => List<Recipient>.unmodifiable(items.toList()..sort((a, b) => a.storable.createdAt.compareTo(b.storable.createdAt))); List<Recipient> get recipients => List<Recipient>.unmodifiable(items.toList()..sort((a, b) => a.storable.createdAt.compareTo(b.storable.createdAt)));
RecipientsProvider() : super(service: RecipientService.basicService); RecipientsProvider() : super(service: RecipientService.basicService);
Recipient? get previousRecipient => _previousRecipientRef == null
? null
: getItemByRef(_previousRecipientRef!);
List<Recipient> get filteredRecipients {
List<Recipient> 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<Recipient> create({ Future<Recipient> create({
required String name, required String name,
required String email, required String email,
@@ -92,8 +32,10 @@ class RecipientsProvider extends GenericProvider<Recipient> {
void updateProviders(OrganizationsProvider organizations) { void updateProviders(OrganizationsProvider organizations) {
_organizations = organizations; _organizations = organizations;
if (_organizations.isOrganizationSet) { if (!_organizations.isOrganizationSet) return;
load(_organizations.current.id, _organizations.current.id); final organizationRef = _organizations.current.id;
} if (_organizationRef == organizationRef) return;
_organizationRef = organizationRef;
load(organizationRef, organizationRef);
} }
} }

View File

@@ -34,6 +34,7 @@ import 'package:pweb/widgets/dialogs/confirmation_dialog.dart';
import 'package:pweb/widgets/error/snackbar.dart'; import 'package:pweb/widgets/error/snackbar.dart';
import 'package:pweb/widgets/sidebar/destinations.dart'; import 'package:pweb/widgets/sidebar/destinations.dart';
import 'package:pweb/widgets/sidebar/page.dart'; import 'package:pweb/widgets/sidebar/page.dart';
import 'package:pweb/utils/payment/availability.dart';
import 'package:pweb/generated/i18n/app_localizations.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), update: (context, organizations, recipients, provider) => provider!..updateProviders(organizations, recipients),
), ),
ChangeNotifierProxyProvider2<RecipientsProvider, PaymentMethodsProvider, PaymentFlowProvider>( ChangeNotifierProxyProvider2<RecipientsProvider, PaymentMethodsProvider, PaymentFlowProvider>(
create: (_) => PaymentFlowProvider(initialType: PaymentType.bankAccount), create: (_) => PaymentFlowProvider(initialType: enabledPaymentTypes.first),
update: (context, recipients, methods, provider) => provider!..update( update: (context, recipients, methods, provider) => provider!..update(
recipients, recipients,
methods, methods,

View File

@@ -372,6 +372,22 @@
"paymentConfigTitle": "Where to receive money", "paymentConfigTitle": "Where to receive money",
"paymentConfigSubtitle": "Add multiple methods and choose your primary one.", "paymentConfigSubtitle": "Add multiple methods and choose your primary one.",
"addPaymentMethod": "Add payment method", "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", "makeMain": "Make primary",
"advanced": "Advanced", "advanced": "Advanced",
"fallbackExplanation": "If the primary method is unavailable, we will try the next enabled one in the list.", "fallbackExplanation": "If the primary method is unavailable, we will try the next enabled one in the list.",
@@ -436,9 +452,16 @@
"walletId": "Wallet ID", "walletId": "Wallet ID",
"enterWalletId": "Enter wallet ID", "enterWalletId": "Enter wallet ID",
"ledgerAccountRef": "Ledger account reference",
"enterLedgerAccountRef": "Enter ledger account reference",
"contraLedgerAccountRef": "Contra ledger account reference (optional)",
"recipients": "Recipients", "recipients": "Recipients",
"recipientName": "Recipient Name", "recipientName": "Recipient Name",
"recipientNameHint": "e.g. Alex Johnson",
"@recipientNameHint": {
"description": "Hint shown in the recipient name field"
},
"enterRecipientName": "Enter recipient name", "enterRecipientName": "Enter recipient name",
"inn": "INN", "inn": "INN",
"enterInn": "Enter INN", "enterInn": "Enter INN",

View File

@@ -372,6 +372,22 @@
"paymentConfigTitle": "Куда получать деньги", "paymentConfigTitle": "Куда получать деньги",
"paymentConfigSubtitle": "Добавьте несколько методов и выберите основной.", "paymentConfigSubtitle": "Добавьте несколько методов и выберите основной.",
"addPaymentMethod": "Добавить способ оплаты", "addPaymentMethod": "Добавить способ оплаты",
"paymentMethodAdded": "Добавлено",
"@paymentMethodAdded": {
"description": "Текст статуса для способа оплаты, который уже настроен"
},
"paymentMethodNotAdded": "Не добавлено",
"@paymentMethodNotAdded": {
"description": "Текст статуса для способа оплаты без заполненных реквизитов"
},
"paymentMethodComingSoon": "Скоро",
"@paymentMethodComingSoon": {
"description": "Текст статуса для способа оплаты, который виден, но пока недоступен"
},
"paymentMethodDetails": "Реквизиты",
"@paymentMethodDetails": {
"description": "Заголовок над формой выбранного способа оплаты"
},
"makeMain": "Сделать основным", "makeMain": "Сделать основным",
"advanced": "Дополнительно", "advanced": "Дополнительно",
"fallbackExplanation": "Если основной метод недоступен, мы попробуем следующий включенный метод в списке.", "fallbackExplanation": "Если основной метод недоступен, мы попробуем следующий включенный метод в списке.",
@@ -436,9 +452,16 @@
"walletId": "ID кошелька", "walletId": "ID кошелька",
"enterWalletId": "Введите ID кошелька", "enterWalletId": "Введите ID кошелька",
"ledgerAccountRef": "Референс леджер-счета",
"enterLedgerAccountRef": "Введите референс леджер-счета",
"contraLedgerAccountRef": "Референс контр-счета (необязательно)",
"recipients": "Получатели", "recipients": "Получатели",
"recipientName": "Имя получателя", "recipientName": "Имя получателя",
"recipientNameHint": "например, Алексей Иванов",
"@recipientNameHint": {
"description": "Подсказка в поле имени получателя"
},
"enterRecipientName": "Введите имя получателя", "enterRecipientName": "Введите имя получателя",
"inn": "ИНН", "inn": "ИНН",
"enterInn": "Введите ИНН", "enterInn": "Введите ИНН",

View File

@@ -1 +0,0 @@
enum ButtonState { enabled, disabled, loading }

View File

@@ -0,0 +1 @@
enum ControlState { enabled, disabled, loading }

View File

@@ -0,0 +1 @@
enum PaymentMethodTileAvailability { added, available, comingSoon }

View File

@@ -0,0 +1 @@
enum PaymentMethodTileSelection { selected, idle }

View File

@@ -0,0 +1 @@
enum SeedState { idle, seeded }

View File

@@ -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<FormState> formKey;
final TextEditingController nameCtrl;
final TextEditingController emailCtrl;
final bool isEditing;
final Future<void> 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<AddressBookRecipientFormBody> createState() => _AddressBookRecipientFormBodyState();
}
class _AddressBookRecipientFormBodyState extends State<AddressBookRecipientFormBody> {
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<AddressBookRecipientFormProvider>();
_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,
);
}
}

View File

@@ -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<PaymentMethodData?> 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<AddressBookPaymentMethodTile> createState() => _AddressBookPaymentMethodTileState();
}
class _AddressBookPaymentMethodTileState extends State<AddressBookPaymentMethodTile> {
Future<void> _confirmDelete() async {
final l10n = AppLocalizations.of(context)!;
final confirmed = await showConfirmationDialog(
context: context,
title: l10n.delete,
message: l10n.deletePaymentConfirmation,
confirmLabel: l10n.delete,
);
if (confirmed) {
widget.onChanged(null);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
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),
],
);
}
}

View File

@@ -1,22 +1,16 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.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/payment/type.dart';
import 'package:pshared/models/recipient/recipient.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/methods_cache.dart';
import 'package:pshared/provider/recipient/provider.dart'; import 'package:pshared/provider/recipient/provider.dart';
import 'package:pweb/pages/address_book/form/view.dart'; import 'package:pweb/pages/address_book/form/body.dart';
import 'package:pweb/services/posthog.dart'; import 'package:pweb/providers/address_book_recipient_form.dart';
import 'package:pweb/utils/error/snackbar.dart'; import 'package:pweb/utils/payment/availability.dart';
import 'package:pweb/utils/payment/label.dart'; import 'package:pweb/utils/payment/label.dart';
import 'package:pweb/utils/snackbar.dart';
import 'package:pweb/generated/i18n/app_localizations.dart'; import 'package:pweb/generated/i18n/app_localizations.dart';
@@ -35,11 +29,8 @@ class _AddressBookRecipientFormState extends State<AddressBookRecipientForm> {
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
late TextEditingController _nameCtrl; late TextEditingController _nameCtrl;
late TextEditingController _emailCtrl; late TextEditingController _emailCtrl;
RecipientType _type = RecipientType.internal;
RecipientStatus _status = RecipientStatus.ready; static const List<PaymentType> _supportedTypes = visiblePaymentTypes;
final MethodMap _methods = {};
late RecipientMethodsCacheProvider _methodsCacheProvider;
bool _hasInitializedMethods = false;
@override @override
void initState() { void initState() {
@@ -47,129 +38,60 @@ class _AddressBookRecipientFormState extends State<AddressBookRecipientForm> {
final r = widget.recipient; final r = widget.recipient;
_nameCtrl = TextEditingController(text: r?.name ?? ''); _nameCtrl = TextEditingController(text: r?.name ?? '');
_emailCtrl = TextEditingController(text: r?.email ?? ''); _emailCtrl = TextEditingController(text: r?.email ?? '');
_type = r?.type ?? RecipientType.internal;
_status = r?.status ?? RecipientStatus.ready;
_methodsCacheProvider = context.read<RecipientMethodsCacheProvider>()
..addListener(_onProviderChanged);
if (r != null) {
_methodsCacheProvider.refreshRecipient(r.id);
_syncMethodsFromCache();
}
} }
Future<Recipient?> _doSave() async { Map<PaymentType, String> _methodNames(BuildContext context) => {
final recipients = context.read<RecipientsProvider>(); for (final type in _supportedTypes) type: getPaymentTypeLabel(context, type),
final recipient = widget.recipient == null };
? await recipients.create(
name: _nameCtrl.text,
email: _emailCtrl.text,
)
: widget.recipient!;
recipients.setCurrentObject(recipient.id);
final methods = <PaymentType, PaymentMethodData>{};
final names = <PaymentType, String>{};
for (final entry in _methods.entries) {
final data = entry.value;
if (data == null) continue;
methods[entry.key] = data;
names[entry.key] = getPaymentTypeLabel(context, entry.key);
}
await _methodsCacheProvider.syncRecipientMethods(
recipientId: recipient.id,
methods: methods,
names: names,
);
return recipient;
}
Future<void> _save() async { Future<void> _save(AddressBookRecipientFormProvider formState) async {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
if (!_formKey.currentState!.validate() || !formState.hasAnyMethod) {
if (!_formKey.currentState!.validate()) { ScaffoldMessenger.of(context).showSnackBar(
notifyUser(context, l10n.recipientFormValidationError); SnackBar(content: Text(l10n.recipientFormRule)),
);
return; return;
} }
if (_methods.isEmpty) { try {
notifyUser(context, l10n.recipientFormRule); final saved = await formState.save(
return; 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( @override
_type, Widget build(BuildContext context) {
_status, return ChangeNotifierProxyProvider2<
_methods.keys.toSet(), RecipientsProvider,
)); RecipientMethodsCacheProvider,
final recipient = await executeActionWithNotification( AddressBookRecipientFormProvider
context: context, >(
action: _doSave, create: (_) => AddressBookRecipientFormProvider(
errorMessage: l10n.errorSaveRecipient, recipient: widget.recipient,
successMessage: l10n.recipientSavedSuccessfully, 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);
},
);
} }

View File

@@ -1,16 +1,15 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:pshared/models/payment/methods/data.dart';
import 'package:pshared/models/payment/type.dart'; import 'package:pshared/models/payment/type.dart';
import 'package:pshared/models/recipient/status.dart'; import 'package:pshared/models/payment/methods/data.dart';
import 'package:pshared/models/recipient/type.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/feilds/email.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/header.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/pages/address_book/form/widgets/feilds/name.dart';
import 'package:pweb/utils/payment/label.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'; import 'package:pweb/generated/i18n/app_localizations.dart';
@@ -19,12 +18,15 @@ class FormView extends StatelessWidget {
final GlobalKey<FormState> formKey; final GlobalKey<FormState> formKey;
final TextEditingController nameCtrl; final TextEditingController nameCtrl;
final TextEditingController emailCtrl; final TextEditingController emailCtrl;
final RecipientType type; final List<PaymentType> types;
final RecipientStatus status; final PaymentType selectedType;
final MethodMap methods; final int? selectedIndex;
final ValueChanged<RecipientType> onTypeChanged; final Map<PaymentType, List<RecipientMethodDraft>> methods;
final ValueChanged<RecipientStatus> onStatusChanged; final void Function(PaymentType type, int index) onMethodSelected;
final void Function(PaymentType, PaymentMethodData?) onMethodsChanged; final ValueChanged<PaymentType> onMethodAdd;
final Set<PaymentType> disabledTypes;
final ValueChanged<int> onMethodRemove;
final void Function(int, PaymentMethodData) onMethodChanged;
final VoidCallback onSave; final VoidCallback onSave;
final bool isEditing; final bool isEditing;
final VoidCallback onBack; final VoidCallback onBack;
@@ -45,16 +47,19 @@ class FormView extends StatelessWidget {
required this.formKey, required this.formKey,
required this.nameCtrl, required this.nameCtrl,
required this.emailCtrl, required this.emailCtrl,
required this.type, required this.types,
required this.status, required this.selectedType,
required this.selectedIndex,
required this.methods, required this.methods,
required this.onTypeChanged, required this.onMethodSelected,
required this.onStatusChanged, required this.onMethodAdd,
required this.onMethodsChanged, this.disabledTypes = const {},
required this.onMethodRemove,
required this.onMethodChanged,
required this.onSave, required this.onSave,
required this.isEditing, required this.isEditing,
required this.onBack, required this.onBack,
this.maxWidth = 500, this.maxWidth = 800,
this.elevation = 4, this.elevation = 4,
this.borderRadius = 16, this.borderRadius = 16,
this.padding = const EdgeInsets.all(20), this.padding = const EdgeInsets.all(20),
@@ -69,6 +74,10 @@ class FormView extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final entries = methods[selectedType] ?? const <RecipientMethodDraft>[];
final hasSelection = selectedIndex != null &&
selectedIndex! >= 0 &&
selectedIndex! < entries.length;
return Align( return Align(
alignment: Alignment.topCenter, alignment: Alignment.topCenter,
@@ -102,14 +111,25 @@ class FormView extends StatelessWidget {
?.copyWith(fontWeight: FontWeight.bold), ?.copyWith(fontWeight: FontWeight.bold),
), ),
SizedBox(height: spacingFields), SizedBox(height: spacingFields),
...PaymentType.values.map( PaymentMethodSelectorRow(
(p) => AddressBookPaymentMethodTile( types: types,
type: p, selectedType: selectedType,
title: getPaymentTypeLabel(context, p), selectedIndex: selectedIndex,
methods: methods, methods: methods,
onChanged: (data) => onMethodsChanged(p, data), 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), SizedBox(height: spacingSave),
SaveButton(onSave: onSave), SaveButton(onSave: onSave),
SizedBox(height: spacingBottom), SizedBox(height: spacingBottom),
@@ -122,4 +142,4 @@ class FormView extends StatelessWidget {
), ),
); );
} }
} }

View File

@@ -1,62 +0,0 @@
import 'package:flutter/material.dart';
class ChoiceChips<T> extends StatelessWidget {
final String label;
final List<T> values;
final T selected;
final ValueChanged<T> 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(),
),
],
);
}
}

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:pweb/generated/i18n/app_localizations.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 { class EmailField extends StatelessWidget {
@@ -20,17 +21,16 @@ class EmailField extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!; final loc = AppLocalizations.of(context)!;
return TextFormField( return RecipientTextField(
controller: controller, controller: controller,
decoration: InputDecoration( labelText: loc.username,
labelText: loc.username, hintText: loc.usernameHint,
border: OutlineInputBorder( icon: Icons.alternate_email,
borderRadius: BorderRadius.circular(borderRadius), keyboardType: TextInputType.emailAddress,
), autofillHints: const [AutofillHints.email],
contentPadding: contentPadding, validator: (v) => v == null || v.isEmpty ? loc.usernameErrorInvalid : null,
), borderRadius: borderRadius,
validator: (v) => contentPadding: contentPadding,
v == null || v.isEmpty ? loc.usernameErrorInvalid : null,
); );
} }
} }

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:pweb/generated/i18n/app_localizations.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 { class NameField extends StatelessWidget {
@@ -20,16 +21,16 @@ class NameField extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!; final loc = AppLocalizations.of(context)!;
return TextFormField( return RecipientTextField(
controller: controller, controller: controller,
decoration: InputDecoration( labelText: loc.recipientName,
labelText: loc.recipientName, hintText: loc.recipientNameHint,
border: OutlineInputBorder( icon: Icons.person_outline,
borderRadius: BorderRadius.circular(borderRadius), textCapitalization: TextCapitalization.words,
), autofillHints: const [AutofillHints.name],
contentPadding: contentPadding,
),
validator: (v) => v == null || v.isEmpty ? loc.enterRecipientName : null, validator: (v) => v == null || v.isEmpty ? loc.enterRecipientName : null,
borderRadius: borderRadius,
contentPadding: contentPadding,
); );
} }
} }

View File

@@ -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<String>? autofillHints;
final FormFieldValidator<String>? 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<TextEditingValue>(
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,
);
},
);
}
}

View File

@@ -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<PaymentType> types;
final Set<PaymentType> disabledTypes;
final ValueChanged<PaymentType> 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<PaymentType>(
enabled: hasEnabled,
onSelected: onAdd,
itemBuilder: (context) => types
.map((type) {
final isDisabled = disabledTypes.contains(type);
return PopupMenuItem<PaymentType>(
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,
),
),
],
),
),
);
}
}

View File

@@ -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<RecipientMethodDraft> entries;
final ValueChanged<int> 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<void> _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);
},
),
],
),
);
}
}

View File

@@ -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<PaymentType> types;
final PaymentType selectedType;
final int? selectedIndex;
final Map<PaymentType, List<RecipientMethodDraft>> methods;
final void Function(PaymentType type, int index) onSelected;
final ValueChanged<PaymentType> onAdd;
final Set<PaymentType> 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 = <Widget>[];
for (final type in types) {
final entries = methods[type] ?? const <RecipientMethodDraft>[];
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,
);
}
}

View File

@@ -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,
),
),
),
],
),
),
),
),
),
);
}
}

View File

@@ -42,10 +42,10 @@ class SaveButton extends StatelessWidget {
child: Text( child: Text(
text ?? AppLocalizations.of(context)!.saveRecipient, text ?? AppLocalizations.of(context)!.saveRecipient,
style: textStyle ?? style: textStyle ??
theme.textTheme.labelLarge?.copyWith( theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.onPrimary, color: theme.colorScheme.onPrimary,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
), ),
), ),
), ),

View File

@@ -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/header.dart';
import 'package:pweb/pages/address_book/page/list.dart'; import 'package:pweb/pages/address_book/page/list.dart';
import 'package:pweb/pages/address_book/page/search.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'; import 'package:pweb/generated/i18n/app_localizations.dart';
@@ -41,12 +42,13 @@ class RecipientAddressBookPage extends StatefulWidget {
class _RecipientAddressBookPageState extends State<RecipientAddressBookPage> { class _RecipientAddressBookPageState extends State<RecipientAddressBookPage> {
late final TextEditingController _searchController; late final TextEditingController _searchController;
late final FocusNode _searchFocusNode; late final FocusNode _searchFocusNode;
RecipientFilter _selectedFilter = RecipientFilter.all;
String _query = '';
@override @override
void initState() { void initState() {
super.initState(); super.initState();
final provider = context.read<RecipientsProvider>(); _searchController = TextEditingController();
_searchController = TextEditingController(text: provider.query);
_searchFocusNode = FocusNode(); _searchFocusNode = FocusNode();
} }
@@ -57,23 +59,27 @@ class _RecipientAddressBookPageState extends State<RecipientAddressBookPage> {
super.dispose(); super.dispose();
} }
void _syncSearchField(RecipientsProvider provider) { void _setQuery(String query) {
final query = provider.query; setState(() {
if (_searchController.text == query) return; _query = query;
});
}
_searchController.value = TextEditingValue( void _setFilter(RecipientFilter filter) {
text: query, setState(() {
selection: TextSelection.collapsed(offset: query.length), _selectedFilter = filter;
); });
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!; final loc = AppLocalizations.of(context)!;
final provider = context.watch<RecipientsProvider>(); final provider = context.watch<RecipientsProvider>();
_syncSearchField(provider); final filteredRecipients = filterRecipients(
final filteredRecipients = provider.filteredRecipients; recipients: provider.recipients,
filter: _selectedFilter,
query: _query,
);
if (provider.isLoading) { if (provider.isLoading) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
@@ -91,7 +97,7 @@ class _RecipientAddressBookPageState extends State<RecipientAddressBookPage> {
RecipientSearchField( RecipientSearchField(
controller: _searchController, controller: _searchController,
focusNode: _searchFocusNode, focusNode: _searchFocusNode,
onChanged: provider.setQuery, onChanged: _setQuery,
), ),
const SizedBox(height: RecipientAddressBookPage._bigBox), const SizedBox(height: RecipientAddressBookPage._bigBox),
Row( Row(
@@ -99,26 +105,26 @@ class _RecipientAddressBookPageState extends State<RecipientAddressBookPage> {
RecipientFilterButton( RecipientFilterButton(
text: loc.allStatus, text: loc.allStatus,
filter: RecipientFilter.all, filter: RecipientFilter.all,
selected: provider.selectedFilter, selected: _selectedFilter,
onTap: provider.setFilter, onTap: _setFilter,
), ),
RecipientFilterButton( RecipientFilterButton(
text: loc.readyStatus, text: loc.readyStatus,
filter: RecipientFilter.ready, filter: RecipientFilter.ready,
selected: provider.selectedFilter, selected: _selectedFilter,
onTap: provider.setFilter, onTap: _setFilter,
), ),
RecipientFilterButton( RecipientFilterButton(
text: loc.registeredStatus, text: loc.registeredStatus,
filter: RecipientFilter.registered, filter: RecipientFilter.registered,
selected: provider.selectedFilter, selected: _selectedFilter,
onTap: provider.setFilter, onTap: _setFilter,
), ),
RecipientFilterButton( RecipientFilterButton(
text: loc.notRegisteredStatus, text: loc.notRegisteredStatus,
filter: RecipientFilter.notRegistered, filter: RecipientFilter.notRegistered,
selected: provider.selectedFilter, selected: _selectedFilter,
onTap: provider.setFilter, onTap: _setFilter,
), ),
], ],
), ),
@@ -145,4 +151,4 @@ class _RecipientAddressBookPageState extends State<RecipientAddressBookPage> {
], ],
); );
} }
} }

View File

@@ -3,14 +3,13 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:pshared/models/recipient/recipient.dart'; import 'package:pshared/models/recipient/recipient.dart';
import 'package:pshared/provider/organizations.dart'; import 'package:pshared/provider/recipient/methods_cache.dart';
import 'package:pshared/provider/recipient/pmethods.dart';
import 'package:pweb/pages/address_book/page/recipient/info_row.dart'; import 'package:pweb/pages/address_book/page/recipient/info_row.dart';
import 'package:pweb/utils/payment/label.dart'; import 'package:pweb/utils/payment/label.dart';
class RecipientPaymentRow extends StatefulWidget { class RecipientPaymentRow extends StatelessWidget {
final Recipient recipient; final Recipient recipient;
final double spacing; final double spacing;
@@ -20,40 +19,19 @@ class RecipientPaymentRow extends StatefulWidget {
this.spacing = 18 this.spacing = 18
}); });
@override
State<RecipientPaymentRow> createState() => _RecipientPaymentRowState();
}
class _RecipientPaymentRowState extends State<RecipientPaymentRow> {
late final PaymentMethodsProvider _methodsProvider;
@override
void initState() {
super.initState();
_methodsProvider = PaymentMethodsProvider()
..addListener(_onProviderChanged)
..loadMethods(
context.read<OrganizationsProvider>(),
widget.recipient.id,
);
}
@override
void dispose() {
_methodsProvider.removeListener(_onProviderChanged);
_methodsProvider.dispose();
super.dispose();
}
void _onProviderChanged() => setState(() {});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (!_methodsProvider.isReady) return const Center(child: CircularProgressIndicator()); final cacheProvider = context.watch<RecipientMethodsCacheProvider>();
final recipientId = recipient.id;
final isLoading = cacheProvider.isLoadingFor(recipientId);
if (isLoading && !cacheProvider.hasMethodsFor(recipientId)) {
return const Center(child: CircularProgressIndicator());
}
return Row( return Row(
spacing: widget.spacing, spacing: spacing,
children: _methodsProvider.methods.map((m) => RecipientAddressBookInfoRow( children: cacheProvider.methodsForRecipient(recipientId).map((m) => RecipientAddressBookInfoRow(
type: m.type, type: m.type,
value: getPaymentTypeDescription(context, m), value: getPaymentTypeDescription(context, m),
)).toList(), )).toList(),

View File

@@ -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/long_list/widget.dart';
import 'package:pweb/pages/dashboard/payouts/single/address_book/placeholder.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/pages/dashboard/payouts/single/address_book/short_list.dart';
import 'package:pweb/utils/recipient/filtering.dart';
import 'package:pweb/generated/i18n/app_localizations.dart'; import 'package:pweb/generated/i18n/app_localizations.dart';
@@ -34,18 +35,14 @@ class _AddressBookPayoutState extends State<AddressBookPayout> {
final FocusNode _searchFocusNode = FocusNode(); final FocusNode _searchFocusNode = FocusNode();
late final TextEditingController _searchController; late final TextEditingController _searchController;
String _query = '';
bool get _isExpanded => _searchFocusNode.hasFocus; bool get _isExpanded => _searchFocusNode.hasFocus;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
final provider = context.read<RecipientsProvider>(); _searchController = TextEditingController();
_searchController = TextEditingController(text: provider.query);
_searchController.addListener(() {
provider.setQuery(_searchController.text);
});
} }
@override @override
@@ -55,12 +52,21 @@ class _AddressBookPayoutState extends State<AddressBookPayout> {
super.dispose(); super.dispose();
} }
void _setQuery(String query) {
setState(() {
_query = query;
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!; final loc = AppLocalizations.of(context)!;
final provider = context.watch<RecipientsProvider>(); final provider = context.watch<RecipientsProvider>();
final recipients = provider.recipients; final recipients = provider.recipients;
final filteredRecipients = provider.filteredRecipients; final filteredRecipients = filterRecipients(
recipients: recipients,
query: _query,
);
if (provider.isLoading) { if (provider.isLoading) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
@@ -86,7 +92,7 @@ class _AddressBookPayoutState extends State<AddressBookPayout> {
RecipientSearchField( RecipientSearchField(
controller: _searchController, controller: _searchController,
focusNode: _searchFocusNode, focusNode: _searchFocusNode,
onChanged: (_) {}, onChanged: _setQuery,
), ),
const SizedBox(height: _spacingBetween), const SizedBox(height: _spacingBetween),
Expanded( Expanded(
@@ -110,4 +116,4 @@ class _AddressBookPayoutState extends State<AddressBookPayout> {
), ),
); );
} }
} }

View File

@@ -2,7 +2,9 @@ import 'package:flutter/material.dart';
import 'package:pshared/models/payment/type.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/pages/dashboard/payouts/single/new_recipient/type.dart';
import 'package:pweb/utils/payment/availability.dart';
class SinglePayout extends StatelessWidget { class SinglePayout extends StatelessWidget {
@@ -17,7 +19,7 @@ class SinglePayout extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final paymentTypes = PaymentType.values; final paymentTypes = visiblePaymentTypes;
final dividerColor = Theme.of(context).dividerColor; final dividerColor = Theme.of(context).dividerColor;
return SizedBox( return SizedBox(
@@ -36,6 +38,9 @@ class SinglePayout extends StatelessWidget {
PaymentTypeTile( PaymentTypeTile(
type: paymentTypes[i], type: paymentTypes[i],
onSelected: onGoToPayment, onSelected: onGoToPayment,
state: disabledPaymentTypes.contains(paymentTypes[i])
? ControlState.disabled
: ControlState.enabled,
), ),
if (i < paymentTypes.length - 1) if (i < paymentTypes.length - 1)
Padding( Padding(

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:pshared/models/payment/type.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/pages/payment_methods/icon.dart';
import 'package:pweb/utils/payment/label.dart'; import 'package:pweb/utils/payment/label.dart';
@@ -9,30 +10,53 @@ import 'package:pweb/utils/payment/label.dart';
class PaymentTypeTile extends StatelessWidget { class PaymentTypeTile extends StatelessWidget {
final PaymentType type; final PaymentType type;
final void Function(PaymentType type) onSelected; final void Function(PaymentType type) onSelected;
final ControlState state;
const PaymentTypeTile({ const PaymentTypeTile({
super.key, super.key,
required this.type, required this.type,
required this.onSelected, required this.onSelected,
this.state = ControlState.enabled,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final label = getPaymentTypeLabel(context, type); 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( return InkWell(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
onTap: () => onSelected(type), onTap: isEnabled ? () => onSelected(type) : null,
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.symmetric(vertical: 8),
child: Row( child: Row(
children: [ children: [
Icon(iconForPaymentType(type), size: 24), Icon(iconForPaymentType(type), size: 24, color: textColor),
const SizedBox(width: 12), const SizedBox(width: 12),
Text( Text(
label, 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<Color>(
theme.colorScheme.primary,
),
),
),
],
], ],
), ),
), ),

View File

@@ -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<LedgerForm> createState() => _LedgerFormState();
}
class _LedgerFormState extends State<LedgerForm> {
final _formKey = GlobalKey<FormState>();
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();
}
}

View File

@@ -9,11 +9,15 @@ import 'package:pweb/generated/i18n/app_localizations.dart';
class PaymentMethodTypeSelector extends StatelessWidget { class PaymentMethodTypeSelector extends StatelessWidget {
final PaymentType? value; final PaymentType? value;
final List<PaymentType> types;
final Set<PaymentType> disabledTypes;
final ValueChanged<PaymentType?> onChanged; final ValueChanged<PaymentType?> onChanged;
const PaymentMethodTypeSelector({ const PaymentMethodTypeSelector({
super.key, super.key,
required this.value, required this.value,
required this.types,
this.disabledTypes = const {},
required this.onChanged, required this.onChanged,
}); });
@@ -24,12 +28,18 @@ class PaymentMethodTypeSelector extends StatelessWidget {
return DropdownButtonFormField<PaymentType>( return DropdownButtonFormField<PaymentType>(
initialValue: value, initialValue: value,
decoration: InputDecoration(labelText: l10n.paymentType), decoration: InputDecoration(labelText: l10n.paymentType),
items: PaymentType.values.map((type) { items: types.map((type) {
final label = getPaymentTypeLabel(context, 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(), }).toList(),
onChanged: onChanged, onChanged: onChanged,
validator: (val) => val == null ? l10n.selectPaymentType : null, validator: (val) => val == null ? l10n.selectPaymentType : null,
); );
} }
} }

View File

@@ -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/add/method_selector.dart';
import 'package:pweb/pages/payment_methods/form.dart'; import 'package:pweb/pages/payment_methods/form.dart';
import 'package:pweb/utils/payment/availability.dart';
import 'package:pweb/generated/i18n/app_localizations.dart'; import 'package:pweb/generated/i18n/app_localizations.dart';
@@ -46,6 +47,8 @@ class _AddPaymentMethodDialogState extends State<AddPaymentMethodDialog> {
children: [ children: [
PaymentMethodTypeSelector( PaymentMethodTypeSelector(
value: _selectedType, value: _selectedType,
types: visiblePaymentTypes,
disabledTypes: disabledPaymentTypes,
onChanged: (val) => setState(() { onChanged: (val) => setState(() {
_selectedType = val; _selectedType = val;
_currentMethod = null; _currentMethod = null;
@@ -73,4 +76,4 @@ class _AddPaymentMethodDialogState extends State<AddPaymentMethodDialog> {
], ],
); );
} }
} }

View File

@@ -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/crypto_address.dart';
import 'package:pshared/models/payment/methods/data.dart'; import 'package:pshared/models/payment/methods/data.dart';
import 'package:pshared/models/payment/methods/iban.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/russian_bank.dart';
import 'package:pshared/models/payment/methods/wallet.dart'; import 'package:pshared/models/payment/methods/wallet.dart';
import 'package:pshared/models/payment/type.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/card.dart';
import 'package:pweb/pages/payment_methods/add/crypto_address.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/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/russian_bank.dart';
import 'package:pweb/pages/payment_methods/add/wallet.dart'; import 'package:pweb/pages/payment_methods/add/wallet.dart';
@@ -57,6 +59,11 @@ class PaymentMethodForm extends StatelessWidget {
initialData: initialData as CryptoAddressPaymentMethod?, initialData: initialData as CryptoAddressPaymentMethod?,
isEditable: isEditable, isEditable: isEditable,
), ),
PaymentType.ledger => LedgerForm(
onChanged: onChanged,
initialData: initialData as LedgerPaymentMethod?,
isEditable: isEditable,
),
_ => const SizedBox.shrink(), _ => const SizedBox.shrink(),
}; };
} }

View File

@@ -11,6 +11,7 @@ import 'package:pshared/provider/recipient/pmethods.dart';
import 'package:pshared/provider/recipient/provider.dart'; import 'package:pshared/provider/recipient/provider.dart';
import 'package:pweb/pages/payment_methods/payment_page/body.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/widgets/sidebar/destinations.dart';
import 'package:pweb/services/posthog.dart'; import 'package:pweb/services/posthog.dart';
import 'package:pweb/widgets/dialogs/payment_status_dialog.dart'; import 'package:pweb/widgets/dialogs/payment_status_dialog.dart';
@@ -35,6 +36,8 @@ class PaymentPage extends StatefulWidget {
class _PaymentPageState extends State<PaymentPage> { class _PaymentPageState extends State<PaymentPage> {
late final TextEditingController _searchController; late final TextEditingController _searchController;
late final FocusNode _searchFocusNode; late final FocusNode _searchFocusNode;
Recipient? _previousRecipient;
String _query = '';
@override @override
void initState() { void initState() {
@@ -58,17 +61,25 @@ class _PaymentPageState extends State<PaymentPage> {
} }
void _handleSearchChanged(String query) { void _handleSearchChanged(String query) {
context.read<RecipientsProvider>().setQuery(query); setState(() {
_query = query;
});
} }
void _handleRecipientSelected(Recipient recipient) { void _handleRecipientSelected(Recipient recipient) {
final recipientProvider = context.read<RecipientsProvider>(); final recipientProvider = context.read<RecipientsProvider>();
setState(() {
_previousRecipient = recipientProvider.currentObject;
});
recipientProvider.setCurrentObject(recipient.id); recipientProvider.setCurrentObject(recipient.id);
_clearSearchField(); _clearSearchField();
} }
void _handleRecipientCleared() { void _handleRecipientCleared() {
final recipientProvider = context.read<RecipientsProvider>(); final recipientProvider = context.read<RecipientsProvider>();
setState(() {
_previousRecipient = recipientProvider.currentObject;
});
recipientProvider.setCurrentObject(null); recipientProvider.setCurrentObject(null);
_clearSearchField(); _clearSearchField();
} }
@@ -76,7 +87,9 @@ class _PaymentPageState extends State<PaymentPage> {
void _clearSearchField() { void _clearSearchField() {
_searchController.clear(); _searchController.clear();
_searchFocusNode.unfocus(); _searchFocusNode.unfocus();
context.read<RecipientsProvider>().setQuery(''); setState(() {
_query = '';
});
} }
void _handleSendPayment() { void _handleSendPayment() {
@@ -97,16 +110,21 @@ class _PaymentPageState extends State<PaymentPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final methodsProvider = context.watch<PaymentMethodsProvider>(); final methodsProvider = context.watch<PaymentMethodsProvider>();
final recipientProvider = context.read<RecipientsProvider>(); final recipientProvider = context.watch<RecipientsProvider>();
final recipient = context.select<RecipientsProvider, Recipient?>( final recipient = recipientProvider.currentObject;
(provider) => provider.currentObject, final filteredRecipients = filterRecipients(
recipients: recipientProvider.recipients,
query: _query,
); );
return PaymentPageBody( return PaymentPageBody(
onBack: widget.onBack, onBack: widget.onBack,
fallbackDestination: widget.fallbackDestination, fallbackDestination: widget.fallbackDestination,
recipient: recipient, recipient: recipient,
previousRecipient: _previousRecipient,
recipientProvider: recipientProvider, recipientProvider: recipientProvider,
searchQuery: _query,
filteredRecipients: filteredRecipients,
methodsProvider: methodsProvider, methodsProvider: methodsProvider,
onWalletSelected: context.read<WalletsController>().selectWallet, onWalletSelected: context.read<WalletsController>().selectWallet,
searchController: _searchController, searchController: _searchController,

View File

@@ -15,7 +15,10 @@ import 'package:pweb/generated/i18n/app_localizations.dart';
class PaymentPageBody extends StatelessWidget { class PaymentPageBody extends StatelessWidget {
final ValueChanged<Recipient?>? onBack; final ValueChanged<Recipient?>? onBack;
final Recipient? recipient; final Recipient? recipient;
final Recipient? previousRecipient;
final RecipientsProvider recipientProvider; final RecipientsProvider recipientProvider;
final String searchQuery;
final List<Recipient> filteredRecipients;
final PaymentMethodsProvider methodsProvider; final PaymentMethodsProvider methodsProvider;
final ValueChanged<Wallet> onWalletSelected; final ValueChanged<Wallet> onWalletSelected;
final PayoutDestination fallbackDestination; final PayoutDestination fallbackDestination;
@@ -30,7 +33,10 @@ class PaymentPageBody extends StatelessWidget {
super.key, super.key,
required this.onBack, required this.onBack,
required this.recipient, required this.recipient,
required this.previousRecipient,
required this.recipientProvider, required this.recipientProvider,
required this.searchQuery,
required this.filteredRecipients,
required this.methodsProvider, required this.methodsProvider,
required this.onWalletSelected, required this.onWalletSelected,
required this.fallbackDestination, required this.fallbackDestination,
@@ -59,7 +65,10 @@ class PaymentPageBody extends StatelessWidget {
return PaymentPageContent( return PaymentPageContent(
onBack: onBack, onBack: onBack,
recipient: recipient, recipient: recipient,
previousRecipient: previousRecipient,
recipientProvider: recipientProvider, recipientProvider: recipientProvider,
searchQuery: searchQuery,
filteredRecipients: filteredRecipients,
onWalletSelected: onWalletSelected, onWalletSelected: onWalletSelected,
fallbackDestination: fallbackDestination, fallbackDestination: fallbackDestination,
searchController: searchController, searchController: searchController,

View File

@@ -25,7 +25,10 @@ import 'package:pweb/generated/i18n/app_localizations.dart';
class PaymentPageContent extends StatelessWidget { class PaymentPageContent extends StatelessWidget {
final ValueChanged<Recipient?>? onBack; final ValueChanged<Recipient?>? onBack;
final Recipient? recipient; final Recipient? recipient;
final Recipient? previousRecipient;
final RecipientsProvider recipientProvider; final RecipientsProvider recipientProvider;
final String searchQuery;
final List<Recipient> filteredRecipients;
final ValueChanged<Wallet> onWalletSelected; final ValueChanged<Wallet> onWalletSelected;
final PayoutDestination fallbackDestination; final PayoutDestination fallbackDestination;
final TextEditingController searchController; final TextEditingController searchController;
@@ -39,7 +42,10 @@ class PaymentPageContent extends StatelessWidget {
super.key, super.key,
required this.onBack, required this.onBack,
required this.recipient, required this.recipient,
required this.previousRecipient,
required this.recipientProvider, required this.recipientProvider,
required this.searchQuery,
required this.filteredRecipients,
required this.onWalletSelected, required this.onWalletSelected,
required this.fallbackDestination, required this.fallbackDestination,
required this.searchController, required this.searchController,
@@ -99,8 +105,11 @@ class PaymentPageContent extends StatelessWidget {
SizedBox(height: dimensions.paddingXLarge), SizedBox(height: dimensions.paddingXLarge),
RecipientSection( RecipientSection(
recipient: recipient, recipient: recipient,
previousRecipient: previousRecipient,
dimensions: dimensions, dimensions: dimensions,
recipientProvider: recipientProvider, recipientProvider: recipientProvider,
searchQuery: searchQuery,
filteredRecipients: filteredRecipients,
searchController: searchController, searchController: searchController,
searchFocusNode: searchFocusNode, searchFocusNode: searchFocusNode,
onSearchChanged: onSearchChanged, onSearchChanged: onSearchChanged,

View File

@@ -21,7 +21,10 @@ import 'package:pweb/generated/i18n/app_localizations.dart';
class PaymentPageContent extends StatelessWidget { class PaymentPageContent extends StatelessWidget {
final ValueChanged<Recipient?>? onBack; final ValueChanged<Recipient?>? onBack;
final Recipient? recipient; final Recipient? recipient;
final Recipient? previousRecipient;
final RecipientsProvider recipientProvider; final RecipientsProvider recipientProvider;
final String searchQuery;
final List<Recipient> filteredRecipients;
final ValueChanged<Wallet> onWalletSelected; final ValueChanged<Wallet> onWalletSelected;
final PayoutDestination fallbackDestination; final PayoutDestination fallbackDestination;
final TextEditingController searchController; final TextEditingController searchController;
@@ -35,7 +38,10 @@ class PaymentPageContent extends StatelessWidget {
super.key, super.key,
required this.onBack, required this.onBack,
required this.recipient, required this.recipient,
required this.previousRecipient,
required this.recipientProvider, required this.recipientProvider,
required this.searchQuery,
required this.filteredRecipients,
required this.onWalletSelected, required this.onWalletSelected,
required this.fallbackDestination, required this.fallbackDestination,
required this.searchController, required this.searchController,
@@ -82,8 +88,11 @@ class PaymentPageContent extends StatelessWidget {
SizedBox(height: dimensions.paddingXLarge), SizedBox(height: dimensions.paddingXLarge),
RecipientSection( RecipientSection(
recipient: recipient, recipient: recipient,
previousRecipient: previousRecipient,
dimensions: dimensions, dimensions: dimensions,
recipientProvider: recipientProvider, recipientProvider: recipientProvider,
searchQuery: searchQuery,
filteredRecipients: filteredRecipients,
searchController: searchController, searchController: searchController,
searchFocusNode: searchFocusNode, searchFocusNode: searchFocusNode,
onSearchChanged: onSearchChanged, onSearchChanged: onSearchChanged,

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; 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/utils/dimensions.dart';
import 'package:pweb/generated/i18n/app_localizations.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 { class SendButton extends StatelessWidget {
final VoidCallback onPressed; final VoidCallback onPressed;
final ButtonState state; final ControlState state;
const SendButton({ const SendButton({
super.key, super.key,
required this.onPressed, required this.onPressed,
this.state = ButtonState.enabled, this.state = ControlState.enabled,
}); });
@override @override
@@ -21,8 +21,8 @@ class SendButton extends StatelessWidget {
final theme = Theme.of(context); final theme = Theme.of(context);
final dimensions = AppDimensions(); final dimensions = AppDimensions();
final isEnabled = state == ButtonState.enabled; final isEnabled = state == ControlState.enabled;
final isLoading = state == ButtonState.loading; final isLoading = state == ControlState.loading;
final backgroundColor = isEnabled || isLoading final backgroundColor = isEnabled || isLoading
? theme.colorScheme.primary ? theme.colorScheme.primary
: theme.colorScheme.onSurface.withValues(alpha: 0.12); : theme.colorScheme.onSurface.withValues(alpha: 0.12);

View File

@@ -3,16 +3,19 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:pshared/models/payment/methods/data.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:pshared/provider/payment/flow.dart';
import 'package:pweb/pages/payment_methods/form.dart'; import 'package:pweb/pages/payment_methods/form.dart';
import 'package:pweb/pages/payment_methods/widgets/section_title.dart'; import 'package:pweb/pages/payment_methods/widgets/section_title.dart';
import 'package:pweb/utils/dimensions.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/selector_type.dart';
import 'package:pweb/utils/payment/label.dart';
import 'package:pweb/generated/i18n/app_localizations.dart'; import 'package:pweb/generated/i18n/app_localizations.dart';
//TODO Whole page sucks. Will redesign.
class PaymentInfoSection extends StatelessWidget { class PaymentInfoSection extends StatelessWidget {
final AppDimensions dimensions; final AppDimensions dimensions;
@@ -26,7 +29,13 @@ class PaymentInfoSection extends StatelessWidget {
final loc = AppLocalizations.of(context)!; final loc = AppLocalizations.of(context)!;
final flowProvider = context.watch<PaymentFlowProvider>(); final flowProvider = context.watch<PaymentFlowProvider>();
final hasRecipient = flowProvider.hasRecipient; final hasRecipient = flowProvider.hasRecipient;
final MethodMap resolvedAvailableTypes = flowProvider.availableTypes; final MethodMap resolvedAvailableTypes = filterVisiblePaymentTypes(flowProvider.availableTypes);
final disabledTypesForSelection = hasRecipient
? disabledPaymentTypes.difference(resolvedAvailableTypes.keys.toSet())
: disabledPaymentTypes;
final methodsForSelectedType = flowProvider.methodsForSelectedType;
final selectedMethod = flowProvider.selectedMethod ??
(methodsForSelectedType.isNotEmpty ? methodsForSelectedType.first : null);
if (hasRecipient && resolvedAvailableTypes.isEmpty) { if (hasRecipient && resolvedAvailableTypes.isEmpty) {
return Text(loc.recipientNoPaymentDetails); return Text(loc.recipientNoPaymentDetails);
@@ -42,12 +51,37 @@ class PaymentInfoSection extends StatelessWidget {
PaymentTypeSelector( PaymentTypeSelector(
availableTypes: resolvedAvailableTypes, availableTypes: resolvedAvailableTypes,
selectedType: selectedType, selectedType: selectedType,
disabledTypes: disabledTypesForSelection,
onSelected: (type) => flowProvider.selectType( onSelected: (type) => flowProvider.selectType(
type, type,
resetManualData: !hasRecipient, resetManualData: !hasRecipient,
), ),
), ),
SizedBox(height: dimensions.paddingMedium), SizedBox(height: dimensions.paddingMedium),
if (hasRecipient && methodsForSelectedType.length > 1)
DropdownButtonFormField<PaymentMethod>(
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( PaymentMethodForm(
selectedType: selectedType, selectedType: selectedType,
onChanged: (data) { onChanged: (data) {

View File

@@ -14,8 +14,11 @@ import 'package:pweb/generated/i18n/app_localizations.dart';
class RecipientSection extends StatelessWidget { class RecipientSection extends StatelessWidget {
final Recipient? recipient; final Recipient? recipient;
final Recipient? previousRecipient;
final AppDimensions dimensions; final AppDimensions dimensions;
final RecipientsProvider recipientProvider; final RecipientsProvider recipientProvider;
final String searchQuery;
final List<Recipient> filteredRecipients;
final TextEditingController searchController; final TextEditingController searchController;
final FocusNode searchFocusNode; final FocusNode searchFocusNode;
final ValueChanged<String> onSearchChanged; final ValueChanged<String> onSearchChanged;
@@ -25,8 +28,11 @@ class RecipientSection extends StatelessWidget {
const RecipientSection({ const RecipientSection({
super.key, super.key,
required this.recipient, required this.recipient,
required this.previousRecipient,
required this.dimensions, required this.dimensions,
required this.recipientProvider, required this.recipientProvider,
required this.searchQuery,
required this.filteredRecipients,
required this.searchController, required this.searchController,
required this.searchFocusNode, required this.searchFocusNode,
required this.onSearchChanged, required this.onSearchChanged,
@@ -48,8 +54,8 @@ class RecipientSection extends StatelessWidget {
return AnimatedBuilder( return AnimatedBuilder(
animation: recipientProvider, animation: recipientProvider,
builder: (context, _) { builder: (context, _) {
final previousRecipient = recipientProvider.previousRecipient; final hasQuery = searchQuery.isNotEmpty;
final hasQuery = recipientProvider.query.isNotEmpty; final prev = previousRecipient;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -61,15 +67,15 @@ class RecipientSection extends StatelessWidget {
onChanged: onSearchChanged, onChanged: onSearchChanged,
focusNode: searchFocusNode, focusNode: searchFocusNode,
), ),
if (previousRecipient != null) ...[ if (prev != null) ...[
SizedBox(height: dimensions.paddingSmall), SizedBox(height: dimensions.paddingSmall),
ListTile( ListTile(
dense: true, dense: true,
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.undo), leading: const Icon(Icons.undo),
title: Text(loc.back), title: Text(loc.back),
subtitle: Text(previousRecipient.name), subtitle: Text(prev.name),
onTap: () => onRecipientSelected(previousRecipient), onTap: () => onRecipientSelected(prev),
), ),
], ],
if (hasQuery) ...[ if (hasQuery) ...[
@@ -77,6 +83,7 @@ class RecipientSection extends StatelessWidget {
RecipientSearchResults( RecipientSearchResults(
dimensions: dimensions, dimensions: dimensions,
recipientProvider: recipientProvider, recipientProvider: recipientProvider,
results: filteredRecipients,
onRecipientSelected: onRecipientSelected, onRecipientSelected: onRecipientSelected,
), ),
], ],

View File

@@ -11,12 +11,14 @@ import 'package:pweb/generated/i18n/app_localizations.dart';
class RecipientSearchResults extends StatelessWidget { class RecipientSearchResults extends StatelessWidget {
final AppDimensions dimensions; final AppDimensions dimensions;
final RecipientsProvider recipientProvider; final RecipientsProvider recipientProvider;
final List<Recipient> results;
final ValueChanged<Recipient> onRecipientSelected; final ValueChanged<Recipient> onRecipientSelected;
const RecipientSearchResults({ const RecipientSearchResults({
super.key, super.key,
required this.dimensions, required this.dimensions,
required this.recipientProvider, required this.recipientProvider,
required this.results,
required this.onRecipientSelected, required this.onRecipientSelected,
}); });
@@ -38,8 +40,6 @@ class RecipientSearchResults extends StatelessWidget {
return Text(loc.noRecipientsYet); return Text(loc.noRecipientsYet);
} }
final results = recipientProvider.filteredRecipients;
if (results.isEmpty) { if (results.isEmpty) {
return Text(loc.noRecipientsFound); return Text(loc.noRecipientsFound);
} }

View File

@@ -9,6 +9,7 @@ import 'package:pweb/app/router/pages.dart';
import 'package:pweb/pages/errors/error.dart'; import 'package:pweb/pages/errors/error.dart';
import 'package:pweb/pages/status/success.dart'; import 'package:pweb/pages/status/success.dart';
import 'package:pweb/pages/with_footer.dart'; import 'package:pweb/pages/with_footer.dart';
import 'package:pweb/generated/i18n/app_localizations.dart'; import 'package:pweb/generated/i18n/app_localizations.dart';

View File

@@ -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<PaymentType> _supportedTypes;
final Map<PaymentType, List<RecipientMethodDraft>> _methods;
SeedState _seedState = SeedState.idle;
AddressBookRecipientFormProvider({
required List<PaymentType> supportedTypes,
Recipient? recipient,
}) : _recipient = recipient,
_supportedTypes = supportedTypes,
_methods = {
for (final type in supportedTypes) type: <RecipientMethodDraft>[],
};
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<PaymentType> get supportedTypes => List.unmodifiable(_supportedTypes);
Map<PaymentType, List<RecipientMethodDraft>> get methods => {
for (final entry in _methods.entries)
entry.key: List<RecipientMethodDraft>.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<RecipientMethodDraft> 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<PaymentMethod> existing) {
if (existing.isEmpty) return;
final next = <PaymentType, List<RecipientMethodDraft>>{
for (final type in _supportedTypes) type: <RecipientMethodDraft>[],
};
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<Recipient> save({
required String name,
required String email,
required Map<PaymentType, String> 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();
}
}

View File

@@ -0,0 +1,26 @@
import 'package:pshared/models/payment/methods/data.dart';
import 'package:pshared/models/payment/type.dart';
const List<PaymentType> enabledPaymentTypes = [
PaymentType.card,
PaymentType.ledger,
PaymentType.externalChain,
];
const List<PaymentType> previewPaymentTypes = [
PaymentType.bankAccount,
];
const List<PaymentType> visiblePaymentTypes = [
...enabledPaymentTypes,
...previewPaymentTypes,
];
const Set<PaymentType> disabledPaymentTypes = {
PaymentType.bankAccount,
};
MethodMap filterVisiblePaymentTypes(MethodMap source) => {
for (final type in visiblePaymentTypes)
if (source.containsKey(type)) type: source[type],
};

View File

@@ -10,12 +10,14 @@ class PaymentTypeSelector extends StatelessWidget {
final MethodMap availableTypes; final MethodMap availableTypes;
final PaymentType selectedType; final PaymentType selectedType;
final ValueChanged<PaymentType> onSelected; final ValueChanged<PaymentType> onSelected;
final Set<PaymentType> disabledTypes;
const PaymentTypeSelector({ const PaymentTypeSelector({
super.key, super.key,
required this.availableTypes, required this.availableTypes,
required this.selectedType, required this.selectedType,
required this.onSelected, required this.onSelected,
this.disabledTypes = const {},
}); });
static const double _chipSpacing = 12.0; static const double _chipSpacing = 12.0;
@@ -30,14 +32,16 @@ class PaymentTypeSelector extends StatelessWidget {
runSpacing: _chipSpacing, runSpacing: _chipSpacing,
children: availableTypes.keys.map((type) { children: availableTypes.keys.map((type) {
final isSelected = selectedType == 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( return ChoiceChip(
label: Text( label: Text(
getPaymentTypeLabel(context, type), getPaymentTypeLabel(context, type),
style: theme.textTheme.titleMedium!.copyWith( style: theme.textTheme.titleMedium!.copyWith(
color: isSelected color: labelColor,
? theme.colorScheme.onPrimary
: theme.colorScheme.onSurface,
), ),
), ),
selected: isSelected, selected: isSelected,
@@ -47,7 +51,7 @@ class PaymentTypeSelector extends StatelessWidget {
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(_chipBorderRadius), borderRadius: BorderRadius.circular(_chipBorderRadius),
), ),
onSelected: (_) => onSelected(type), onSelected: isDisabled ? null : (_) => onSelected(type),
); );
}).toList(), }).toList(),
); );

View File

@@ -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<Recipient> filterRecipients({
required List<Recipient> 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();
}