From 91d6dc9e54e57effdbed6ad0b63e247461717416 Mon Sep 17 00:00:00 2001 From: davmar Date: Wed, 9 Sep 2026 23:31:30 +0200 Subject: [PATCH] chart improvements, synch working --- lib/providers/tx_meta_provider.dart | 21 ++-- lib/screens/overview_screen.dart | 76 +++++++------- lib/utils/app_theme.dart | 1 - lib/widgets/label_filter_dialog.dart | 149 ++++++++++++++++----------- 4 files changed, 144 insertions(+), 103 deletions(-) diff --git a/lib/providers/tx_meta_provider.dart b/lib/providers/tx_meta_provider.dart index 92b0ae2..bc9ce7b 100644 --- a/lib/providers/tx_meta_provider.dart +++ b/lib/providers/tx_meta_provider.dart @@ -790,15 +790,24 @@ class TxMetaController extends ChangeNotifier { return false; } - /// OR filter: any selected label, and/or unlabeled when uncategorized is selected. + /// AND filter: every selected label must be on the tx. + /// + /// Uncategorized alone matches unlabeled txs. Uncategorized combined with + /// other labels matches nothing (a tx cannot be both). bool _matchesLabelFilter(TxMeta meta, Set labelIds) { final wantUncategorized = labelIds.contains(kUncategorizedFilterId); - if (wantUncategorized && !meta.hasLabel) return true; - for (final id in labelIds) { - if (id == kUncategorizedFilterId) continue; - if (meta.labelIds.contains(id)) return true; + final required = [ + for (final id in labelIds) + if (id != kUncategorizedFilterId) id, + ]; + if (wantUncategorized) { + if (required.isNotEmpty) return false; + return !meta.hasLabel; } - return false; + for (final id in required) { + if (!meta.labelIds.contains(id)) return false; + } + return true; } /// Labels currently used by [transactions]. diff --git a/lib/screens/overview_screen.dart b/lib/screens/overview_screen.dart index fc956d9..3797760 100644 --- a/lib/screens/overview_screen.dart +++ b/lib/screens/overview_screen.dart @@ -234,7 +234,6 @@ class _OverviewScreenState extends State { _period, includedLabelIds: _includedLabelIds, flow: _labelFlow, - invertExpensePerspective: _labelInvertChart, ); labelSpend = labels.spend; labelAmountByPeriod = labels.amountByPeriod; @@ -277,6 +276,7 @@ class _OverviewScreenState extends State { labelSpend: labelSpend, labelAmountByMonth: labelAmountByPeriod, labelBarStyle: _labelBarStyle, + invertChart: _labelFlow == TxFlowFilter.all && _labelInvertChart, currency: currency, onPickLabels: () => _pickChartLabels(context), includedLabelCount: _includedLabelIds.isEmpty && @@ -555,8 +555,9 @@ class _LabelAggregates { /// One pass over labeled amounts: totals, per-period series, and history. /// /// Amounts are summed with sign (refunds offset spend). For [TxFlowFilter.all] -/// the signed net is kept (can be negative). With [invertExpensePerspective], -/// signs flip so spending plots upward. For In/Out, bars use absolute nets. +/// the signed net is kept (can be negative). For In/Out, bars use absolute nets. +/// Chart-only invert is applied later in [_LabelMonthBars], not here — history +/// always follows bank transaction signs. _LabelAggregates _labelAggregates( List txs, TxMetaController meta, @@ -564,7 +565,6 @@ _LabelAggregates _labelAggregates( _PeriodMode mode, { Set includedLabelIds = const {}, TxFlowFilter flow = TxFlowFilter.expense, - bool invertExpensePerspective = false, }) { final keys = {for (final p in periods) _periodKey(p, mode)}; final allByLabel = {}; @@ -576,7 +576,6 @@ _LabelAggregates _labelAggregates( }; final filtering = includedLabelIds.isNotEmpty; final keepSigned = flow == TxFlowFilter.all; - final invert = keepSigned && invertExpensePerspective; for (final tx in txs) { if (meta.metaFor(tx).internalTransfer) continue; @@ -608,7 +607,7 @@ _LabelAggregates _labelAggregates( double displayAmount(double signed) { if (!keepSigned) return signed.abs(); - return invert ? -signed : signed; + return signed; } final allSpend = <_LabelSpend>[ @@ -756,6 +755,7 @@ class _ChartCard extends StatelessWidget { required this.labelSpend, required this.labelAmountByMonth, required this.labelBarStyle, + required this.invertChart, required this.currency, required this.onPickLabels, required this.includedLabelCount, @@ -776,6 +776,7 @@ class _ChartCard extends StatelessWidget { final List<_LabelSpend> labelSpend; final Map> labelAmountByMonth; final LabelChartBarStyle labelBarStyle; + final bool invertChart; final String currency; final VoidCallback onPickLabels; final int? includedLabelCount; @@ -951,6 +952,7 @@ class _ChartCard extends StatelessWidget { currency: currency, period: period, barStyle: labelBarStyle, + invertChart: invertChart, onMonthTap: onMonthTap, ), ), @@ -1118,6 +1120,7 @@ class _LabelMonthBars extends StatelessWidget { required this.currency, required this.period, required this.barStyle, + required this.invertChart, required this.onMonthTap, }); @@ -1129,8 +1132,11 @@ class _LabelMonthBars extends StatelessWidget { final String currency; final _PeriodMode period; final LabelChartBarStyle barStyle; + final bool invertChart; final void Function(DateTime month) onMonthTap; + double _plot(double value) => invertChart ? -value : value; + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -1172,10 +1178,11 @@ class _LabelMonthBars extends StatelessWidget { var pos = 0.0; var neg = 0.0; void accumulate(double value) { - if (value > 0) { - pos += value; - } else if (value < 0) { - neg += value; + final plotted = _plot(value); + if (plotted > 0) { + pos += plotted; + } else if (plotted < 0) { + neg += plotted; } } @@ -1187,11 +1194,11 @@ class _LabelMonthBars extends StatelessWidget { if (neg < minY) minY = neg; } else { for (final row in topLabels) { - final value = bucket[row.label.id] ?? 0; + final value = _plot(bucket[row.label.id] ?? 0); if (value > maxY) maxY = value; if (value < minY) minY = value; } - final other = otherFor(month); + final other = _plot(otherFor(month)); if (other > maxY) maxY = other; if (other < minY) minY = other; } @@ -1229,12 +1236,13 @@ class _LabelMonthBars extends StatelessWidget { var neg = 0.0; void addSegment(double value, Color color) { - if (value > 0) { - stackItems.add(BarChartRodStackItem(pos, pos + value, color)); - pos += value; - } else if (value < 0) { - stackItems.add(BarChartRodStackItem(neg + value, neg, color)); - neg += value; + final plotted = _plot(value); + if (plotted > 0) { + stackItems.add(BarChartRodStackItem(pos, pos + plotted, color)); + pos += plotted; + } else if (plotted < 0) { + stackItems.add(BarChartRodStackItem(neg + plotted, neg, color)); + neg += plotted; } } @@ -1277,16 +1285,16 @@ class _LabelMonthBars extends StatelessWidget { return [ for (final row in topLabels) BarChartRodData( - toY: bucket[row.label.id] ?? 0, + toY: _plot(bucket[row.label.id] ?? 0), width: rodWidth, - borderRadius: rodRadius(bucket[row.label.id] ?? 0), + borderRadius: rodRadius(_plot(bucket[row.label.id] ?? 0)), color: row.label.color, ), if (hasOther) BarChartRodData( - toY: otherFor(month), + toY: _plot(otherFor(month)), width: rodWidth, - borderRadius: rodRadius(otherFor(month)), + borderRadius: rodRadius(_plot(otherFor(month))), color: otherColor, ), ]; @@ -1342,7 +1350,7 @@ class _LabelMonthBars extends StatelessWidget { return BarTooltipItem( '${_periodAxisLabel(month, period)}\n' '$name\n' - '${formatMoney(rod.toY, currency)}', + '${formatMoney(invertChart ? -rod.toY : rod.toY, currency)}', TextStyle(color: colors.onInverseSurface), ); }, @@ -1627,19 +1635,17 @@ class _LabelHistoryCard extends StatelessWidget { /// Display amount + color for history rows. /// - /// Keeps the original `-amount` sign convention; for All, color follows the - /// displayed value (positive → green, negative → red). + /// Keep the aggregate sign (All already signed; In/Out use abs then restore + /// bank-like sign). Color follows the displayed value. (double display, Color color) _styleFor(double amount) { - switch (flow) { - case TxFlowFilter.expense: - case TxFlowFilter.income: - return (-amount.abs(), _kExpenseColor); - case TxFlowFilter.all: - final display = -amount; - if (display > 0) return (display, _kIncomeColor); - if (display < 0) return (display, _kExpenseColor); - return (0, _kExpenseColor); - } + final signed = switch (flow) { + TxFlowFilter.expense => -amount.abs(), + TxFlowFilter.income => amount.abs(), + TxFlowFilter.all => amount, + }; + if (signed > 0) return (signed, _kIncomeColor); + if (signed < 0) return (signed, _kExpenseColor); + return (0, _kExpenseColor); } @override diff --git a/lib/utils/app_theme.dart b/lib/utils/app_theme.dart index 5695ec8..91ff37b 100644 --- a/lib/utils/app_theme.dart +++ b/lib/utils/app_theme.dart @@ -297,7 +297,6 @@ abstract final class AppTheme { inactiveTrackColor: colorScheme.outlineVariant.withValues(alpha: 0.5), thumbColor: colorScheme.primary, overlayColor: colorScheme.primary.withValues(alpha: 0.16), - rangeThumbShape: const RoundRangeSliderThumbShape(enabledThumbRadius: 8), trackHeight: 4, ), checkboxTheme: CheckboxThemeData( diff --git a/lib/widgets/label_filter_dialog.dart b/lib/widgets/label_filter_dialog.dart index 301f363..2964b6e 100644 --- a/lib/widgets/label_filter_dialog.dart +++ b/lib/widgets/label_filter_dialog.dart @@ -491,9 +491,8 @@ class _TransactionFilterDialogState extends State<_TransactionFilterDialog> { late final TextEditingController _queryController; late final TextEditingController _labelSearchController; late final TextEditingController _exactAmountController; - late double _amountCeil; - late RangeValues _amountRange; - var _rangeTouched = false; + late final TextEditingController _minAmountController; + late final TextEditingController _maxAmountController; @override void initState() { @@ -505,28 +504,20 @@ class _TransactionFilterDialogState extends State<_TransactionFilterDialog> { _queryController = TextEditingController(text: initial.query); _labelSearchController = TextEditingController(); - var maxAbs = 0.0; - for (final tx in widget.transactions) { - final abs = tx.amount.abs(); - if (abs > maxAbs) maxAbs = abs; - } - _amountCeil = maxAbs <= 0 ? 100.0 : (maxAbs * 1.05).ceilToDouble(); - if (_amountCeil < 1) _amountCeil = 1; - final exact = initial.amountRange.exact; _exactAmountController = TextEditingController( text: exact == null ? '' : _formatAmountInput(exact), ); - - final min = initial.amountRange.min ?? 0; - final max = initial.amountRange.max ?? _amountCeil; - _amountRange = RangeValues( - min.clamp(0, _amountCeil), - max.clamp(0, _amountCeil), + _minAmountController = TextEditingController( + text: exact == null && initial.amountRange.min != null + ? _formatAmountInput(initial.amountRange.min!) + : '', + ); + _maxAmountController = TextEditingController( + text: exact == null && initial.amountRange.max != null + ? _formatAmountInput(initial.amountRange.max!) + : '', ); - _rangeTouched = - exact == null && (initial.amountRange.min != null || - initial.amountRange.max != null); } @override @@ -534,6 +525,8 @@ class _TransactionFilterDialogState extends State<_TransactionFilterDialog> { _queryController.dispose(); _labelSearchController.dispose(); _exactAmountController.dispose(); + _minAmountController.dispose(); + _maxAmountController.dispose(); super.dispose(); } @@ -544,12 +537,21 @@ class _TransactionFilterDialogState extends State<_TransactionFilterDialog> { return value.toStringAsFixed(2); } - double? _parseExactAmount() { - final raw = _exactAmountController.text.trim().replaceAll(',', '.'); + double? _parseAmountField(TextEditingController controller) { + final raw = controller.text.trim().replaceAll(',', '.'); if (raw.isEmpty) return null; return double.tryParse(raw); } + void _clearRangeFields() { + _minAmountController.clear(); + _maxAmountController.clear(); + } + + void _clearExactField() { + _exactAmountController.clear(); + } + void _toggle(String id, bool selected) { setState(() { if (selected) { @@ -561,15 +563,18 @@ class _TransactionFilterDialogState extends State<_TransactionFilterDialog> { } TxAmountRange get _amountResult { - final exact = _parseExactAmount(); + final exact = _parseAmountField(_exactAmountController); if (exact != null && exact >= 0) { return TxAmountRange(exact: exact); } - if (!_rangeTouched) return const TxAmountRange(); - final start = _amountRange.start <= 0.009 ? null : _amountRange.start; - final end = - _amountRange.end >= _amountCeil - 0.009 ? null : _amountRange.end; - return TxAmountRange(min: start, max: end); + final min = _parseAmountField(_minAmountController); + final max = _parseAmountField(_maxAmountController); + if (min == null && max == null) return const TxAmountRange(); + // Swap if user typed max < min. + if (min != null && max != null && min > max) { + return TxAmountRange(min: max, max: min); + } + return TxAmountRange(min: min, max: max); } void _apply() { @@ -721,7 +726,7 @@ class _TransactionFilterDialogState extends State<_TransactionFilterDialog> { : IconButton( tooltip: 'Clear exact', onPressed: () { - _exactAmountController.clear(); + _clearExactField(); setState(() {}); }, icon: const Icon(Icons.clear, size: 18), @@ -729,8 +734,9 @@ class _TransactionFilterDialogState extends State<_TransactionFilterDialog> { ), onChanged: (_) { setState(() { - _rangeTouched = false; - _amountRange = RangeValues(0, _amountCeil); + if (_exactAmountController.text.trim().isNotEmpty) { + _clearRangeFields(); + } }); }, ), @@ -742,43 +748,64 @@ class _TransactionFilterDialogState extends State<_TransactionFilterDialog> { fontWeight: FontWeight.w600, ), ), - const SizedBox(height: 4), - Text( - _rangeTouched - ? '${_amountRange.start.toStringAsFixed(0)} – ${_amountRange.end.toStringAsFixed(0)}' - : 'Any amount', - style: theme.textTheme.bodySmall?.copyWith( - color: colors.onSurfaceVariant, - ), - ), - RangeSlider( - values: _amountRange, - min: 0, - max: _amountCeil, - labels: RangeLabels( - _amountRange.start.toStringAsFixed(0), - _amountRange.end.toStringAsFixed(0), - ), - onChanged: (values) { - setState(() { - _exactAmountController.clear(); - _rangeTouched = true; - _amountRange = values; - }); - }, + const SizedBox(height: 6), + Row( + children: [ + Expanded( + child: TextField( + controller: _minAmountController, + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + decoration: const InputDecoration( + isDense: true, + labelText: 'Min', + hintText: '0', + ), + onChanged: (_) { + setState(() { + if (_minAmountController.text.trim().isNotEmpty || + _maxAmountController.text.trim().isNotEmpty) { + _clearExactField(); + } + }); + }, + ), + ), + const SizedBox(width: 12), + Expanded( + child: TextField( + controller: _maxAmountController, + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + decoration: const InputDecoration( + isDense: true, + labelText: 'Max', + hintText: '∞', + ), + onChanged: (_) { + setState(() { + if (_minAmountController.text.trim().isNotEmpty || + _maxAmountController.text.trim().isNotEmpty) { + _clearExactField(); + } + }); + }, + ), + ), + ], ), Align( alignment: Alignment.centerRight, child: TextButton( - onPressed: !_rangeTouched + onPressed: _minAmountController.text.trim().isEmpty && + _maxAmountController.text.trim().isEmpty ? null : () { - setState(() { - _rangeTouched = false; - _amountRange = RangeValues(0, _amountCeil); - }); + setState(_clearRangeFields); }, - child: const Text('Reset range'), + child: const Text('Clear range'), ), ), const SizedBox(height: 8),