sync transactions with pocketbase

This commit is contained in:
davmar
2026-09-09 22:24:16 +02:00
parent c6d1fdb19e
commit 96627a830e
9 changed files with 1104 additions and 324 deletions
+214 -107
View File
@@ -35,15 +35,30 @@ class TransactionFilterSelection {
query.trim().isNotEmpty;
}
/// How label series are drawn on the overview chart.
enum LabelChartBarStyle {
/// Side-by-side bars per label (default).
parallel,
/// Single column with stacked label segments.
stacked,
}
/// Result of the simple label multi-select dialog.
class LabelFilterSelection {
const LabelFilterSelection({
required this.labelIds,
this.flow = TxFlowFilter.all,
this.barStyle = LabelChartBarStyle.parallel,
this.invertExpensePerspective = false,
});
final Set<String> labelIds;
final TxFlowFilter flow;
final LabelChartBarStyle barStyle;
/// When [flow] is All: flip signs so expenses plot upward (spend-positive).
final bool invertExpensePerspective;
}
/// Returns the selection, or `null` if cancelled.
@@ -58,6 +73,10 @@ Future<LabelFilterSelection?> showLabelFilterDialog(
bool allowCreate = false,
bool includeFlow = false,
TxFlowFilter initialFlow = TxFlowFilter.all,
bool includeBarStyle = false,
LabelChartBarStyle initialBarStyle = LabelChartBarStyle.parallel,
bool includeInvertChart = false,
bool initialInvertChart = false,
}) {
return showDialog<LabelFilterSelection>(
context: context,
@@ -72,6 +91,10 @@ Future<LabelFilterSelection?> showLabelFilterDialog(
allowCreate: allowCreate,
includeFlow: includeFlow,
initialFlow: initialFlow,
includeBarStyle: includeBarStyle,
initialBarStyle: initialBarStyle,
includeInvertChart: includeInvertChart,
initialInvertChart: initialInvertChart,
);
},
);
@@ -126,6 +149,10 @@ class _SimpleLabelFilterDialog extends StatefulWidget {
required this.allowCreate,
required this.includeFlow,
required this.initialFlow,
required this.includeBarStyle,
required this.initialBarStyle,
required this.includeInvertChart,
required this.initialInvertChart,
});
final List<TxLabel> available;
@@ -137,6 +164,10 @@ class _SimpleLabelFilterDialog extends StatefulWidget {
final bool allowCreate;
final bool includeFlow;
final TxFlowFilter initialFlow;
final bool includeBarStyle;
final LabelChartBarStyle initialBarStyle;
final bool includeInvertChart;
final bool initialInvertChart;
@override
State<_SimpleLabelFilterDialog> createState() =>
@@ -146,6 +177,8 @@ class _SimpleLabelFilterDialog extends StatefulWidget {
class _SimpleLabelFilterDialogState extends State<_SimpleLabelFilterDialog> {
late final Set<String> _selected;
late TxFlowFilter _flow;
late LabelChartBarStyle _barStyle;
late bool _invertChart;
late final TextEditingController _labelSearchController;
@override
@@ -153,6 +186,8 @@ class _SimpleLabelFilterDialogState extends State<_SimpleLabelFilterDialog> {
super.initState();
_selected = Set<String>.of(widget.initiallySelected);
_flow = widget.initialFlow;
_barStyle = widget.initialBarStyle;
_invertChart = widget.initialInvertChart;
_labelSearchController = TextEditingController();
}
@@ -208,8 +243,17 @@ class _SimpleLabelFilterDialogState extends State<_SimpleLabelFilterDialog> {
kUncategorizedFilterLabel.name.toLowerCase().contains(needle));
final media = MediaQuery.of(context);
final maxContentHeight =
((media.size.height - media.viewInsets.bottom) * 0.55)
.clamp(240.0, 560.0);
((media.size.height - media.viewInsets.bottom) * 0.72)
.clamp(320.0, 720.0);
final visibleIds = <String>[
for (final e in entries) e.label.id,
if (showUncategorized) kUncategorizedFilterId,
];
final selectedVisible = visibleIds.where(_selected.contains).length;
final allVisibleSelected =
visibleIds.isNotEmpty && selectedVisible == visibleIds.length;
final noneVisibleSelected = selectedVisible == 0;
return AlertDialog(
title: Text(widget.title),
@@ -219,72 +263,152 @@ class _SimpleLabelFilterDialogState extends State<_SimpleLabelFilterDialog> {
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,
),
],
),
],
const SizedBox(height: 10),
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),
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,
),
),
onChanged: (_) => setState(() {}),
),
if (widget.allowCreate) ...[
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: _createLabel,
icon: const Icon(Icons.add, size: 18),
label: const Text('New label'),
),
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'),
),
),
],
],
),
),
],
),
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),
),
),
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),
Expanded(
child: _LabelChecklist(
@@ -302,50 +426,33 @@ class _SimpleLabelFilterDialogState extends State<_SimpleLabelFilterDialog> {
actions: [
SizedBox(
width: double.maxFinite,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
child: Row(
children: [
Row(
children: [
Expanded(
child: TextButton(
onPressed: () {
setState(() {
_selected
..clear()
..addAll(entries.map((e) => e.label.id));
if (showUncategorized) {
_selected.add(kUncategorizedFilterId);
}
});
},
child: const Text('All'),
),
),
Expanded(
child: TextButton(
onPressed: () => setState(() => _selected.clear()),
child: const Text('None'),
),
),
Expanded(
child: TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
),
],
),
const SizedBox(height: 4),
FilledButton(
onPressed: () => Navigator.pop(
context,
LabelFilterSelection(
labelIds: Set<String>.of(_selected),
flow: widget.includeFlow ? _flow : TxFlowFilter.all,
),
Expanded(
child: TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
),
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'),
),
child: const Text('Apply'),
),
],
),