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