look ahead

This commit is contained in:
davmar
2026-09-10 00:35:54 +02:00
parent 91d6dc9e54
commit 5b22fc8694
20 changed files with 2942 additions and 799 deletions
+532
View File
@@ -0,0 +1,532 @@
/// 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<void> showCreateRecurringTemplateSheet(BuildContext context) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
showDragHandle: true,
builder: (context) => const _RecurringTemplateSheet(),
);
}
Future<void> showEditRecurringTemplateSheet(
BuildContext context, {
required RecurringTemplate template,
}) {
return showModalBottomSheet<void>(
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<BankController>().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<void> _pickTransaction() async {
final bank = context.read<BankController>();
final picked = await showModalBottomSheet<BankTransaction>(
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<void> _pickLabel() async {
final meta = context.read<TxMetaController>();
final labels = [...meta.allLabels]
..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
final picked = await showModalBottomSheet<TxLabel>(
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<TxMetaController>();
Navigator.pop(context);
meta.upsertRecurringTemplate(tpl);
}
void _delete() {
final existing = widget.existing;
if (existing == null) return;
final meta = context.read<TxMetaController>();
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<TxMetaController>();
final bank = context.read<BankController>();
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<RecurringTemplateSource>(
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<BankTransaction> 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<TxLabel> 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),
);
},
),
);
}
}