From 34ef156538623643268d6d3fd43e70a6bfb0c7f3 Mon Sep 17 00:00:00 2001 From: davmar Date: Fri, 11 Sep 2026 13:20:48 +0200 Subject: [PATCH] fix tap on chart 2 --- lib/screens/overview_screen.dart | 691 ++++++++++++------------------- 1 file changed, 267 insertions(+), 424 deletions(-) diff --git a/lib/screens/overview_screen.dart b/lib/screens/overview_screen.dart index 30fd913..1baa498 100644 --- a/lib/screens/overview_screen.dart +++ b/lib/screens/overview_screen.dart @@ -1431,172 +1431,175 @@ class _ChartCard extends StatelessWidget { final hasLegend = metric == OverviewMetricMode.flow || labelSpend.isNotEmpty; + final radius = BorderRadius.circular(compact ? 16 : 20); + return Card( color: colors.surfaceContainer, elevation: 0, margin: EdgeInsets.zero, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(compact ? 16 : 20), - ), - child: Padding( - padding: EdgeInsets.fromLTRB( - compact ? 12 : 14, - compact ? 10 : 12, - compact ? 10 : 14, - compact ? 12 : 16, - ), - child: Column( - children: [ - if (compact) ...[ - Row( - children: [ - if (onPeriodModeChanged != null) - SegmentedButton( - showSelectedIcon: false, - style: _compactSegmentStyle, - segments: const [ - ButtonSegment( - value: OverviewPeriodMode.monthly, - label: Text('Month'), - ), - ButtonSegment( - value: OverviewPeriodMode.yearly, - label: Text('Year'), - ), - ], - selected: {period}, - onSelectionChanged: (selection) { - onPeriodModeChanged!(selection.first); - }, - ), - if (heading != null) ...[ - const SizedBox(width: 8), - Expanded( - child: Text( - heading!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - ), - ] else - const Spacer(), - if (showFilter) ...[ - const SizedBox(width: 4), - _filterButton(colors), - ], - if (onEdit != null) ...[ - const SizedBox(width: 2), - _editButton(colors, compact: true), - ], - ], - ), - const SizedBox(height: 4), - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( - child: _periodControls(theme, compact: true), - ), - if (hasLegend) ...[ - const SizedBox(width: 8), - Flexible(child: legend), - ], - ], - ), - const SizedBox(height: 6), - ] else ...[ - if (heading != null) ...[ - Align( - alignment: Alignment.centerLeft, - child: Text( - heading!, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - ), - const SizedBox(height: 8), - ], - if (period == OverviewPeriodMode.monthly) + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder(borderRadius: radius), + // Whole card opens the chart filter; nested buttons/legend keep their own taps. + child: InkWell( + onTap: onChartTap, + borderRadius: radius, + child: Padding( + padding: EdgeInsets.fromLTRB( + compact ? 12 : 14, + compact ? 10 : 12, + compact ? 10 : 14, + compact ? 12 : 16, + ), + child: Column( + children: [ + if (compact) ...[ Row( children: [ - IconButton( - tooltip: 'Previous year', - onPressed: canGoBack ? onBack : null, - visualDensity: const VisualDensity( - horizontal: -4, - vertical: -4, + if (onPeriodModeChanged != null) + SegmentedButton( + showSelectedIcon: false, + style: _compactSegmentStyle, + segments: const [ + ButtonSegment( + value: OverviewPeriodMode.monthly, + label: Text('Month'), + ), + ButtonSegment( + value: OverviewPeriodMode.yearly, + label: Text('Year'), + ), + ], + selected: {period}, + onSelectionChanged: (selection) { + onPeriodModeChanged!(selection.first); + }, ), - constraints: const BoxConstraints( - minWidth: 32, - minHeight: 32, - ), - padding: EdgeInsets.zero, - icon: const Icon(Icons.chevron_left, size: 22), - ), - Expanded( - child: Text( - periodTitle, - textAlign: TextAlign.center, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, + if (heading != null) ...[ + const SizedBox(width: 8), + Expanded( + child: Text( + heading!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), ), ), - ), - IconButton( - tooltip: 'Next year', - onPressed: canGoForward ? onForward : null, - visualDensity: const VisualDensity( - horizontal: -4, - vertical: -4, - ), - constraints: const BoxConstraints( - minWidth: 32, - minHeight: 32, - ), - padding: EdgeInsets.zero, - icon: const Icon(Icons.chevron_right, size: 22), - ), + ] else + const Spacer(), if (showFilter) ...[ - const SizedBox(width: 2), + const SizedBox(width: 4), _filterButton(colors), ], + if (onEdit != null) ...[ + const SizedBox(width: 2), + _editButton(colors, compact: true), + ], ], - ) - else + ), + const SizedBox(height: 4), Row( + crossAxisAlignment: CrossAxisAlignment.center, children: [ - SizedBox(width: showFilter ? 36 : 0), Expanded( - child: Text( - periodTitle, - textAlign: TextAlign.center, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, - ), + child: _periodControls(theme, compact: true), + ), + if (hasLegend) ...[ + const SizedBox(width: 8), + Flexible(child: legend), + ], + ], + ), + const SizedBox(height: 6), + ] else ...[ + if (heading != null) ...[ + Align( + alignment: Alignment.centerLeft, + child: Text( + heading!, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, ), ), - if (showFilter) - _filterButton(colors) - else - const SizedBox(width: 0), - ], - ), - if (hasLegend) ...[ - Align( - alignment: Alignment.centerRight, - child: legend, - ), - const SizedBox(height: 8), + ), + const SizedBox(height: 8), + ], + if (period == OverviewPeriodMode.monthly) + Row( + children: [ + IconButton( + tooltip: 'Previous year', + onPressed: canGoBack ? onBack : null, + visualDensity: const VisualDensity( + horizontal: -4, + vertical: -4, + ), + constraints: const BoxConstraints( + minWidth: 32, + minHeight: 32, + ), + padding: EdgeInsets.zero, + icon: const Icon(Icons.chevron_left, size: 22), + ), + Expanded( + child: Text( + periodTitle, + textAlign: TextAlign.center, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + IconButton( + tooltip: 'Next year', + onPressed: canGoForward ? onForward : null, + visualDensity: const VisualDensity( + horizontal: -4, + vertical: -4, + ), + constraints: const BoxConstraints( + minWidth: 32, + minHeight: 32, + ), + padding: EdgeInsets.zero, + icon: const Icon(Icons.chevron_right, size: 22), + ), + if (showFilter) ...[ + const SizedBox(width: 2), + _filterButton(colors), + ], + ], + ) + else + Row( + children: [ + SizedBox(width: showFilter ? 36 : 0), + Expanded( + child: Text( + periodTitle, + textAlign: TextAlign.center, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + if (showFilter) + _filterButton(colors) + else + const SizedBox(width: 0), + ], + ), + if (hasLegend) ...[ + Align( + alignment: Alignment.centerRight, + child: legend, + ), + const SizedBox(height: 8), + ], ], - ], - SizedBox( - height: chartHeight, - child: _TappableChartArea( - onChartTap: onChartTap, + SizedBox( + height: chartHeight, child: metric == OverviewMetricMode.flow ? _FlowBars( months: months, @@ -1618,77 +1621,14 @@ class _ChartCard extends StatelessWidget { onChartTap: onChartTap, ), ), - ), - ], + ], + ), ), ), ); } } -/// Lets empty-chart taps open the chart filter without double-firing when a bar -/// was just selected (fl_chart + parent [GestureDetector] both see the tap). -class _TappableChartArea extends StatefulWidget { - const _TappableChartArea({ - required this.onChartTap, - required this.child, - }); - - final VoidCallback onChartTap; - final Widget child; - - @override - State<_TappableChartArea> createState() => _TappableChartAreaState(); -} - -class _TappableChartAreaState extends State<_TappableChartArea> { - var _handling = false; - - void _runExclusive(VoidCallback action) { - if (_handling) return; - _handling = true; - action(); - Future.delayed(const Duration(milliseconds: 400), () { - if (mounted) _handling = false; - }); - } - - void openChartFilter() => _runExclusive(widget.onChartTap); - - void openBar(VoidCallback action) => _runExclusive(action); - - @override - Widget build(BuildContext context) { - return GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: openChartFilter, - child: _ChartTapScope( - openChartFilter: openChartFilter, - openBar: openBar, - child: widget.child, - ), - ); - } -} - -class _ChartTapScope extends InheritedWidget { - const _ChartTapScope({ - required this.openChartFilter, - required this.openBar, - required super.child, - }); - - final VoidCallback openChartFilter; - final void Function(VoidCallback action) openBar; - - static _ChartTapScope? maybeOf(BuildContext context) { - return context.getInheritedWidgetOfExactType<_ChartTapScope>(); - } - - @override - bool updateShouldNotify(_ChartTapScope oldWidget) => false; -} - class _FlowBars extends StatelessWidget { const _FlowBars({ required this.months, @@ -1742,133 +1682,104 @@ class _FlowBars extends StatelessWidget { ); } - return BarChart( - BarChartData( - maxY: safeMax, - groupsSpace: period == OverviewPeriodMode.yearly ? 16 : 10, - barTouchData: BarTouchData( - touchCallback: (event, response) { - if (event is! FlTapUpEvent) return; - final spot = response?.spot; - final index = spot?.touchedBarGroupIndex; - if (spot == null || - index == null || - index < 0 || - index >= months.length) { - // Parent GestureDetector also handles empty-area taps. - return; - } - final rodIndex = spot.touchedRodDataIndex; - // Rods: 0 = expense, 1 = income. - final flow = rodIndex == 1 - ? TxFlowFilter.income - : TxFlowFilter.expense; - final scope = _ChartTapScope.maybeOf(context); - if (scope != null) { - scope.openBar(() => onMonthTap(months[index], flow: flow)); - } else { - onMonthTap(months[index], flow: flow); - } - }, - touchTooltipData: BarTouchTooltipData( - getTooltipItem: (group, groupIndex, rod, rodIndex) { - final month = months[group.x.toInt()]; - final bucket = monthly[_periodKey(month, period)]!; - final isIncome = rodIndex == 1; - final value = isIncome ? bucket.income : bucket.expense; - return BarTooltipItem( - '${_periodAxisLabel(month, period)}\n' - '${isIncome ? 'Income' : 'Expense'}\n' - '${formatMoney(value, currency)}', - TextStyle(color: colors.onInverseSurface), - ); - }, - ), - ), - titlesData: FlTitlesData( - topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), - rightTitles: - const AxisTitles(sideTitles: SideTitles(showTitles: false)), - leftTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - reservedSize: 42, - interval: safeMax / 3, - getTitlesWidget: (value, meta) { - if (value <= 0) { - return Text('0', style: theme.textTheme.labelSmall); - } - if ((value - safeMax).abs() < 0.01) { - return const SizedBox.shrink(); - } - return Text( - _compactMoney(value), - style: theme.textTheme.labelSmall, - ); - }, - ), - ), - bottomTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - reservedSize: 28, - getTitlesWidget: (value, meta) { - final index = value.toInt(); - if (index < 0 || index >= months.length) { - return const SizedBox.shrink(); - } - if (period == OverviewPeriodMode.monthly && - index % 2 == 1 && - months.length > 8) { - return const SizedBox.shrink(); - } - return Padding( - padding: const EdgeInsets.only(top: 8), - child: Text( - _periodAxisLabel(months[index], period), - style: theme.textTheme.labelSmall, - ), - ); - }, - ), - ), - ), - gridData: FlGridData( - show: true, - drawVerticalLine: false, - horizontalInterval: safeMax / 3, - getDrawingHorizontalLine: (value) => FlLine( - color: colors.outlineVariant.withValues(alpha: 0.35), - strokeWidth: 1, - dashArray: const [4, 4], - ), - ), - borderData: FlBorderData(show: false), - barGroups: [ - for (var i = 0; i < months.length; i++) - BarChartGroupData( - x: i, - barsSpace: 3, - barRods: [ - BarChartRodData( - toY: monthly[_periodKey(months[i], period)]!.expense, - width: period == OverviewPeriodMode.yearly ? 14 : 9, - borderRadius: - const BorderRadius.vertical(top: Radius.circular(6)), - color: _kExpenseColor, + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onChartTap, + child: IgnorePointer( + // fl_chart otherwise owns the gesture arena and empty taps go nowhere. + // Tap opens this chart's filters; legend / history still open columns. + child: BarChart( + BarChartData( + maxY: safeMax, + groupsSpace: period == OverviewPeriodMode.yearly ? 16 : 10, + barTouchData: const BarTouchData(enabled: false), + titlesData: FlTitlesData( + topTitles: + const AxisTitles(sideTitles: SideTitles(showTitles: false)), + rightTitles: + const AxisTitles(sideTitles: SideTitles(showTitles: false)), + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 42, + interval: safeMax / 3, + getTitlesWidget: (value, meta) { + if (value <= 0) { + return Text('0', style: theme.textTheme.labelSmall); + } + if ((value - safeMax).abs() < 0.01) { + return const SizedBox.shrink(); + } + return Text( + _compactMoney(value), + style: theme.textTheme.labelSmall, + ); + }, ), - BarChartRodData( - toY: monthly[_periodKey(months[i], period)]!.income, - width: period == OverviewPeriodMode.yearly ? 14 : 9, - borderRadius: - const BorderRadius.vertical(top: Radius.circular(6)), - color: _kIncomeColor, + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 28, + getTitlesWidget: (value, meta) { + final index = value.toInt(); + if (index < 0 || index >= months.length) { + return const SizedBox.shrink(); + } + if (period == OverviewPeriodMode.monthly && + index % 2 == 1 && + months.length > 8) { + return const SizedBox.shrink(); + } + return Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + _periodAxisLabel(months[index], period), + style: theme.textTheme.labelSmall, + ), + ); + }, ), - ], + ), ), - ], + gridData: FlGridData( + show: true, + drawVerticalLine: false, + horizontalInterval: safeMax / 3, + getDrawingHorizontalLine: (value) => FlLine( + color: colors.outlineVariant.withValues(alpha: 0.35), + strokeWidth: 1, + dashArray: const [4, 4], + ), + ), + borderData: FlBorderData(show: false), + barGroups: [ + for (var i = 0; i < months.length; i++) + BarChartGroupData( + x: i, + barsSpace: 3, + barRods: [ + BarChartRodData( + toY: monthly[_periodKey(months[i], period)]!.expense, + width: period == OverviewPeriodMode.yearly ? 14 : 9, + borderRadius: + const BorderRadius.vertical(top: Radius.circular(6)), + color: _kExpenseColor, + ), + BarChartRodData( + toY: monthly[_periodKey(months[i], period)]!.income, + width: period == OverviewPeriodMode.yearly ? 14 : 9, + borderRadius: + const BorderRadius.vertical(top: Radius.circular(6)), + color: _kIncomeColor, + ), + ], + ), + ], + ), + duration: Duration.zero, + ), ), - duration: Duration.zero, ); } } @@ -2078,86 +1989,16 @@ class _LabelMonthBars extends StatelessWidget { final axisInterval = ((safeMax - safeMin) / 3).abs(); final interval = axisInterval <= 0 ? 1.0 : axisInterval; - return BarChart( - BarChartData( - minY: safeMin, - maxY: safeMax, - groupsSpace: period == OverviewPeriodMode.yearly ? 16 : 10, - barTouchData: BarTouchData( - touchCallback: (event, response) { - if (event is! FlTapUpEvent) return; - final spot = response?.spot; - final index = spot?.touchedBarGroupIndex; - if (spot == null || - index == null || - index < 0 || - index >= months.length) { - // Parent GestureDetector also handles empty-area taps. - return; - } - String? labelId; - if (stacked) { - final stackIdx = spot.touchedStackItemIndex; - if (stackIdx >= 0 && stackIdx < topLabels.length) { - labelId = topLabels[stackIdx].label.id; - } - } else { - final rodIdx = spot.touchedRodDataIndex; - if (rodIdx >= 0 && rodIdx < topLabels.length) { - labelId = topLabels[rodIdx].label.id; - } - // Last rod may be "Other" — open month with chart labels only. - } - final scope = _ChartTapScope.maybeOf(context); - if (scope != null) { - scope.openBar( - () => onMonthTap(months[index], labelId: labelId), - ); - } else { - onMonthTap(months[index], labelId: labelId); - } - }, - touchTooltipData: BarTouchTooltipData( - getTooltipItem: (group, groupIndex, rod, rodIndex) { - final month = months[group.x.toInt()]; - if (stacked && rod.rodStackItems.isNotEmpty) { - final bucket = - amountByMonth[_periodKey(month, period)] ?? const {}; - final buffer = StringBuffer( - '${_periodAxisLabel(month, period)}\n', - ); - var total = 0.0; - for (final row in topLabels) { - final amount = bucket[row.label.id] ?? 0; - if (amount == 0) continue; - total += amount; - buffer.writeln( - '${row.label.name}: ${formatMoney(amount, currency)}', - ); - } - final other = otherFor(month); - if (other != 0) { - total += other; - buffer.writeln('Other: ${formatMoney(other, currency)}'); - } - buffer.write('Total: ${formatMoney(total, currency)}'); - return BarTooltipItem( - buffer.toString(), - TextStyle(color: colors.onInverseSurface), - ); - } - final name = rodIndex < topLabels.length - ? topLabels[rodIndex].label.name - : 'Other'; - return BarTooltipItem( - '${_periodAxisLabel(month, period)}\n' - '$name\n' - '${formatMoney(invertChart ? -rod.toY : rod.toY, currency)}', - TextStyle(color: colors.onInverseSurface), - ); - }, - ), - ), + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onChartTap, + child: IgnorePointer( + child: BarChart( + BarChartData( + minY: safeMin, + maxY: safeMax, + groupsSpace: period == OverviewPeriodMode.yearly ? 16 : 10, + barTouchData: const BarTouchData(enabled: false), titlesData: FlTitlesData( topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), rightTitles: @@ -2220,16 +2061,18 @@ class _LabelMonthBars extends StatelessWidget { ), ), borderData: FlBorderData(show: false), - barGroups: [ - for (var i = 0; i < months.length; i++) - BarChartGroupData( - x: i, - barsSpace: stacked ? 0 : 2, - barRods: rodsFor(months[i]), - ), - ], + barGroups: [ + for (var i = 0; i < months.length; i++) + BarChartGroupData( + x: i, + barsSpace: stacked ? 0 : 2, + barRods: rodsFor(months[i]), + ), + ], + ), + duration: Duration.zero, + ), ), - duration: Duration.zero, ); } }