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/data.dart';
import 'package:pshared/models/payment/methods/iban.dart';
import 'package:pshared/models/payment/methods/ledger.dart';
import 'package:pshared/models/payment/methods/managed_wallet.dart';
import 'package:pshared/models/payment/methods/russian_bank.dart';
import 'package:pshared/models/payment/methods/wallet.dart';
@@ -46,6 +47,7 @@ class PaymentMethod implements PermissionBoundStorable, Describable {
WalletPaymentMethod? get walletData => dataAsOrNull<WalletPaymentMethod>();
ManagedWalletPaymentMethod? get managedWalletData => dataAsOrNull<ManagedWalletPaymentMethod>();
CryptoAddressPaymentMethod? get cryptoAddressData => dataAsOrNull<CryptoAddressPaymentMethod>();
LedgerPaymentMethod? get ledgerData => dataAsOrNull<LedgerPaymentMethod>();
@override
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;
List<PaymentMethod> _recipientMethods = [];
Recipient? _recipient;
String? _selectedMethodId;
PaymentFlowProvider({
required PaymentType initialType,
@@ -25,9 +26,14 @@ class PaymentFlowProvider extends ChangeNotifier {
PaymentType get selectedType => _selectedType;
PaymentMethodData? get manualPaymentData => _manualPaymentData;
Recipient? get recipient => _recipient;
PaymentMethod? get selectedMethod => hasRecipient
? _recipientMethods.firstWhereOrNull((method) => method.type == _selectedType)
: null;
PaymentMethod? get selectedMethod {
if (!hasRecipient) return null;
if (_selectedMethodId != null) {
final byId = _recipientMethods.firstWhereOrNull((method) => method.id == _selectedMethodId);
if (byId != null) return byId;
}
return _preferredMethodForType(_selectedType, _recipientMethods);
}
bool get hasRecipient => _recipient != null;
@@ -42,6 +48,12 @@ class PaymentFlowProvider extends ChangeNotifier {
? List<PaymentMethod>.unmodifiable(_recipientMethods)
: const [];
List<PaymentMethod> get methodsForSelectedType => hasRecipient
? List<PaymentMethod>.unmodifiable(
_recipientMethods.where((method) => method.type == _selectedType).toList(),
)
: const [];
void update(
RecipientsProvider recipientsProvider,
PaymentMethodsProvider methodsProvider,
@@ -63,12 +75,25 @@ class PaymentFlowProvider extends ChangeNotifier {
}
_selectedType = type;
if (hasRecipient) {
_selectedMethodId = _preferredMethodForType(type, _recipientMethods)?.id;
}
if (resetManualData) {
_manualPaymentData = null;
}
notifyListeners();
}
void selectMethod(PaymentMethod method) {
if (!hasRecipient) return;
if (_selectedMethodId == method.id && _selectedType == method.type) return;
_selectedMethodId = method.id;
if (_selectedType != method.type) {
_selectedType = method.type;
}
notifyListeners();
}
void setManualPaymentData(PaymentMethodData? data) {
_manualPaymentData = data;
notifyListeners();
@@ -124,6 +149,12 @@ class PaymentFlowProvider extends ChangeNotifier {
availableTypes: availableTypes,
preferredType: preferredType,
);
final resolvedMethod = _resolveSelectedMethod(
recipient: recipient,
methods: methods,
selectedType: resolvedType,
selectedMethodId: _selectedMethodId,
);
var hasChanges = false;
@@ -142,6 +173,11 @@ class PaymentFlowProvider extends ChangeNotifier {
hasChanges = true;
}
if ((resolvedMethod?.id ?? _selectedMethodId) != _selectedMethodId) {
_selectedMethodId = resolvedMethod?.id;
hasChanges = true;
}
if ((recipient != null || forceResetManualData) && _manualPaymentData != null) {
_manualPaymentData = null;
hasChanges = true;
@@ -154,6 +190,28 @@ class PaymentFlowProvider extends ChangeNotifier {
for (final method in methods) method.type: method.data,
};
PaymentMethod? _preferredMethodForType(PaymentType type, List<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) {
if (_recipientMethods.length != methods.length) return false;
for (var i = 0; i < methods.length; i++) {

View File

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

View File

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

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

View File

@@ -372,6 +372,22 @@
"paymentConfigTitle": "Where to receive money",
"paymentConfigSubtitle": "Add multiple methods and choose your primary one.",
"addPaymentMethod": "Add payment method",
"paymentMethodAdded": "Added",
"@paymentMethodAdded": {
"description": "Status badge text for a payment method that is already configured"
},
"paymentMethodNotAdded": "Not added",
"@paymentMethodNotAdded": {
"description": "Status badge text for a payment method that has no details yet"
},
"paymentMethodComingSoon": "Coming soon",
"@paymentMethodComingSoon": {
"description": "Status badge text for a payment method that is visible but not available yet"
},
"paymentMethodDetails": "Payment details",
"@paymentMethodDetails": {
"description": "Title above the selected payment method form"
},
"makeMain": "Make primary",
"advanced": "Advanced",
"fallbackExplanation": "If the primary method is unavailable, we will try the next enabled one in the list.",
@@ -436,9 +452,16 @@
"walletId": "Wallet ID",
"enterWalletId": "Enter wallet ID",
"ledgerAccountRef": "Ledger account reference",
"enterLedgerAccountRef": "Enter ledger account reference",
"contraLedgerAccountRef": "Contra ledger account reference (optional)",
"recipients": "Recipients",
"recipientName": "Recipient Name",
"recipientNameHint": "e.g. Alex Johnson",
"@recipientNameHint": {
"description": "Hint shown in the recipient name field"
},
"enterRecipientName": "Enter recipient name",
"inn": "INN",
"enterInn": "Enter INN",

View File

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

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:provider/provider.dart';
import 'package:pshared/models/payment/methods/data.dart';
import 'package:pshared/models/payment/type.dart';
import 'package:pshared/models/recipient/recipient.dart';
import 'package:pshared/models/recipient/status.dart';
import 'package:pshared/models/recipient/type.dart';
import 'package:pshared/provider/recipient/methods_cache.dart';
import 'package:pshared/provider/recipient/provider.dart';
import 'package:pweb/pages/address_book/form/view.dart';
import 'package:pweb/services/posthog.dart';
import 'package:pweb/utils/error/snackbar.dart';
import 'package:pweb/pages/address_book/form/body.dart';
import 'package:pweb/providers/address_book_recipient_form.dart';
import 'package:pweb/utils/payment/availability.dart';
import 'package:pweb/utils/payment/label.dart';
import 'package:pweb/utils/snackbar.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
@@ -35,11 +29,8 @@ class _AddressBookRecipientFormState extends State<AddressBookRecipientForm> {
final _formKey = GlobalKey<FormState>();
late TextEditingController _nameCtrl;
late TextEditingController _emailCtrl;
RecipientType _type = RecipientType.internal;
RecipientStatus _status = RecipientStatus.ready;
final MethodMap _methods = {};
late RecipientMethodsCacheProvider _methodsCacheProvider;
bool _hasInitializedMethods = false;
static const List<PaymentType> _supportedTypes = visiblePaymentTypes;
@override
void initState() {
@@ -47,129 +38,60 @@ class _AddressBookRecipientFormState extends State<AddressBookRecipientForm> {
final r = widget.recipient;
_nameCtrl = TextEditingController(text: r?.name ?? '');
_emailCtrl = TextEditingController(text: r?.email ?? '');
_type = r?.type ?? RecipientType.internal;
_status = r?.status ?? RecipientStatus.ready;
_methodsCacheProvider = context.read<RecipientMethodsCacheProvider>()
..addListener(_onProviderChanged);
if (r != null) {
_methodsCacheProvider.refreshRecipient(r.id);
_syncMethodsFromCache();
}
}
Future<Recipient?> _doSave() async {
final recipients = context.read<RecipientsProvider>();
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;
}
Map<PaymentType, String> _methodNames(BuildContext context) => {
for (final type in _supportedTypes) type: getPaymentTypeLabel(context, type),
};
Future<void> _save() async {
Future<void> _save(AddressBookRecipientFormProvider formState) async {
final l10n = AppLocalizations.of(context)!;
if (!_formKey.currentState!.validate()) {
notifyUser(context, l10n.recipientFormValidationError);
if (!_formKey.currentState!.validate() || !formState.hasAnyMethod) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.recipientFormRule)),
);
return;
}
if (_methods.isEmpty) {
notifyUser(context, l10n.recipientFormRule);
return;
try {
final saved = await formState.save(
name: _nameCtrl.text,
email: _emailCtrl.text,
methodNames: _methodNames(context),
);
widget.onSaved?.call(saved);
} catch (_) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.notificationError(l10n.noErrorInformation))),
);
}
}
unawaited(PosthogService.recipientAddCompleted(
_type,
_status,
_methods.keys.toSet(),
));
final recipient = await executeActionWithNotification(
context: context,
action: _doSave,
errorMessage: l10n.errorSaveRecipient,
successMessage: l10n.recipientSavedSuccessfully,
@override
Widget build(BuildContext context) {
return ChangeNotifierProxyProvider2<
RecipientsProvider,
RecipientMethodsCacheProvider,
AddressBookRecipientFormProvider
>(
create: (_) => AddressBookRecipientFormProvider(
recipient: widget.recipient,
supportedTypes: _supportedTypes,
),
update: (_, recipientsProvider, methodsCache, formProvider) =>
formProvider!..updateProviders(
recipientsProvider: recipientsProvider,
methodsCache: methodsCache,
),
child: AddressBookRecipientFormBody(
formKey: _formKey,
nameCtrl: _nameCtrl,
emailCtrl: _emailCtrl,
isEditing: widget.recipient != null,
onSave: _save,
onBack: () => widget.onSaved?.call(null),
),
);
widget.onSaved?.call(recipient);
}
@override
void dispose() {
_methodsCacheProvider.removeListener(_onProviderChanged);
super.dispose();
}
void _onProviderChanged() => _syncMethodsFromCache();
void _syncMethodsFromCache() {
final recipient = widget.recipient;
if (recipient == null || _hasInitializedMethods) return;
if (!_methodsCacheProvider.hasMethodsFor(recipient.id)) return;
final list = _methodsCacheProvider.methodsForRecipient(recipient.id);
if (list.isEmpty) {
_hasInitializedMethods = true;
return;
}
setState(() {
_methods
..clear()
..addEntries(list.map((m) {
final data = switch (m.type) {
PaymentType.card => m.cardData,
PaymentType.iban => m.ibanData,
PaymentType.wallet => m.walletData,
PaymentType.bankAccount => m.bankAccountData,
PaymentType.externalChain => m.cryptoAddressData,
//TODO: support new payment methods
_ => throw UnimplementedError('Payment method ${m.type} is not supported yet'),
};
return MapEntry(m.type, data);
}));
_hasInitializedMethods = true;
});
}
@override
Widget build(BuildContext context) => FormView(
formKey: _formKey,
nameCtrl: _nameCtrl,
emailCtrl: _emailCtrl,
type: _type,
status: _status,
methods: _methods,
onTypeChanged: (t) => setState(() => _type = t),
onStatusChanged: (s) => setState(() => _status = s),
onMethodsChanged: (type, data) {
setState(() {
if (data != null) {
_methods[type] = data;
} else {
_methods.remove(type);
}
});
},
onSave: _save,
isEditing: widget.recipient != null,
onBack: () {
widget.onSaved?.call(null);
},
);
}

View File

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

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

View File

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

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(
text ?? AppLocalizations.of(context)!.saveRecipient,
style: textStyle ??
theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.onPrimary,
fontWeight: FontWeight.w600,
),
theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.onPrimary,
fontWeight: FontWeight.w600,
),
),
),
),

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

View File

@@ -3,14 +3,13 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/models/recipient/recipient.dart';
import 'package:pshared/provider/organizations.dart';
import 'package:pshared/provider/recipient/pmethods.dart';
import 'package:pshared/provider/recipient/methods_cache.dart';
import 'package:pweb/pages/address_book/page/recipient/info_row.dart';
import 'package:pweb/utils/payment/label.dart';
class RecipientPaymentRow extends StatefulWidget {
class RecipientPaymentRow extends StatelessWidget {
final Recipient recipient;
final double spacing;
@@ -20,40 +19,19 @@ class RecipientPaymentRow extends StatefulWidget {
this.spacing = 18
});
@override
State<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
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(
spacing: widget.spacing,
children: _methodsProvider.methods.map((m) => RecipientAddressBookInfoRow(
spacing: spacing,
children: cacheProvider.methodsForRecipient(recipientId).map((m) => RecipientAddressBookInfoRow(
type: m.type,
value: getPaymentTypeDescription(context, m),
)).toList(),

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

View File

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

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:pshared/models/payment/type.dart';
import 'package:pweb/models/control_state.dart';
import 'package:pweb/pages/payment_methods/icon.dart';
import 'package:pweb/utils/payment/label.dart';
@@ -9,30 +10,53 @@ import 'package:pweb/utils/payment/label.dart';
class PaymentTypeTile extends StatelessWidget {
final PaymentType type;
final void Function(PaymentType type) onSelected;
final ControlState state;
const PaymentTypeTile({
super.key,
required this.type,
required this.onSelected,
this.state = ControlState.enabled,
});
@override
Widget build(BuildContext context) {
final label = getPaymentTypeLabel(context, type);
final theme = Theme.of(context);
final isEnabled = state == ControlState.enabled;
final isDisabled = state == ControlState.disabled;
final isLoading = state == ControlState.loading;
final textColor = isDisabled
? theme.colorScheme.onSurface.withValues(alpha: 0.55)
: theme.colorScheme.onSurface;
return InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () => onSelected(type),
onTap: isEnabled ? () => onSelected(type) : null,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Icon(iconForPaymentType(type), size: 24),
Icon(iconForPaymentType(type), size: 24, color: textColor),
const SizedBox(width: 12),
Text(
label,
style: Theme.of(context).textTheme.bodyMedium,
style: theme.textTheme.bodyMedium?.copyWith(color: textColor),
),
if (isLoading) ...[
const SizedBox(width: 12),
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<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 {
final PaymentType? value;
final List<PaymentType> types;
final Set<PaymentType> disabledTypes;
final ValueChanged<PaymentType?> onChanged;
const PaymentMethodTypeSelector({
super.key,
required this.value,
required this.types,
this.disabledTypes = const {},
required this.onChanged,
});
@@ -24,9 +28,15 @@ class PaymentMethodTypeSelector extends StatelessWidget {
return DropdownButtonFormField<PaymentType>(
initialValue: value,
decoration: InputDecoration(labelText: l10n.paymentType),
items: PaymentType.values.map((type) {
items: types.map((type) {
final label = getPaymentTypeLabel(context, type);
return DropdownMenuItem(value: type, child: Text(label));
final isDisabled = disabledTypes.contains(type);
final effectiveLabel = isDisabled ? '$label - ${l10n.paymentMethodComingSoon}' : label;
return DropdownMenuItem(
value: type,
enabled: !isDisabled,
child: Text(effectiveLabel),
);
}).toList(),
onChanged: onChanged,
validator: (val) => val == null ? l10n.selectPaymentType : null,

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,16 +3,19 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/models/payment/methods/data.dart';
import 'package:pshared/models/payment/methods/type.dart';
import 'package:pshared/provider/payment/flow.dart';
import 'package:pweb/pages/payment_methods/form.dart';
import 'package:pweb/pages/payment_methods/widgets/section_title.dart';
import 'package:pweb/utils/dimensions.dart';
import 'package:pweb/utils/payment/availability.dart';
import 'package:pweb/utils/payment/selector_type.dart';
import 'package:pweb/utils/payment/label.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
//TODO Whole page sucks. Will redesign.
class PaymentInfoSection extends StatelessWidget {
final AppDimensions dimensions;
@@ -26,7 +29,13 @@ class PaymentInfoSection extends StatelessWidget {
final loc = AppLocalizations.of(context)!;
final flowProvider = context.watch<PaymentFlowProvider>();
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) {
return Text(loc.recipientNoPaymentDetails);
@@ -42,12 +51,37 @@ class PaymentInfoSection extends StatelessWidget {
PaymentTypeSelector(
availableTypes: resolvedAvailableTypes,
selectedType: selectedType,
disabledTypes: disabledTypesForSelection,
onSelected: (type) => flowProvider.selectType(
type,
resetManualData: !hasRecipient,
),
),
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(
selectedType: selectedType,
onChanged: (data) {

View File

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

View File

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

View File

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

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

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();
}