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

@@ -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,
),
),
),
),