refactoring for recipient addition page #344
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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++) {
|
||||
|
||||
@@ -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,33 +60,41 @@ 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);
|
||||
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;
|
||||
}
|
||||
} else {
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -92,7 +102,6 @@ class RecipientMethodsCacheProvider extends ChangeNotifier {
|
||||
);
|
||||
current.add(created);
|
||||
}
|
||||
}
|
||||
|
||||
_methodsByRecipient[recipientId] = _sortedMethods(current);
|
||||
notifyListeners();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Введите ИНН",
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
enum ButtonState { enabled, disabled, loading }
|
||||
1
frontend/pweb/lib/models/control_state.dart
Normal file
1
frontend/pweb/lib/models/control_state.dart
Normal file
@@ -0,0 +1 @@
|
||||
enum ControlState { enabled, disabled, loading }
|
||||
@@ -0,0 +1 @@
|
||||
enum PaymentMethodTileAvailability { added, available, comingSoon }
|
||||
@@ -0,0 +1 @@
|
||||
enum PaymentMethodTileSelection { selected, idle }
|
||||
1
frontend/pweb/lib/models/seed_state.dart
Normal file
1
frontend/pweb/lib/models/seed_state.dart
Normal file
@@ -0,0 +1 @@
|
||||
enum SeedState { idle, seeded }
|
||||
120
frontend/pweb/lib/pages/address_book/form/body.dart
Normal file
120
frontend/pweb/lib/pages/address_book/form/body.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
Map<PaymentType, String> _methodNames(BuildContext context) => {
|
||||
for (final type in _supportedTypes) type: getPaymentTypeLabel(context, type),
|
||||
};
|
||||
|
||||
Future<void> _save(AddressBookRecipientFormProvider formState) async {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
if (!_formKey.currentState!.validate() || !formState.hasAnyMethod) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l10n.recipientFormRule)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final saved = await formState.save(
|
||||
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,
|
||||
methodNames: _methodNames(context),
|
||||
);
|
||||
return recipient;
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
||||
if (!_formKey.currentState!.validate()) {
|
||||
notifyUser(context, l10n.recipientFormValidationError);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_methods.isEmpty) {
|
||||
notifyUser(context, l10n.recipientFormRule);
|
||||
return;
|
||||
}
|
||||
|
||||
unawaited(PosthogService.recipientAddCompleted(
|
||||
_type,
|
||||
_status,
|
||||
_methods.keys.toSet(),
|
||||
));
|
||||
final recipient = await executeActionWithNotification(
|
||||
context: context,
|
||||
action: _doSave,
|
||||
errorMessage: l10n.errorSaveRecipient,
|
||||
successMessage: l10n.recipientSavedSuccessfully,
|
||||
widget.onSaved?.call(saved);
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l10n.notificationError(l10n.noErrorInformation))),
|
||||
);
|
||||
|
||||
|
||||
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(
|
||||
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,
|
||||
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);
|
||||
},
|
||||
onSave: _save,
|
||||
onBack: () => widget.onSaved?.call(null),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
PaymentMethodSelectorRow(
|
||||
types: types,
|
||||
selectedType: selectedType,
|
||||
selectedIndex: selectedIndex,
|
||||
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),
|
||||
SaveButton(onSave: onSave),
|
||||
SizedBox(height: spacingBottom),
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
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,
|
||||
),
|
||||
validator: (v) =>
|
||||
v == null || v.isEmpty ? loc.usernameErrorInvalid : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
121
frontend/pweb/lib/pages/payment_methods/add/ledger.dart
Normal file
121
frontend/pweb/lib/pages/payment_methods/add/ledger.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
|
||||
170
frontend/pweb/lib/providers/address_book_recipient_form.dart
Normal file
170
frontend/pweb/lib/providers/address_book_recipient_form.dart
Normal 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 {
|
||||
|
tech
commented
больше на контроллер похоже, чем на провайдер. больше на контроллер похоже, чем на провайдер.
|
||||
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();
|
||||
}
|
||||
}
|
||||
26
frontend/pweb/lib/utils/payment/availability.dart
Normal file
26
frontend/pweb/lib/utils/payment/availability.dart
Normal 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],
|
||||
};
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
28
frontend/pweb/lib/utils/recipient/filtering.dart
Normal file
28
frontend/pweb/lib/utils/recipient/filtering.dart
Normal 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();
|
||||
}
|
||||
Reference in New Issue
Block a user
Сейчас можно не править, но для себя понял, что связанные с UI вещи лучше укладывать в аналоги провайдера для UI - контроллеры. Так проще код получается, а провайдер фокусируется строго на одной задаче - получения и управления данными, тогда как контроллер работает как надстройка над провайдером, обеспечивая прокладку между UI и провайдером, сохраняя в себе состояния UI.