chart improvements, synch working

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