feat: add payment card grouping and analysis
This commit is contained in:
290
lib/widgets/payment_card/payment_card_form_sheet.dart
Normal file
290
lib/widgets/payment_card/payment_card_form_sheet.dart
Normal file
@@ -0,0 +1,290 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../models/payment_card_model.dart';
|
||||
import '../../providers/payment_card_provider.dart';
|
||||
import '../../utils/payment_card_utils.dart';
|
||||
|
||||
class PaymentCardFormSheet extends StatefulWidget {
|
||||
final PaymentCardModel? card;
|
||||
final String? initialIssuerName;
|
||||
final String? initialLast4;
|
||||
final String? initialColorHex;
|
||||
final String? initialIconName;
|
||||
|
||||
const PaymentCardFormSheet({
|
||||
super.key,
|
||||
this.card,
|
||||
this.initialIssuerName,
|
||||
this.initialLast4,
|
||||
this.initialColorHex,
|
||||
this.initialIconName,
|
||||
});
|
||||
|
||||
static Future<String?> show(
|
||||
BuildContext context, {
|
||||
PaymentCardModel? card,
|
||||
String? initialIssuerName,
|
||||
String? initialLast4,
|
||||
String? initialColorHex,
|
||||
String? initialIconName,
|
||||
}) async {
|
||||
return showModalBottomSheet<String>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
builder: (_) => PaymentCardFormSheet(
|
||||
card: card,
|
||||
initialIssuerName: initialIssuerName,
|
||||
initialLast4: initialLast4,
|
||||
initialColorHex: initialColorHex,
|
||||
initialIconName: initialIconName,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<PaymentCardFormSheet> createState() => _PaymentCardFormSheetState();
|
||||
}
|
||||
|
||||
class _PaymentCardFormSheetState extends State<PaymentCardFormSheet> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
late TextEditingController _issuerController;
|
||||
late TextEditingController _last4Controller;
|
||||
late String _selectedColor;
|
||||
late String _selectedIcon;
|
||||
late bool _isDefault;
|
||||
bool _isSaving = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_issuerController = TextEditingController(
|
||||
text: widget.card?.issuerName ?? widget.initialIssuerName ?? '',
|
||||
);
|
||||
_last4Controller = TextEditingController(
|
||||
text: widget.card?.last4 ?? widget.initialLast4 ?? '',
|
||||
);
|
||||
_selectedColor = widget.card?.colorHex ??
|
||||
widget.initialColorHex ??
|
||||
PaymentCardUtils.colorPalette.first;
|
||||
_selectedIcon = widget.card?.iconName ??
|
||||
widget.initialIconName ??
|
||||
PaymentCardUtils.iconMap.keys.first;
|
||||
_isDefault = widget.card?.isDefault ?? false;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_issuerController.dispose();
|
||||
_last4Controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final loc = AppLocalizations.of(context);
|
||||
final isEditing = widget.card != null;
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
isEditing ? loc.editPaymentCard : loc.addPaymentCard,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: const Icon(Icons.close),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _issuerController,
|
||||
decoration: InputDecoration(
|
||||
labelText: loc.paymentCardIssuer,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
textInputAction: TextInputAction.next,
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return loc.requiredFieldsError;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _last4Controller,
|
||||
decoration: InputDecoration(
|
||||
labelText: loc.paymentCardLast4,
|
||||
border: const OutlineInputBorder(),
|
||||
counterText: '',
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
LengthLimitingTextInputFormatter(4),
|
||||
],
|
||||
validator: (value) {
|
||||
if (value == null || value.length != 4) {
|
||||
return loc.paymentCardLast4;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
loc.paymentCardColor,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: PaymentCardUtils.colorPalette.map((hex) {
|
||||
final color = PaymentCardUtils.colorFromHex(hex);
|
||||
final selected = _selectedColor == hex;
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_selectedColor = hex;
|
||||
});
|
||||
},
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: selected
|
||||
? Theme.of(context).colorScheme.onSurface
|
||||
: Colors.transparent,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
loc.paymentCardIcon,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: PaymentCardUtils.iconMap.entries.map((entry) {
|
||||
final selected = _selectedIcon == entry.key;
|
||||
return ChoiceChip(
|
||||
label: Icon(entry.value,
|
||||
color: selected
|
||||
? Theme.of(context).colorScheme.onPrimary
|
||||
: Theme.of(context).colorScheme.onSurface),
|
||||
selected: selected,
|
||||
onSelected: (_) {
|
||||
setState(() {
|
||||
_selectedIcon = entry.key;
|
||||
});
|
||||
},
|
||||
selectedColor: Theme.of(context).colorScheme.primary,
|
||||
backgroundColor: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerHighest,
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(loc.setAsDefaultCard),
|
||||
value: _isDefault,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_isDefault = value;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: _isSaving ? null : _handleSubmit,
|
||||
child: _isSaving
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Text(loc.save),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleSubmit() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
setState(() {
|
||||
_isSaving = true;
|
||||
});
|
||||
try {
|
||||
final provider = context.read<PaymentCardProvider>();
|
||||
String cardId;
|
||||
if (widget.card == null) {
|
||||
final card = await provider.addCard(
|
||||
issuerName: _issuerController.text.trim(),
|
||||
last4: _last4Controller.text.trim(),
|
||||
colorHex: _selectedColor,
|
||||
iconName: _selectedIcon,
|
||||
isDefault: _isDefault,
|
||||
);
|
||||
cardId = card.id;
|
||||
} else {
|
||||
widget.card!
|
||||
..issuerName = _issuerController.text.trim()
|
||||
..last4 = _last4Controller.text.trim()
|
||||
..colorHex = _selectedColor
|
||||
..iconName = _selectedIcon
|
||||
..isDefault = _isDefault;
|
||||
await provider.updateCard(widget.card!);
|
||||
cardId = widget.card!.id;
|
||||
}
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop(cardId);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isSaving = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
142
lib/widgets/payment_card/payment_card_selector.dart
Normal file
142
lib/widgets/payment_card/payment_card_selector.dart
Normal file
@@ -0,0 +1,142 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../models/payment_card_model.dart';
|
||||
import '../../providers/payment_card_provider.dart';
|
||||
import '../../utils/payment_card_utils.dart';
|
||||
|
||||
class PaymentCardSelector extends StatelessWidget {
|
||||
final String? selectedCardId;
|
||||
final ValueChanged<String?> onChanged;
|
||||
final Future<void> Function()? onAddCard;
|
||||
final VoidCallback? onManageCards;
|
||||
|
||||
const PaymentCardSelector({
|
||||
super.key,
|
||||
required this.selectedCardId,
|
||||
required this.onChanged,
|
||||
this.onAddCard,
|
||||
this.onManageCards,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<PaymentCardProvider>(
|
||||
builder: (context, provider, child) {
|
||||
final loc = AppLocalizations.of(context);
|
||||
final cards = provider.cards;
|
||||
final unassignedSelected = selectedCardId == null;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
Semantics(
|
||||
label: loc.paymentCardUnassigned,
|
||||
selected: unassignedSelected,
|
||||
button: true,
|
||||
child: ChoiceChip(
|
||||
label: Text(loc.paymentCardUnassigned),
|
||||
selected: unassignedSelected,
|
||||
onSelected: (_) => onChanged(null),
|
||||
avatar: const Icon(Icons.credit_card_off_rounded, size: 18),
|
||||
),
|
||||
),
|
||||
...cards.map((card) => _PaymentCardChip(
|
||||
card: card,
|
||||
isSelected: selectedCardId == card.id,
|
||||
onSelected: () => onChanged(card.id),
|
||||
)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
TextButton.icon(
|
||||
onPressed: cards.isEmpty && onAddCard == null
|
||||
? null
|
||||
: () async {
|
||||
if (onAddCard != null) {
|
||||
await onAddCard!();
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.add),
|
||||
label: Text(loc.addNewCard),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
TextButton(
|
||||
onPressed: onManageCards,
|
||||
child: Text(loc.managePaymentCards),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (cards.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(
|
||||
loc.noPaymentCards,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PaymentCardChip extends StatelessWidget {
|
||||
final PaymentCardModel card;
|
||||
final bool isSelected;
|
||||
final VoidCallback onSelected;
|
||||
|
||||
const _PaymentCardChip({
|
||||
required this.card,
|
||||
required this.isSelected,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = PaymentCardUtils.colorFromHex(card.colorHex);
|
||||
final icon = PaymentCardUtils.iconForName(card.iconName);
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final labelText = '${card.issuerName} · ****${card.last4}';
|
||||
return Semantics(
|
||||
label: labelText,
|
||||
selected: isSelected,
|
||||
button: true,
|
||||
child: ChoiceChip(
|
||||
avatar: CircleAvatar(
|
||||
backgroundColor:
|
||||
isSelected ? cs.onPrimary : color.withValues(alpha: 0.15),
|
||||
child: Icon(
|
||||
icon,
|
||||
color: isSelected ? color : cs.onSurface,
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
label: Text(labelText),
|
||||
selected: isSelected,
|
||||
onSelected: (_) => onSelected(),
|
||||
selectedColor: color,
|
||||
labelStyle: TextStyle(
|
||||
color: isSelected ? cs.onPrimary : cs.onSurface,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
backgroundColor: cs.surface,
|
||||
side: BorderSide(
|
||||
color: isSelected
|
||||
? Colors.transparent
|
||||
: cs.outline.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user