redesigned payment page + a lot of fixes

This commit is contained in:
Arseni
2026-02-21 21:55:20 +03:00
parent a68aa2abff
commit 0c6fa03aba
208 changed files with 4062 additions and 2217 deletions

View File

@@ -2,15 +2,14 @@ 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/controllers/organization/address_book_recipient_form.dart';
import 'package:pweb/controllers/organization/address_book_recipient_form_selection.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 {
class AddressBookRecipientFormBody extends StatelessWidget {
final GlobalKey<FormState> formKey;
final TextEditingController nameCtrl;
final TextEditingController emailCtrl;
@@ -27,94 +26,43 @@ class AddressBookRecipientFormBody extends StatefulWidget {
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 formState = Provider.of<AddressBookRecipientFormProvider>(context, listen: false);
final controller = Provider.of<AddressBookRecipientFormController>(context);
final selection =
Provider.of<AddressBookRecipientFormSelectionController>(context);
final selectedType = _selectedType ?? formState.supportedTypes.first;
if (controller.supportedTypes.isEmpty) {
return const SizedBox.shrink();
}
final selectedType = selection.selectedType ?? controller.supportedTypes.first;
return FormView(
formKey: widget.formKey,
nameCtrl: widget.nameCtrl,
emailCtrl: widget.emailCtrl,
types: formState.supportedTypes,
formKey: formKey,
nameCtrl: nameCtrl,
emailCtrl: emailCtrl,
types: controller.supportedTypes,
selectedType: selectedType,
selectedIndex: _selectedIndex,
methods: formState.methods,
onMethodSelected: _onMethodSelected,
onMethodAdd: (type) => _onMethodAdd(formState, type),
selectedIndex: selection.selectedIndex,
methods: controller.methods,
onMethodSelected: selection.select,
onMethodAdd: (type) {
final newIndex = controller.addMethod(type);
selection.selectAfterAdd(type, newIndex);
},
disabledTypes: disabledPaymentTypes,
onMethodRemove: (index) => _onMethodRemove(formState, index),
onMethodChanged: (index, data) => _onMethodChanged(formState, index, data),
onSave: () => widget.onSave(formState),
isEditing: widget.isEditing,
onBack: widget.onBack,
onMethodRemove: (index) {
final type = selection.selectedType ?? controller.supportedTypes.first;
controller.removeMethod(type, index);
},
onMethodChanged: (index, data) {
final type = selection.selectedType ?? controller.supportedTypes.first;
controller.updateMethod(type, index, data);
},
onSave: () => onSave(formState),
isEditing: isEditing,
onBack: onBack,
);
}
}

View File

@@ -7,12 +7,11 @@ 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/controllers/organization/address_book_recipient_form.dart';
import 'package:pweb/controllers/organization/address_book_recipient_form_selection.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/generated/i18n/app_localizations.dart';
class AddressBookRecipientForm extends StatefulWidget {
@@ -29,6 +28,8 @@ class _AddressBookRecipientFormState extends State<AddressBookRecipientForm> {
final _formKey = GlobalKey<FormState>();
late TextEditingController _nameCtrl;
late TextEditingController _emailCtrl;
late final String _initialName;
late final String _initialEmail;
static const List<PaymentType> _supportedTypes = visiblePaymentTypes;
@@ -36,61 +37,79 @@ class _AddressBookRecipientFormState extends State<AddressBookRecipientForm> {
void initState() {
super.initState();
final r = widget.recipient;
_nameCtrl = TextEditingController(text: r?.name ?? '');
_emailCtrl = TextEditingController(text: r?.email ?? '');
}
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,
methodNames: _methodNames(context),
);
widget.onSaved?.call(saved);
} catch (_) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.notificationError(l10n.noErrorInformation))),
);
}
_initialName = r?.name ?? '';
_initialEmail = r?.email ?? '';
_nameCtrl = TextEditingController(text: _initialName);
_emailCtrl = TextEditingController(text: _initialEmail);
}
@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,
return MultiProvider(
providers: [
ChangeNotifierProxyProvider2<
RecipientsProvider,
RecipientMethodsCacheProvider,
AddressBookRecipientFormProvider
>(
create: (_) => AddressBookRecipientFormProvider(
recipient: widget.recipient,
),
child: AddressBookRecipientFormBody(
formKey: _formKey,
nameCtrl: _nameCtrl,
emailCtrl: _emailCtrl,
isEditing: widget.recipient != null,
onSave: _save,
onBack: () => widget.onSaved?.call(null),
update: (_, recipientsProvider, methodsCache, formProvider) =>
formProvider!..updateProviders(
recipientsProvider: recipientsProvider,
methodsCache: methodsCache,
),
),
ChangeNotifierProxyProvider<
RecipientMethodsCacheProvider,
AddressBookRecipientFormController
>(
create: (_) => AddressBookRecipientFormController(
supportedTypes: _supportedTypes,
),
update: (_, methodsCache, controller) => controller!
..update(
recipient: widget.recipient,
methodsCache: methodsCache,
),
),
ChangeNotifierProxyProvider<
AddressBookRecipientFormController,
AddressBookRecipientFormSelectionController
>(
create: (_) => AddressBookRecipientFormSelectionController(),
update: (_, formController, selectionController) =>
selectionController!..update(formController),
),
],
child: Builder(
builder: (context) {
final formState = context.read<AddressBookRecipientFormProvider>();
final controller = context.read<AddressBookRecipientFormController>();
return AddressBookRecipientFormBody(
formKey: _formKey,
nameCtrl: _nameCtrl,
emailCtrl: _emailCtrl,
isEditing: widget.recipient != null,
onSave: (form) => controller.saveForm(
context: context,
formKey: _formKey,
formState: form,
name: _nameCtrl.text,
email: _emailCtrl.text,
onSaved: widget.onSaved,
),
onBack: () => controller.handleBack(
context: context,
formKey: _formKey,
formState: formState,
name: _nameCtrl.text,
email: _emailCtrl.text,
onSaved: widget.onSaved,
),
);
},
),
);
}

View File

@@ -12,7 +12,8 @@ import 'package:pweb/generated/i18n/app_localizations.dart';
class AddPaymentMethodButton extends StatelessWidget {
final List<PaymentType> types;
final Set<PaymentType> disabledTypes;
final ValueChanged<PaymentType> onAdd;
final ValueChanged<PaymentType>? onAdd;
final VoidCallback? onPressed;
static const double _borderRadius = 14;
static const double _iconSize = 18;
@@ -20,13 +21,18 @@ class AddPaymentMethodButton extends StatelessWidget {
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 EdgeInsets _buttonPadding = EdgeInsets.symmetric(
horizontal: 14,
vertical: 10,
);
static const FontWeight _labelWeight = FontWeight.w600;
const AddPaymentMethodButton({
super.key,
required this.types,
required this.disabledTypes,
required this.onAdd,
this.onAdd,
this.onPressed,
});
@override
@@ -41,6 +47,38 @@ class AddPaymentMethodButton extends StatelessWidget {
? theme.colorScheme.primary
: theme.colorScheme.onSurface.withValues(alpha: 0.4);
final buttonChild = 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,
),
),
],
),
);
final onPressed = this.onPressed;
if (onPressed != null) {
return GestureDetector(
onTap: hasEnabled ? onPressed : null,
child: buttonChild,
);
}
return PopupMenuButton<PaymentType>(
enabled: hasEnabled,
onSelected: onAdd,
@@ -67,29 +105,7 @@ class AddPaymentMethodButton extends StatelessWidget {
);
})
.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,
),
),
],
),
),
child: buttonChild,
);
}
}

View File

@@ -7,6 +7,8 @@ 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/models/state/control_state.dart';
import 'package:pweb/models/state/visibility.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
@@ -17,6 +19,8 @@ class PaymentMethodPanel extends StatelessWidget {
final List<RecipientMethodDraft> entries;
final ValueChanged<int> onRemove;
final void Function(int, PaymentMethodData) onChanged;
final ControlState editState;
final VisibilityState deleteVisibility;
final double padding;
@@ -27,6 +31,8 @@ class PaymentMethodPanel extends StatelessWidget {
required this.entries,
required this.onRemove,
required this.onChanged,
this.editState = ControlState.enabled,
this.deleteVisibility = VisibilityState.visible,
this.padding = 16,
});
@@ -79,7 +85,7 @@ class PaymentMethodPanel extends StatelessWidget {
),
),
),
if (entry != null)
if (entry != null && deleteVisibility == VisibilityState.visible)
TextButton.icon(
onPressed: () => _confirmDelete(context, () => onRemove(selectedIndex)),
icon: Icon(Icons.delete, color: theme.colorScheme.error),
@@ -96,6 +102,7 @@ class PaymentMethodPanel extends StatelessWidget {
key: ValueKey('${selectedType.name}-${entry.existing?.id ?? selectedIndex}-form'),
selectedType: selectedType,
initialData: entry.data,
isEditable: editState == ControlState.enabled,
onChanged: (data) {
if (data == null) return;
onChanged(selectedIndex, data);

View File

@@ -3,8 +3,8 @@ 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/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';
@@ -15,8 +15,10 @@ class PaymentMethodSelectorRow extends StatelessWidget {
final int? selectedIndex;
final Map<PaymentType, List<RecipientMethodDraft>> methods;
final void Function(PaymentType type, int index) onSelected;
final ValueChanged<PaymentType> onAdd;
final ValueChanged<PaymentType>? onAdd;
final VoidCallback? onAddPressed;
final Set<PaymentType> disabledTypes;
final String? Function(RecipientMethodDraft entry)? detailsBuilder;
final double spacing;
final double tilePadding;
@@ -29,8 +31,10 @@ class PaymentMethodSelectorRow extends StatelessWidget {
required this.selectedIndex,
required this.methods,
required this.onSelected,
required this.onAdd,
this.onAdd,
this.onAddPressed,
this.disabledTypes = const {},
this.detailsBuilder,
this.spacing = 12,
this.tilePadding = 10,
this.runSpacing = 12,
@@ -51,12 +55,14 @@ class PaymentMethodSelectorRow extends StatelessWidget {
final availability = isAdded
? PaymentMethodTileAvailability.added
: PaymentMethodTileAvailability.available;
final detailsText = detailsBuilder?.call(entry);
tiles.add(
PaymentMethodTile(
type: type,
selection: selection,
availability: availability,
padding: tilePadding,
detailsText: detailsText,
onTap: () => onSelected(type, index),
),
);
@@ -68,6 +74,7 @@ class PaymentMethodSelectorRow extends StatelessWidget {
types: types,
disabledTypes: disabledTypes,
onAdd: onAdd,
onPressed: onAddPressed,
),
);
@@ -78,4 +85,4 @@ class PaymentMethodSelectorRow extends StatelessWidget {
children: tiles,
);
}
}
}

View File

@@ -2,8 +2,8 @@ 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/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';
@@ -15,6 +15,7 @@ class PaymentMethodTile extends StatelessWidget {
final PaymentMethodTileSelection selection;
final PaymentMethodTileAvailability availability;
final double padding;
final String? detailsText;
final VoidCallback? onTap;
const PaymentMethodTile({
@@ -22,6 +23,7 @@ class PaymentMethodTile extends StatelessWidget {
required this.selection,
required this.availability,
required this.padding,
this.detailsText,
required this.onTap,
});
@@ -50,6 +52,13 @@ class PaymentMethodTile extends StatelessWidget {
final backgroundColor = isSelected
? theme.colorScheme.primary.withValues(alpha: 0.08)
: theme.colorScheme.onSecondary;
final showDetails =
availability == PaymentMethodTileAvailability.added &&
detailsText != null &&
detailsText!.isNotEmpty;
final detailsColor = isSelected
? theme.colorScheme.primary
: theme.colorScheme.onSurfaceVariant;
return IntrinsicWidth(
child: Opacity(
@@ -68,9 +77,9 @@ class PaymentMethodTile extends StatelessWidget {
border: Border.all(color: borderColor),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
iconForPaymentType(type),
@@ -78,30 +87,44 @@ class PaymentMethodTile extends StatelessWidget {
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,
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
color: isSelected ? theme.colorScheme.primary : theme.colorScheme.onSurface,
),
),
const SizedBox(height: 8),
if (showDetails)
Text(
detailsText!,
style: theme.textTheme.labelSmall?.copyWith(
color: detailsColor,
fontWeight: FontWeight.w600,
),
)
else
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,
),
),
),
],
),
],
),
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,
),
),
),
],
),
),