look ahead
This commit is contained in:
@@ -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),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user