sync transactions with pocketbase
This commit is contained in:
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user