/// Create / edit look-ahead recurring templates. library; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../models/bank.dart'; import '../models/transaction_meta.dart'; import '../providers/bank_provider.dart'; import '../providers/tx_meta_provider.dart'; import '../utils/helpers.dart'; import '../utils/look_ahead.dart'; Future showCreateRecurringTemplateSheet(BuildContext context) { return showModalBottomSheet( context: context, isScrollControlled: true, useSafeArea: true, showDragHandle: true, builder: (context) => const _RecurringTemplateSheet(), ); } Future showEditRecurringTemplateSheet( BuildContext context, { required RecurringTemplate template, }) { return showModalBottomSheet( context: context, isScrollControlled: true, useSafeArea: true, showDragHandle: true, builder: (context) => _RecurringTemplateSheet(existing: template), ); } class _RecurringTemplateSheet extends StatefulWidget { const _RecurringTemplateSheet({this.existing}); final RecurringTemplate? existing; @override State<_RecurringTemplateSheet> createState() => _RecurringTemplateSheetState(); } class _RecurringTemplateSheetState extends State<_RecurringTemplateSheet> { late RecurringTemplateSource _source; late RecurringAmountRule _rule; late final TextEditingController _nameController; late final TextEditingController _dayController; late final TextEditingController _avgController; BankTransaction? _pickedTx; String? _labelId; String _currency = 'EUR'; @override void initState() { super.initState(); final existing = widget.existing; _source = existing?.source ?? RecurringTemplateSource.transaction; _rule = existing?.amountRule ?? RecurringAmountRule.lastValue; _nameController = TextEditingController(text: existing?.name ?? ''); _dayController = TextEditingController( text: '${existing?.dayOfMonth ?? 1}', ); _avgController = TextEditingController( text: '${existing?.avgCount ?? 5}', ); _labelId = existing?.labelId; _currency = existing?.currency ?? 'EUR'; if (existing?.sourceTxId != null) { final txs = context.read().transactions; for (final tx in txs) { if (tx.id == existing!.sourceTxId) { _pickedTx = tx; break; } } } } @override void dispose() { _nameController.dispose(); _dayController.dispose(); _avgController.dispose(); super.dispose(); } int get _day { final v = int.tryParse(_dayController.text.trim()) ?? 1; return v.clamp(1, 28); } int get _avg { final v = int.tryParse(_avgController.text.trim()) ?? 5; return v.clamp(1, 36); } Future _pickTransaction() async { final bank = context.read(); final picked = await showModalBottomSheet( context: context, isScrollControlled: true, useSafeArea: true, showDragHandle: true, builder: (context) => _TransactionPickerSheet( transactions: bank.transactions, ), ); if (picked == null || !mounted) return; setState(() { _pickedTx = picked; _currency = picked.currency; _source = RecurringTemplateSource.transaction; if (_nameController.text.trim().isEmpty || widget.existing == null) { _nameController.text = picked.description; } _dayController.text = '${(DateTime.tryParse(picked.date)?.day ?? 1).clamp(1, 28)}'; if (_rule == RecurringAmountRule.labelAvgMonths) { _rule = RecurringAmountRule.lastValue; } }); } Future _pickLabel() async { final meta = context.read(); final labels = [...meta.allLabels] ..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); final picked = await showModalBottomSheet( context: context, isScrollControlled: true, useSafeArea: true, showDragHandle: true, builder: (context) => _LabelPickerSheet(labels: labels), ); if (picked == null || !mounted) return; setState(() { _labelId = picked.id; _source = RecurringTemplateSource.label; _rule = RecurringAmountRule.labelAvgMonths; if (_nameController.text.trim().isEmpty || widget.existing == null) { _nameController.text = picked.name; } if (_dayController.text.trim().isEmpty) { _dayController.text = '1'; } }); } void _save() { final name = _nameController.text.trim(); if (name.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Enter a name')), ); return; } if (_source == RecurringTemplateSource.transaction && _pickedTx == null) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Pick a transaction')), ); return; } if ((_source == RecurringTemplateSource.label || _rule == RecurringAmountRule.labelAvgMonths) && (_labelId == null || _labelId!.isEmpty)) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Pick a label')), ); return; } final existing = widget.existing; final id = existing?.id.isNotEmpty == true ? existing!.id : 'rt_${DateTime.now().microsecondsSinceEpoch.toRadixString(16)}'; final tpl = RecurringTemplate( id: id, name: name, dayOfMonth: _day, currency: _currency, source: _source, amountRule: _source == RecurringTemplateSource.label ? RecurringAmountRule.labelAvgMonths : _rule, merchantKey: _pickedTx?.merchantKey ?? existing?.merchantKey ?? '', sourceTxId: _source == RecurringTemplateSource.transaction ? _pickedTx?.id : null, labelId: (_source == RecurringTemplateSource.label || _rule == RecurringAmountRule.labelAvgMonths) ? _labelId : null, avgCount: _avg, autoFromRecurring: existing?.autoFromRecurring ?? false, ); // Pop first so the sheet does not rebuild against the notify. final meta = context.read(); Navigator.pop(context); meta.upsertRecurringTemplate(tpl); } void _delete() { final existing = widget.existing; if (existing == null) return; final meta = context.read(); Navigator.pop(context); meta.removeRecurringTemplate(existing.id); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final colors = theme.colorScheme; final meta = context.read(); final bank = context.read(); final media = MediaQuery.of(context); final label = meta.labelById(_labelId); final preview = resolveRecurringAmount( template: RecurringTemplate( id: 'preview', name: _nameController.text, dayOfMonth: _day, currency: _currency, source: _source, amountRule: _source == RecurringTemplateSource.label ? RecurringAmountRule.labelAvgMonths : _rule, merchantKey: _pickedTx?.merchantKey ?? '', sourceTxId: _pickedTx?.id, labelId: _labelId, avgCount: _avg, ), transactions: bank.transactions, isInternalTransfer: (tx) => meta.metaFor(tx).internalTransfer, labelIdsFor: meta.labelIdsFor, ); return Padding( padding: EdgeInsets.only(bottom: media.viewInsets.bottom), child: SingleChildScrollView( padding: const EdgeInsets.fromLTRB(24, 4, 24, 24), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( widget.existing == null ? 'New recurring template' : 'Edit template', style: theme.textTheme.titleLarge, ), const SizedBox(height: 16), TextField( controller: _nameController, decoration: const InputDecoration( labelText: 'Name', isDense: true, ), ), const SizedBox(height: 12), TextField( controller: _dayController, keyboardType: TextInputType.number, inputFormatters: [FilteringTextInputFormatter.digitsOnly], decoration: const InputDecoration( labelText: 'Day of month (1–28)', isDense: true, ), ), const SizedBox(height: 16), Text('Based on', style: theme.textTheme.titleSmall), const SizedBox(height: 8), SegmentedButton( segments: const [ ButtonSegment( value: RecurringTemplateSource.transaction, label: Text('Transaction'), icon: Icon(Icons.receipt_long, size: 18), ), ButtonSegment( value: RecurringTemplateSource.label, label: Text('Label'), icon: Icon(Icons.label_outline, size: 18), ), ], selected: {_source}, onSelectionChanged: (next) { setState(() { _source = next.first; if (_source == RecurringTemplateSource.label) { _rule = RecurringAmountRule.labelAvgMonths; } else if (_rule == RecurringAmountRule.labelAvgMonths) { _rule = RecurringAmountRule.lastValue; } }); }, ), const SizedBox(height: 12), if (_source == RecurringTemplateSource.transaction) ...[ OutlinedButton.icon( onPressed: _pickTransaction, icon: const Icon(Icons.search), label: Text( _pickedTx == null ? 'Pick transaction' : _pickedTx!.description, maxLines: 1, overflow: TextOverflow.ellipsis, ), ), const SizedBox(height: 12), Text('Amount', style: theme.textTheme.titleSmall), const SizedBox(height: 8), Wrap( spacing: 8, runSpacing: 8, children: [ ChoiceChip( label: const Text('Last value'), selected: _rule == RecurringAmountRule.lastValue, onSelected: (_) => setState( () => _rule = RecurringAmountRule.lastValue, ), ), ChoiceChip( label: const Text('Avg last X matching'), selected: _rule == RecurringAmountRule.avgLastTxs, onSelected: (_) => setState( () => _rule = RecurringAmountRule.avgLastTxs, ), ), ChoiceChip( label: const Text('Label avg'), selected: _rule == RecurringAmountRule.labelAvgMonths, onSelected: (_) => setState( () => _rule = RecurringAmountRule.labelAvgMonths, ), ), ], ), if (_rule == RecurringAmountRule.avgLastTxs) ...[ const SizedBox(height: 12), TextField( controller: _avgController, keyboardType: TextInputType.number, inputFormatters: [FilteringTextInputFormatter.digitsOnly], decoration: const InputDecoration( labelText: 'Number of matching transactions', helperText: 'Matches use the same merchant pattern and income/expense as the picked tx', isDense: true, ), onChanged: (_) => setState(() {}), ), ], if (_rule == RecurringAmountRule.labelAvgMonths) ...[ const SizedBox(height: 12), OutlinedButton.icon( onPressed: _pickLabel, icon: Icon( label?.icon ?? Icons.label_outline, color: label?.color, ), label: Text(label?.name ?? 'Pick label'), ), const SizedBox(height: 12), TextField( controller: _avgController, keyboardType: TextInputType.number, inputFormatters: [FilteringTextInputFormatter.digitsOnly], decoration: const InputDecoration( labelText: 'Previous months', isDense: true, ), onChanged: (_) => setState(() {}), ), ], ] else ...[ OutlinedButton.icon( onPressed: _pickLabel, icon: Icon( label?.icon ?? Icons.label_outline, color: label?.color, ), label: Text(label?.name ?? 'Pick label'), ), const SizedBox(height: 12), TextField( controller: _avgController, keyboardType: TextInputType.number, inputFormatters: [FilteringTextInputFormatter.digitsOnly], decoration: const InputDecoration( labelText: 'Previous months to average', isDense: true, ), onChanged: (_) => setState(() {}), ), ], const SizedBox(height: 16), Text( 'Preview: ${formatMoney(preview, _currency)}', style: theme.textTheme.bodyMedium?.copyWith( color: colors.onSurfaceVariant, ), ), const SizedBox(height: 20), FilledButton( onPressed: _save, child: Text(widget.existing == null ? 'Create' : 'Save'), ), if (widget.existing != null) TextButton( onPressed: _delete, child: Text( 'Delete', style: TextStyle(color: colors.error), ), ), TextButton( onPressed: () => Navigator.pop(context), child: const Text('Cancel'), ), ], ), ), ); } } class _TransactionPickerSheet extends StatefulWidget { const _TransactionPickerSheet({required this.transactions}); final List transactions; @override State<_TransactionPickerSheet> createState() => _TransactionPickerSheetState(); } class _TransactionPickerSheetState extends State<_TransactionPickerSheet> { final _search = TextEditingController(); @override void dispose() { _search.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final needle = _search.text.trim().toLowerCase(); final txs = [ for (final tx in widget.transactions) if (needle.isEmpty || tx.description.toLowerCase().contains(needle) || tx.merchantKey.contains(needle)) tx, ].take(80).toList(growable: false); return SizedBox( height: MediaQuery.of(context).size.height * 0.75, child: Column( children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), child: TextField( controller: _search, decoration: const InputDecoration( hintText: 'Search transactions', prefixIcon: Icon(Icons.search), isDense: true, ), onChanged: (_) => setState(() {}), ), ), Expanded( child: ListView.separated( itemCount: txs.length, separatorBuilder: (_, _) => const Divider(height: 1), itemBuilder: (context, index) { final tx = txs[index]; return ListTile( title: Text( tx.description, maxLines: 1, overflow: TextOverflow.ellipsis, ), subtitle: Text( '${formatIsoDate(tx.date)} · ${formatMoney(tx.amount, tx.currency)}', ), onTap: () => Navigator.pop(context, tx), ); }, ), ), ], ), ); } } class _LabelPickerSheet extends StatelessWidget { const _LabelPickerSheet({required this.labels}); final List labels; @override Widget build(BuildContext context) { return SizedBox( height: MediaQuery.of(context).size.height * 0.6, child: ListView.separated( padding: const EdgeInsets.only(bottom: 16), itemCount: labels.length, separatorBuilder: (_, _) => const Divider(height: 1, indent: 56), itemBuilder: (context, index) { final label = labels[index]; return ListTile( leading: Icon(label.icon, color: label.color), title: Text(label.name), onTap: () => Navigator.pop(context, label), ); }, ), ); } }