refactoring for recipient addition page

This commit is contained in:
Arseni
2026-01-29 19:22:30 +03:00
parent da8da04ae9
commit efa69b43b2
47 changed files with 1376 additions and 532 deletions

View File

@@ -0,0 +1,120 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/models/payment/methods/data.dart';
import 'package:pshared/models/payment/type.dart';
import 'package:pweb/pages/address_book/form/view.dart';
import 'package:pweb/providers/address_book_recipient_form.dart';
import 'package:pweb/utils/payment/availability.dart';
class AddressBookRecipientFormBody extends StatefulWidget {
final GlobalKey<FormState> formKey;
final TextEditingController nameCtrl;
final TextEditingController emailCtrl;
final bool isEditing;
final Future<void> Function(AddressBookRecipientFormProvider) onSave;
final VoidCallback onBack;
const AddressBookRecipientFormBody({
required this.formKey,
required this.nameCtrl,
required this.emailCtrl,
required this.isEditing,
required this.onSave,
required this.onBack,
});
@override
State<AddressBookRecipientFormBody> createState() => _AddressBookRecipientFormBodyState();
}
class _AddressBookRecipientFormBodyState extends State<AddressBookRecipientFormBody> {
PaymentType? _selectedType;
int? _selectedIndex;
void _reconcileSelection(AddressBookRecipientFormProvider formState) {
final types = formState.supportedTypes;
if (types.isEmpty) return;
var nextType = _selectedType;
var nextIndex = _selectedIndex;
if (nextType == null || !types.contains(nextType)) {
nextType = formState.preferredType ?? types.first;
nextIndex = null;
}
final entries = formState.methods[nextType] ?? const [];
if (entries.isEmpty) {
nextIndex = null;
} else if (nextIndex == null || nextIndex < 0 || nextIndex >= entries.length) {
nextIndex = 0;
}
if (nextType == _selectedType && nextIndex == _selectedIndex) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
setState(() {
_selectedType = nextType;
_selectedIndex = nextIndex;
});
});
}
void _onMethodSelected(PaymentType type, int index) {
setState(() {
_selectedType = type;
_selectedIndex = index;
});
}
void _onMethodAdd(AddressBookRecipientFormProvider formState, PaymentType type) {
final newIndex = formState.addMethod(type);
setState(() {
_selectedType = type;
_selectedIndex = newIndex;
});
}
void _onMethodRemove(AddressBookRecipientFormProvider formState, int index) {
final type = _selectedType ?? formState.supportedTypes.first;
formState.removeMethod(type, index);
}
void _onMethodChanged(
AddressBookRecipientFormProvider formState,
int index,
PaymentMethodData data,
) {
final type = _selectedType ?? formState.supportedTypes.first;
formState.updateMethod(type, index, data);
}
@override
Widget build(BuildContext context) {
final formState = context.watch<AddressBookRecipientFormProvider>();
_reconcileSelection(formState);
final selectedType = _selectedType ?? formState.supportedTypes.first;
return FormView(
formKey: widget.formKey,
nameCtrl: widget.nameCtrl,
emailCtrl: widget.emailCtrl,
types: formState.supportedTypes,
selectedType: selectedType,
selectedIndex: _selectedIndex,
methods: formState.methods,
onMethodSelected: _onMethodSelected,
onMethodAdd: (type) => _onMethodAdd(formState, type),
disabledTypes: disabledPaymentTypes,
onMethodRemove: (index) => _onMethodRemove(formState, index),
onMethodChanged: (index, data) => _onMethodChanged(formState, index, data),
onSave: () => widget.onSave(formState),
isEditing: widget.isEditing,
onBack: widget.onBack,
);
}
}

View File

@@ -1,105 +0,0 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/payment/methods/data.dart';
import 'package:pshared/models/payment/type.dart';
import 'package:pweb/pages/payment_methods/form.dart';
import 'package:pweb/pages/payment_methods/icon.dart';
import 'package:pweb/widgets/dialogs/confirmation_dialog.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class AddressBookPaymentMethodTile extends StatefulWidget {
final PaymentType type;
final String title;
final MethodMap methods;
final ValueChanged<PaymentMethodData?> onChanged;
final double spacingM;
final double spacingS;
final double sizeM;
final TextStyle? titleTextStyle;
const AddressBookPaymentMethodTile({
super.key,
required this.type,
required this.title,
required this.methods,
required this.onChanged,
this.spacingM = 12,
this.spacingS = 8,
this.sizeM = 20,
this.titleTextStyle,
});
@override
State<AddressBookPaymentMethodTile> createState() => _AddressBookPaymentMethodTileState();
}
class _AddressBookPaymentMethodTileState extends State<AddressBookPaymentMethodTile> {
Future<void> _confirmDelete() async {
final l10n = AppLocalizations.of(context)!;
final confirmed = await showConfirmationDialog(
context: context,
title: l10n.delete,
message: l10n.deletePaymentConfirmation,
confirmLabel: l10n.delete,
);
if (confirmed) {
widget.onChanged(null);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isAdded = widget.methods.containsKey(widget.type);
return ExpansionTile(
title: Row(
children: [
Icon(
iconForPaymentType(widget.type),
size: widget.sizeM,
color: isAdded
? theme.colorScheme.primary
: theme.colorScheme.onSurface,
),
SizedBox(width: widget.spacingS),
Text(
widget.title,
style: widget.titleTextStyle ??
theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: isAdded ? theme.colorScheme.primary : null,
),
),
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isAdded)
IconButton(
icon: Icon(Icons.delete, color: theme.colorScheme.error),
onPressed: _confirmDelete,
),
Icon(
isAdded ? Icons.check_circle : Icons.add_circle_outline,
color: isAdded ? theme.colorScheme.primary : null,
),
],
),
children: [
PaymentMethodForm(
key: ValueKey(widget.type),
selectedType: widget.type,
initialData: widget.methods[widget.type],
onChanged: widget.onChanged,
),
SizedBox(height: widget.spacingM),
],
);
}
}

View File

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

View File

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

View File

@@ -1,62 +0,0 @@
import 'package:flutter/material.dart';
class ChoiceChips<T> extends StatelessWidget {
final String label;
final List<T> values;
final T selected;
final ValueChanged<T> onChanged;
final double spacing;
final double runSpacing;
final double labelSpacing;
const ChoiceChips({
super.key,
required this.label,
required this.values,
required this.selected,
required this.onChanged,
this.spacing = 8,
this.runSpacing = 8,
this.labelSpacing = 8,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: theme.textTheme.titleMedium!.copyWith(fontWeight: FontWeight.bold),
),
SizedBox(height: labelSpacing),
Wrap(
spacing: spacing,
runSpacing: runSpacing,
children: values.map((v) {
final isSelected = v == selected;
return ChoiceChip(
selectedColor: theme.colorScheme.primary,
backgroundColor: theme.colorScheme.onSecondary,
showCheckmark: false,
label: Text(
v.toString().split('.').last,
style: TextStyle(
color: isSelected
? theme.colorScheme.onSecondary
: theme.colorScheme.inverseSurface,
),
),
selected: isSelected,
onSelected: (_) => onChanged(v),
);
}).toList(),
),
],
);
}
}

View File

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

View File

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

View File

@@ -0,0 +1,86 @@
import 'package:flutter/material.dart';
class RecipientTextField extends StatelessWidget {
final TextEditingController controller;
final String labelText;
final String hintText;
final String? helperText;
final IconData icon;
final TextInputType keyboardType;
final TextInputAction textInputAction;
final TextCapitalization textCapitalization;
final Iterable<String>? autofillHints;
final FormFieldValidator<String>? validator;
final double borderRadius;
final EdgeInsetsGeometry contentPadding;
const RecipientTextField({
super.key,
required this.controller,
required this.labelText,
required this.hintText,
required this.icon,
this.helperText,
this.keyboardType = TextInputType.text,
this.textInputAction = TextInputAction.next,
this.textCapitalization = TextCapitalization.none,
this.autofillHints,
this.validator,
this.borderRadius = 12,
this.contentPadding = const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return ValueListenableBuilder<TextEditingValue>(
valueListenable: controller,
builder: (context, value, _) {
final isEmpty = value.text.trim().isEmpty;
final errorColor = theme.colorScheme.error;
final neutralBorder = theme.colorScheme.onSurface.withAlpha(40);
final borderColor = isEmpty ? errorColor : neutralBorder;
final focusedColor = isEmpty ? errorColor : theme.colorScheme.primary;
return TextFormField(
controller: controller,
keyboardType: keyboardType,
textInputAction: textInputAction,
textCapitalization: textCapitalization,
autofillHints: autofillHints,
decoration: InputDecoration(
labelText: labelText,
hintText: hintText,
helperText: helperText,
prefixIcon: Icon(icon, color: isEmpty ? errorColor : null),
filled: true,
fillColor: theme.colorScheme.onSecondary,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(borderRadius),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(borderRadius),
borderSide: BorderSide(color: borderColor),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(borderRadius),
borderSide: BorderSide(color: focusedColor, width: 1.4),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(borderRadius),
borderSide: BorderSide(color: errorColor),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(borderRadius),
borderSide: BorderSide(color: errorColor, width: 1.4),
),
contentPadding: contentPadding,
),
validator: validator,
);
},
);
}
}

View File

@@ -0,0 +1,95 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/payment/type.dart';
import 'package:pweb/pages/payment_methods/icon.dart';
import 'package:pweb/utils/payment/label.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class AddPaymentMethodButton extends StatelessWidget {
final List<PaymentType> types;
final Set<PaymentType> disabledTypes;
final ValueChanged<PaymentType> onAdd;
static const double _borderRadius = 14;
static const double _iconSize = 18;
static const double _iconTextSpacing = 8;
static const double _menuIconSize = 18;
static const double _menuIconTextSpacing = 8;
static const double _buttonHeight = 70;
static const EdgeInsets _buttonPadding = EdgeInsets.symmetric(horizontal: 14, vertical: 10);
static const FontWeight _labelWeight = FontWeight.w600;
const AddPaymentMethodButton({
required this.types,
required this.disabledTypes,
required this.onAdd,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l10n = AppLocalizations.of(context)!;
final hasEnabled = types.any((type) => !disabledTypes.contains(type));
final borderColor = hasEnabled
? theme.colorScheme.primary.withValues(alpha: 0.5)
: theme.colorScheme.onSurface.withValues(alpha: 0.2);
final textColor = hasEnabled
? theme.colorScheme.primary
: theme.colorScheme.onSurface.withValues(alpha: 0.4);
return PopupMenuButton<PaymentType>(
enabled: hasEnabled,
onSelected: onAdd,
itemBuilder: (context) => types
.map((type) {
final isDisabled = disabledTypes.contains(type);
return PopupMenuItem<PaymentType>(
value: type,
enabled: !isDisabled,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
iconForPaymentType(type),
size: _menuIconSize,
color: isDisabled
? theme.colorScheme.onSurface.withValues(alpha: 0.4)
: theme.colorScheme.onSurface,
),
const SizedBox(width: _menuIconTextSpacing),
Text(getPaymentTypeLabel(context, type)),
],
),
);
})
.toList(),
child: Container(
height: _buttonHeight,
padding: _buttonPadding,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(_borderRadius),
border: Border.all(color: borderColor),
color: theme.colorScheme.onSecondary,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.add, size: _iconSize, color: textColor),
const SizedBox(width: _iconTextSpacing),
Text(
l10n.addPaymentMethod,
style: theme.textTheme.titleSmall?.copyWith(
color: textColor,
fontWeight: _labelWeight,
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,108 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/payment/methods/data.dart';
import 'package:pshared/models/payment/type.dart';
import 'package:pshared/models/recipient/payment_method_draft.dart';
import 'package:pweb/pages/payment_methods/form.dart';
import 'package:pweb/pages/payment_methods/icon.dart';
import 'package:pweb/widgets/dialogs/confirmation_dialog.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class PaymentMethodPanel extends StatelessWidget {
final PaymentType selectedType;
final int selectedIndex;
final List<RecipientMethodDraft> entries;
final ValueChanged<int> onRemove;
final void Function(int, PaymentMethodData) onChanged;
final double padding;
const PaymentMethodPanel({
super.key,
required this.selectedType,
required this.selectedIndex,
required this.entries,
required this.onRemove,
required this.onChanged,
this.padding = 16,
});
Future<void> _confirmDelete(BuildContext context, VoidCallback onConfirmed) async {
final l10n = AppLocalizations.of(context)!;
final confirmed = await showConfirmationDialog(
context: context,
title: l10n.delete,
message: l10n.deletePaymentConfirmation,
confirmLabel: l10n.delete,
);
if (confirmed) {
onConfirmed();
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l10n = AppLocalizations.of(context)!;
final label = l10n.paymentMethodDetails;
final entry = selectedIndex >= 0 && selectedIndex < entries.length
? entries[selectedIndex]
: null;
return AnimatedContainer(
duration: const Duration(milliseconds: 3000),
padding: EdgeInsets.all(padding),
decoration: BoxDecoration(
color: theme.colorScheme.onSecondary,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: theme.colorScheme.onSurface.withAlpha(30)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
iconForPaymentType(selectedType),
size: 18,
color: theme.colorScheme.primary,
),
const SizedBox(width: 8),
Expanded(
child: Text(
label,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
if (entry != null)
TextButton.icon(
onPressed: () => _confirmDelete(context, () => onRemove(selectedIndex)),
icon: Icon(Icons.delete, color: theme.colorScheme.error),
label: Text(
l10n.delete,
style: TextStyle(color: theme.colorScheme.error),
),
),
],
),
const SizedBox(height: 12),
if (entry != null)
PaymentMethodForm(
key: ValueKey('${selectedType.name}-${entry.existing?.id ?? selectedIndex}-form'),
selectedType: selectedType,
initialData: entry.data,
onChanged: (data) {
if (data == null) return;
onChanged(selectedIndex, data);
},
),
],
),
);
}
}

View File

@@ -0,0 +1,81 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/payment/type.dart';
import 'package:pshared/models/recipient/payment_method_draft.dart';
import 'package:pweb/models/payment_method_tile/availability.dart';
import 'package:pweb/models/payment_method_tile/selection.dart';
import 'package:pweb/pages/address_book/form/widgets/payment_methods/add_button.dart';
import 'package:pweb/pages/address_book/form/widgets/payment_methods/tile.dart';
class PaymentMethodSelectorRow extends StatelessWidget {
final List<PaymentType> types;
final PaymentType selectedType;
final int? selectedIndex;
final Map<PaymentType, List<RecipientMethodDraft>> methods;
final void Function(PaymentType type, int index) onSelected;
final ValueChanged<PaymentType> onAdd;
final Set<PaymentType> disabledTypes;
final double spacing;
final double tilePadding;
final double runSpacing;
const PaymentMethodSelectorRow({
super.key,
required this.types,
required this.selectedType,
required this.selectedIndex,
required this.methods,
required this.onSelected,
required this.onAdd,
this.disabledTypes = const {},
this.spacing = 12,
this.tilePadding = 10,
this.runSpacing = 12,
});
@override
Widget build(BuildContext context) {
final tiles = <Widget>[];
for (final type in types) {
final entries = methods[type] ?? const <RecipientMethodDraft>[];
for (var index = 0; index < entries.length; index += 1) {
final entry = entries[index];
final isSelected = type == selectedType && selectedIndex == index;
final selection = isSelected
? PaymentMethodTileSelection.selected
: PaymentMethodTileSelection.idle;
final isAdded = entry.data != null || entry.existing != null;
final availability = isAdded
? PaymentMethodTileAvailability.added
: PaymentMethodTileAvailability.available;
tiles.add(
PaymentMethodTile(
type: type,
selection: selection,
availability: availability,
padding: tilePadding,
onTap: () => onSelected(type, index),
),
);
}
}
tiles.add(
AddPaymentMethodButton(
types: types,
disabledTypes: disabledTypes,
onAdd: onAdd,
),
);
return Wrap(
spacing: spacing,
runSpacing: runSpacing,
alignment: WrapAlignment.start,
children: tiles,
);
}
}

View File

@@ -0,0 +1,113 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/payment/type.dart';
import 'package:pweb/models/payment_method_tile/availability.dart';
import 'package:pweb/models/payment_method_tile/selection.dart';
import 'package:pweb/pages/payment_methods/icon.dart';
import 'package:pweb/utils/payment/label.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class PaymentMethodTile extends StatelessWidget {
final PaymentType type;
final PaymentMethodTileSelection selection;
final PaymentMethodTileAvailability availability;
final double padding;
final VoidCallback? onTap;
const PaymentMethodTile({
required this.type,
required this.selection,
required this.availability,
required this.padding,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l10n = AppLocalizations.of(context)!;
final label = getPaymentTypeLabel(context, type);
final badgeLabel = switch (availability) {
PaymentMethodTileAvailability.added => l10n.paymentMethodAdded,
PaymentMethodTileAvailability.comingSoon => l10n.paymentMethodComingSoon,
PaymentMethodTileAvailability.available => l10n.paymentMethodNotAdded,
};
final isSelected = selection == PaymentMethodTileSelection.selected;
final isDisabled = availability == PaymentMethodTileAvailability.comingSoon;
final disabledOpacity = isDisabled ? 0.55 : 1.0;
final badgeColor = availability == PaymentMethodTileAvailability.added
? theme.colorScheme.primary.withValues(alpha: 0.12)
: theme.colorScheme.onSurface.withValues(alpha: isDisabled ? 0.06 : 0.08);
final badgeTextColor = availability == PaymentMethodTileAvailability.added
? theme.colorScheme.primary
: theme.colorScheme.onSurface.withValues(alpha: isDisabled ? 0.6 : 1.0);
final borderColor = isSelected
? theme.colorScheme.primary
: theme.colorScheme.onSurface.withValues(alpha: 0.12);
final backgroundColor = isSelected
? theme.colorScheme.primary.withValues(alpha: 0.08)
: theme.colorScheme.onSecondary;
return IntrinsicWidth(
child: Opacity(
opacity: disabledOpacity,
child: Material(
color: backgroundColor,
borderRadius: BorderRadius.circular(14),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(14),
child: AnimatedContainer(
duration: const Duration(milliseconds: 160),
padding: EdgeInsets.all(padding),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
border: Border.all(color: borderColor),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
iconForPaymentType(type),
size: 20,
color: isSelected ? theme.colorScheme.primary : theme.colorScheme.onSurface,
),
const SizedBox(width: 8),
Text(
label,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
color: isSelected ? theme.colorScheme.primary : theme.colorScheme.onSurface,
),
),
],
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: badgeColor,
borderRadius: BorderRadius.circular(999),
),
child: Text(
badgeLabel,
style: theme.textTheme.labelSmall?.copyWith(
color: badgeTextColor,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
),
),
),
);
}
}

View File

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

View File

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

View File

@@ -3,14 +3,13 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package: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(),