Files
ingtrakapp/lib/widgets/recurring_template_sheet.dart
2026-09-10 09:23:40 +02:00

552 lines
18 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// 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, {
BankTransaction? initialTx,
}) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
showDragHandle: true,
builder: (context) => _RecurringTemplateSheet(initialTx: initialTx),
);
}
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, this.initialTx});
final RecurringTemplate? existing;
final BankTransaction? initialTx;
@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';
BankTransaction? seed = widget.initialTx;
if (seed == null && existing?.sourceTxId != null) {
final txs = context.read<BankController>().transactions;
for (final tx in txs) {
if (tx.id == existing!.sourceTxId) {
seed = tx;
break;
}
}
}
if (seed != null) {
_pickedTx = seed;
_currency = seed.currency;
_source = RecurringTemplateSource.transaction;
if (_nameController.text.trim().isEmpty) {
_nameController.text = seed.description;
}
if (existing == null) {
_dayController.text =
'${(DateTime.tryParse(seed.date)?.day ?? 1).clamp(1, 28)}';
}
}
}
@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 spending'
: 'Edit recurring spending',
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),
);
},
),
);
}
}