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
+238 -203
View File
@@ -19,6 +19,7 @@ class TransactionFilterSelection {
this.amountRange = const TxAmountRange(),
this.query = '',
this.monthOnly = false,
this.recurringOnly = false,
});
final Set<String> labelIds;
@@ -29,10 +30,14 @@ class TransactionFilterSelection {
/// When true, list is limited to the selected calendar month.
final bool monthOnly;
/// When true, only transactions marked recurring are shown.
final bool recurringOnly;
bool get hasActiveExtras =>
flow != TxFlowFilter.all ||
amountRange.isActive ||
query.trim().isNotEmpty;
query.trim().isNotEmpty ||
recurringOnly;
}
/// How label series are drawn on the overview chart.
@@ -78,23 +83,35 @@ Future<LabelFilterSelection?> showLabelFilterDialog(
bool includeInvertChart = false,
bool initialInvertChart = false,
}) {
return showDialog<LabelFilterSelection>(
return showModalBottomSheet<LabelFilterSelection>(
context: context,
isScrollControlled: true,
useSafeArea: true,
showDragHandle: true,
builder: (dialogContext) {
return _SimpleLabelFilterDialog(
available: available,
initiallySelected: initiallySelected,
title: title,
subtitle: subtitle,
includeUncategorized: includeUncategorized,
allowMove: allowMove,
allowCreate: allowCreate,
includeFlow: includeFlow,
initialFlow: initialFlow,
includeBarStyle: includeBarStyle,
initialBarStyle: initialBarStyle,
includeInvertChart: includeInvertChart,
initialInvertChart: initialInvertChart,
final media = MediaQuery.of(dialogContext);
final maxHeight =
(media.size.height - media.viewInsets.bottom) * 0.92;
return Padding(
padding: EdgeInsets.only(bottom: media.viewInsets.bottom),
child: SizedBox(
height: maxHeight.clamp(320.0, media.size.height),
child: _SimpleLabelFilterDialog(
available: available,
initiallySelected: initiallySelected,
title: title,
subtitle: subtitle,
includeUncategorized: includeUncategorized,
allowMove: allowMove,
allowCreate: allowCreate,
includeFlow: includeFlow,
initialFlow: initialFlow,
includeBarStyle: includeBarStyle,
initialBarStyle: initialBarStyle,
includeInvertChart: includeInvertChart,
initialInvertChart: initialInvertChart,
),
),
);
},
);
@@ -241,10 +258,6 @@ class _SimpleLabelFilterDialogState extends State<_SimpleLabelFilterDialog> {
final showUncategorized = widget.includeUncategorized &&
(needle.isEmpty ||
kUncategorizedFilterLabel.name.toLowerCase().contains(needle));
final media = MediaQuery.of(context);
final maxContentHeight =
((media.size.height - media.viewInsets.bottom) * 0.72)
.clamp(320.0, 720.0);
final visibleIds = <String>[
for (final e in entries) e.label.id,
@@ -255,204 +268,205 @@ class _SimpleLabelFilterDialogState extends State<_SimpleLabelFilterDialog> {
visibleIds.isNotEmpty && selectedVisible == visibleIds.length;
final noneVisibleSelected = selectedVisible == 0;
return AlertDialog(
title: Text(widget.title),
content: SizedBox(
width: double.maxFinite,
height: maxContentHeight,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ConstrainedBox(
constraints: BoxConstraints(maxHeight: maxContentHeight * 0.42),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
widget.subtitle,
style: theme.textTheme.bodyMedium?.copyWith(
color: colors.onSurfaceVariant,
),
),
if (widget.includeFlow) ...[
const SizedBox(height: 12),
Text(
'Income / expense',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
_RectToggleBar<TxFlowFilter>(
value: _flow,
onChanged: (value) => setState(() => _flow = value),
options: const [
_RectToggleOption(
value: TxFlowFilter.all,
label: 'All',
),
_RectToggleOption(
value: TxFlowFilter.income,
label: 'In',
icon: Icons.south_west,
),
_RectToggleOption(
value: TxFlowFilter.expense,
label: 'Out',
icon: Icons.north_east,
),
],
),
if (widget.includeInvertChart &&
_flow == TxFlowFilter.all) ...[
const SizedBox(height: 8),
SwitchListTile(
contentPadding: EdgeInsets.zero,
dense: true,
title: const Text('Invert chart'),
subtitle: const Text(
'Expense view: spending up, income/refunds down',
),
value: _invertChart,
onChanged: (value) =>
setState(() => _invertChart = value),
),
],
],
if (widget.includeBarStyle) ...[
const SizedBox(height: 12),
Text(
'Bar layout',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
_RectToggleBar<LabelChartBarStyle>(
value: _barStyle,
onChanged: (value) =>
setState(() => _barStyle = value),
options: const [
_RectToggleOption(
value: LabelChartBarStyle.parallel,
label: 'Parallel',
icon: Icons.view_week_outlined,
),
_RectToggleOption(
value: LabelChartBarStyle.stacked,
label: 'Stacked',
icon: Icons.layers_outlined,
),
],
),
],
if (widget.allowCreate) ...[
const SizedBox(height: 10),
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: _createLabel,
icon: const Icon(Icons.add, size: 18),
label: const Text('New label'),
),
),
],
],
void apply() {
Navigator.pop(
context,
LabelFilterSelection(
labelIds: Set<String>.of(_selected),
flow: widget.includeFlow ? _flow : TxFlowFilter.all,
barStyle: widget.includeBarStyle
? _barStyle
: LabelChartBarStyle.parallel,
invertExpensePerspective: widget.includeInvertChart &&
_flow == TxFlowFilter.all &&
_invertChart,
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 4, 24, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(widget.title, style: theme.textTheme.titleLarge),
const SizedBox(height: 4),
Text(
widget.subtitle,
style: theme.textTheme.bodyMedium?.copyWith(
color: colors.onSurfaceVariant,
),
),
),
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: TextField(
controller: _labelSearchController,
decoration: InputDecoration(
isDense: true,
hintText: 'Filter labels',
prefixIcon: const Icon(Icons.search, size: 20),
suffixIcon: needle.isEmpty
? null
: IconButton(
onPressed: () {
_labelSearchController.clear();
setState(() {});
},
icon: const Icon(Icons.clear, size: 18),
),
],
),
),
const SizedBox(height: 8),
Expanded(
child: ListView(
padding: const EdgeInsets.fromLTRB(24, 4, 24, 8),
children: [
if (widget.includeFlow) ...[
Text(
'Income / expense',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
_RectToggleBar<TxFlowFilter>(
value: _flow,
onChanged: (value) => setState(() => _flow = value),
options: const [
_RectToggleOption(
value: TxFlowFilter.all,
label: 'All',
),
onChanged: (_) => setState(() {}),
),
_RectToggleOption(
value: TxFlowFilter.income,
label: 'In',
icon: Icons.south_west,
),
_RectToggleOption(
value: TxFlowFilter.expense,
label: 'Out',
icon: Icons.north_east,
),
],
),
Tooltip(
message: allVisibleSelected ? 'Deselect all' : 'Select all',
child: Checkbox(
tristate: true,
value: allVisibleSelected
? true
: noneVisibleSelected
? false
: null,
onChanged: visibleIds.isEmpty
? null
: (_) {
setState(() {
if (allVisibleSelected) {
_selected.removeAll(visibleIds);
} else {
_selected.addAll(visibleIds);
}
});
},
if (widget.includeInvertChart &&
_flow == TxFlowFilter.all) ...[
const SizedBox(height: 8),
SwitchListTile(
contentPadding: EdgeInsets.zero,
dense: true,
title: const Text('Invert chart'),
subtitle: const Text(
'Expense view: spending up, income/refunds down',
),
value: _invertChart,
onChanged: (value) => setState(() => _invertChart = value),
),
),
],
const SizedBox(height: 14),
],
),
const SizedBox(height: 8),
Expanded(
child: _LabelChecklist(
if (widget.includeBarStyle) ...[
Text(
'Bar layout',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
_RectToggleBar<LabelChartBarStyle>(
value: _barStyle,
onChanged: (value) => setState(() => _barStyle = value),
options: const [
_RectToggleOption(
value: LabelChartBarStyle.parallel,
label: 'Parallel',
icon: Icons.view_week_outlined,
),
_RectToggleOption(
value: LabelChartBarStyle.stacked,
label: 'Stacked',
icon: Icons.layers_outlined,
),
],
),
const SizedBox(height: 14),
],
if (widget.allowCreate) ...[
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: _createLabel,
icon: const Icon(Icons.add, size: 18),
label: const Text('New label'),
),
),
const SizedBox(height: 8),
],
Text(
'Labels',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: TextField(
controller: _labelSearchController,
decoration: InputDecoration(
isDense: true,
hintText: 'Filter labels',
prefixIcon: const Icon(Icons.search, size: 20),
suffixIcon: needle.isEmpty
? null
: IconButton(
onPressed: () {
_labelSearchController.clear();
setState(() {});
},
icon: const Icon(Icons.clear, size: 18),
),
),
onChanged: (_) => setState(() {}),
),
),
Tooltip(
message:
allVisibleSelected ? 'Deselect all' : 'Select all',
child: Checkbox(
tristate: true,
value: allVisibleSelected
? true
: noneVisibleSelected
? false
: null,
onChanged: visibleIds.isEmpty
? null
: (_) {
setState(() {
if (allVisibleSelected) {
_selected.removeAll(visibleIds);
} else {
_selected.addAll(visibleIds);
}
});
},
),
),
],
),
const SizedBox(height: 8),
_LabelChecklist(
entries: entries,
showUncategorized: showUncategorized,
selected: _selected,
onToggle: _toggle,
allowMove: widget.allowMove,
shrinkWrap: true,
),
),
],
],
),
),
),
actionsPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
actions: [
SizedBox(
width: double.maxFinite,
child: Row(
Padding(
padding: const EdgeInsets.fromLTRB(24, 4, 24, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
FilledButton(
onPressed: apply,
child: const Text('Apply'),
),
const SizedBox(width: 8),
Expanded(
flex: 2,
child: FilledButton(
onPressed: () => Navigator.pop(
context,
LabelFilterSelection(
labelIds: Set<String>.of(_selected),
flow: widget.includeFlow ? _flow : TxFlowFilter.all,
barStyle: widget.includeBarStyle
? _barStyle
: LabelChartBarStyle.parallel,
invertExpensePerspective: widget.includeInvertChart &&
_flow == TxFlowFilter.all &&
_invertChart,
),
),
child: const Text('Apply'),
),
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
],
),
@@ -488,6 +502,7 @@ class _TransactionFilterDialogState extends State<_TransactionFilterDialog> {
late final Set<String> _selected;
late TxFlowFilter _flow;
late bool _monthOnly;
late bool _recurringOnly;
late final TextEditingController _queryController;
late final TextEditingController _labelSearchController;
late final TextEditingController _exactAmountController;
@@ -501,6 +516,7 @@ class _TransactionFilterDialogState extends State<_TransactionFilterDialog> {
_selected = Set<String>.of(initial.labelIds);
_flow = initial.flow;
_monthOnly = initial.monthOnly;
_recurringOnly = initial.recurringOnly;
_queryController = TextEditingController(text: initial.query);
_labelSearchController = TextEditingController();
@@ -586,6 +602,7 @@ class _TransactionFilterDialogState extends State<_TransactionFilterDialog> {
amountRange: _amountResult,
query: _queryController.text.trim(),
monthOnly: _monthOnly,
recurringOnly: _recurringOnly,
),
);
}
@@ -701,6 +718,24 @@ class _TransactionFilterDialogState extends State<_TransactionFilterDialog> {
),
],
),
const SizedBox(height: 8),
CheckboxListTile(
contentPadding: EdgeInsets.zero,
dense: true,
value: _recurringOnly,
onChanged: (value) {
setState(() => _recurringOnly = value ?? false);
},
controlAffinity: ListTileControlAffinity.leading,
secondary: const Icon(Icons.event_repeat, size: 20),
title: const Text('Recurring only'),
subtitle: Text(
'Only transactions marked as recurring',
style: theme.textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
),
),
),
const SizedBox(height: 14),
Text('Amount', style: sectionStyle),
const SizedBox(height: 8),
+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),
);
},
),
);
}
}
+27
View File
@@ -0,0 +1,27 @@
/// Provides the active bottom-nav tab index for [MainShell].
library;
import 'package:flutter/material.dart';
class ShellTabScope extends InheritedWidget {
const ShellTabScope({
super.key,
required this.index,
required super.child,
});
final int index;
static int of(BuildContext context) {
final scope = context.dependOnInheritedWidgetOfExactType<ShellTabScope>();
assert(scope != null, 'ShellTabScope not found');
return scope!.index;
}
static int? maybeOf(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<ShellTabScope>()?.index;
}
@override
bool updateShouldNotify(ShellTabScope oldWidget) => index != oldWidget.index;
}