533 lines
17 KiB
Dart
533 lines
17 KiB
Dart
/// 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),
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
}
|